From ac4fd0320feb828a197dd47f601a899aab72cbe4 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 16:19:29 -0300 Subject: [PATCH 01/12] feat(app): grant the emergency surface to every pause authority --- app/src/hooks/useAdminAuthority.test.ts | 48 +++++++++++++++++++++++-- app/src/hooks/useAdminAuthority.ts | 7 +++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/app/src/hooks/useAdminAuthority.test.ts b/app/src/hooks/useAdminAuthority.test.ts index ce42341e..6c9d222d 100644 --- a/app/src/hooks/useAdminAuthority.test.ts +++ b/app/src/hooks/useAdminAuthority.test.ts @@ -21,6 +21,7 @@ const noHolders: RoleHolders = { "vault guardian": [], "oracle attester": [], "oracle guardian": [], + "oracle admin": [], "share-token compliance": [], "share-token admin": [], } @@ -72,7 +73,8 @@ describe("toSurfaces", () => { ["vault guardian", ["emergency"]], ["oracle guardian", ["emergency"]], ["share-token admin", ["emergency"]], - ["vault governance", ["configuration", "governance"]], + ["oracle admin", ["emergency"]], + ["vault governance", ["emergency", "configuration", "governance"]], ])("grants %s's surfaces to the wallet that holds it", (role, expected) => { const { surfaces, grantedBy, grants } = toSurfaces( investorAddress, @@ -295,7 +297,11 @@ const { oracle: vi.fn(), share_token: vi.fn(), }, - oracleMock: { get_role_member_count: vi.fn(), get_role_member: vi.fn() }, + oracleMock: { + get_role_member_count: vi.fn(), + get_role_member: vi.fn(), + get_admin: vi.fn(), + }, tokenMock: { get_role_member_count: vi.fn(), get_role_member: vi.fn(), @@ -347,6 +353,7 @@ describe("useAdminAuthority", () => { connectNavOracleMock.mockResolvedValue(oracleMock) connectShareTokenMock.mockResolvedValue(tokenMock) oracleMock.get_role_member_count.mockResolvedValue({ result: 0 }) + oracleMock.get_admin.mockResolvedValue({ result: null }) tokenMock.get_role_member_count.mockResolvedValue({ result: 0 }) tokenMock.get_admin.mockResolvedValue({ result: null }) tokenMock.identity_verifier.mockResolvedValue({ @@ -435,6 +442,43 @@ describe("useAdminAuthority", () => { ) }) + it("reads the oracle admin from the oracle's get_admin", async () => { + oracleMock.get_admin.mockResolvedValue({ result: investorAddress }) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + + expect(result.current.surfaces).toEqual(new Set(["cycle", "emergency"])) + expect(result.current.grantedBy.emergency).toEqual([ + { + role: "oracle admin", + authority: investorAddress, + standing: "signs-alone", + }, + ]) + }) + + it("looks up the signers of a multisig oracle admin", async () => { + vaultMock.manager.mockResolvedValue({ result: null }) + oracleMock.get_admin.mockResolvedValue({ result: multisig }) + accountSignersMock.mockResolvedValue(oneSignatureOf(1)) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + + expect(accountSignersMock).toHaveBeenCalledWith(multisig) + expect(result.current.surfaces).toEqual(new Set(["emergency"])) + }) + + it("reports unreadable when the oracle admin read fails", async () => { + oracleMock.get_admin.mockRejectedValue(new Error("simulation failed")) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("unreadable")) + + expect(result.current.surfaces).toEqual(new Set()) + }) + it("grants a signer of a multisig authority and looks each account up once", async () => { vaultMock.manager.mockResolvedValue({ result: null }) vaultMock.governance.mockResolvedValue({ result: multisig }) diff --git a/app/src/hooks/useAdminAuthority.ts b/app/src/hooks/useAdminAuthority.ts index c286431f..734d9888 100644 --- a/app/src/hooks/useAdminAuthority.ts +++ b/app/src/hooks/useAdminAuthority.ts @@ -64,7 +64,8 @@ const ROLE_SURFACES = { "vault guardian": ["emergency"], "oracle guardian": ["emergency"], "share-token admin": ["emergency"], - "vault governance": ["configuration", "governance"], + "oracle admin": ["emergency"], + "vault governance": ["emergency", "configuration", "governance"], } as const satisfies Record type RoleLabel = keyof typeof ROLE_SURFACES @@ -259,12 +260,14 @@ async function fetchAdminAuthority(wallet: string): Promise { const [ oracleAttesters, oracleGuardians, + oracleAdmin, tokenCompliance, tokenAdmin, identityVerifier, ] = await Promise.all([ readRoleMembers(oraclePromise, "attester"), readRoleMembers(oraclePromise, "guardian"), + readContract(async () => (await oraclePromise).get_admin()), readRoleMembers(tokenPromise, "compliance"), readContract(async () => (await tokenPromise).get_admin()), readContract(async () => (await tokenPromise).identity_verifier()), @@ -283,6 +286,7 @@ async function fetchAdminAuthority(wallet: string): Promise { guardian, oracleAttesters, oracleGuardians, + oracleAdmin, tokenCompliance, tokenAdmin, ] @@ -296,6 +300,7 @@ async function fetchAdminAuthority(wallet: string): Promise { "vault guardian": asHolders(guardian), "oracle attester": asMembers(oracleAttesters), "oracle guardian": asMembers(oracleGuardians), + "oracle admin": asHolders(oracleAdmin), "share-token compliance": asMembers(tokenCompliance), "share-token admin": asHolders(tokenAdmin), } From c0cba3c56841ac73ff527e160bd78e1ebfda5bc5 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 16:20:11 -0300 Subject: [PATCH 02/12] feat(app): pause and resume the vault, oracle and token --- app-lib/contracts.test.ts | 35 ++- app-lib/contracts.ts | 7 +- .../admin/CycleActionRail.module.css | 12 + .../components/admin/CycleActionRail.test.tsx | 17 ++ app/src/components/admin/CycleActionRail.tsx | 172 +++++++----- app/src/components/admin/CycleSection.tsx | 1 + .../admin/EmergencySection.module.css | 61 +++++ .../admin/EmergencySection.test.tsx | 249 +++++++++++++++++ app/src/components/admin/EmergencySection.tsx | 143 ++++++++++ .../vault/TransactionModal.test.tsx | 104 ++++++- app/src/components/vault/TransactionModal.tsx | 152 ++++++++++- app/src/config/clients.ts | 4 + app/src/hooks/useClearLatest.test.ts | 110 ++++++++ app/src/hooks/useClearLatest.ts | 25 ++ app/src/hooks/useOracleState.test.ts | 52 ++++ app/src/hooks/useOracleState.ts | 47 ++++ app/src/hooks/usePauseToken.test.ts | 108 ++++++++ app/src/hooks/usePauseToken.ts | 27 ++ app/src/hooks/usePauseVault.test.ts | 108 ++++++++ app/src/hooks/usePauseVault.ts | 27 ++ app/src/hooks/useRaiseRipcord.test.ts | 110 ++++++++ app/src/hooks/useRaiseRipcord.ts | 25 ++ app/src/hooks/useSetRipcord.test.ts | 111 ++++++++ app/src/hooks/useSetRipcord.ts | 25 ++ app/src/hooks/useTokenPaused.test.ts | 35 +++ app/src/hooks/useTokenPaused.ts | 28 ++ app/src/hooks/useUnpauseToken.test.ts | 108 ++++++++ app/src/hooks/useUnpauseToken.ts | 27 ++ app/src/hooks/useUnpauseVault.test.ts | 108 ++++++++ app/src/hooks/useUnpauseVault.ts | 27 ++ app/src/hooks/useVaultPaused.ts | 10 +- app/src/pages/AdminPanel.test.tsx | 62 ++++- app/src/pages/AdminPanel.tsx | 73 +++-- app/src/pages/actions.test.ts | 108 ++++++++ app/src/pages/actions.ts | 72 +++++ app/src/pages/cycle.test.ts | 54 ---- app/src/pages/cycle.ts | 79 +----- app/src/pages/emergency.test.ts | 256 ++++++++++++++++++ app/src/pages/emergency.ts | 236 ++++++++++++++++ 39 files changed, 2795 insertions(+), 220 deletions(-) create mode 100644 app/src/components/admin/EmergencySection.module.css create mode 100644 app/src/components/admin/EmergencySection.test.tsx create mode 100644 app/src/components/admin/EmergencySection.tsx create mode 100644 app/src/hooks/useClearLatest.test.ts create mode 100644 app/src/hooks/useClearLatest.ts create mode 100644 app/src/hooks/useOracleState.test.ts create mode 100644 app/src/hooks/useOracleState.ts create mode 100644 app/src/hooks/usePauseToken.test.ts create mode 100644 app/src/hooks/usePauseToken.ts create mode 100644 app/src/hooks/usePauseVault.test.ts create mode 100644 app/src/hooks/usePauseVault.ts create mode 100644 app/src/hooks/useRaiseRipcord.test.ts create mode 100644 app/src/hooks/useRaiseRipcord.ts create mode 100644 app/src/hooks/useSetRipcord.test.ts create mode 100644 app/src/hooks/useSetRipcord.ts create mode 100644 app/src/hooks/useTokenPaused.test.ts create mode 100644 app/src/hooks/useTokenPaused.ts create mode 100644 app/src/hooks/useUnpauseToken.test.ts create mode 100644 app/src/hooks/useUnpauseToken.ts create mode 100644 app/src/hooks/useUnpauseVault.test.ts create mode 100644 app/src/hooks/useUnpauseVault.ts create mode 100644 app/src/pages/actions.test.ts create mode 100644 app/src/pages/actions.ts create mode 100644 app/src/pages/emergency.test.ts create mode 100644 app/src/pages/emergency.ts diff --git a/app-lib/contracts.test.ts b/app-lib/contracts.test.ts index a9fa40e9..0d5f1014 100644 --- a/app-lib/contracts.test.ts +++ b/app-lib/contracts.test.ts @@ -8,7 +8,11 @@ vi.mock("@stellar/stellar-sdk/contract", async (importOriginal) => { return { ...actual, Client: { from: fromMock } } }) -import { connectAsyncVault, connectNavOracle } from "./contracts" +import { + connectAsyncVault, + connectNavOracle, + connectShareToken, +} from "./contracts" describe("connectAsyncVault", () => { beforeEach(() => fromMock.mockClear()) @@ -67,3 +71,32 @@ describe("connectNavOracle", () => { expect(options.signTransaction).toBe(signTransaction) }) }) + +describe("connectShareToken", () => { + beforeEach(() => fromMock.mockClear()) + + it("builds a read client with no publicKey or signTransaction when no signer is given", async () => { + fromMock.mockResolvedValue({}) + + await connectShareToken("CTOKEN") + + const options = fromMock.mock.calls[0]?.[0] + expect(options.contractId).toBe("CTOKEN") + expect(options.publicKey).toBeUndefined() + expect(options.signTransaction).toBeUndefined() + }) + + it("carries the connected address and signer when one is given", async () => { + fromMock.mockResolvedValue({}) + const signTransaction = vi.fn() + + await connectShareToken("CTOKEN", { + publicKey: "GTOKENADMIN", + signTransaction, + }) + + const options = fromMock.mock.calls[0]?.[0] + expect(options.publicKey).toBe("GTOKENADMIN") + expect(options.signTransaction).toBe(signTransaction) + }) +}) diff --git a/app-lib/contracts.ts b/app-lib/contracts.ts index 6f957666..c348cd80 100644 --- a/app-lib/contracts.ts +++ b/app-lib/contracts.ts @@ -68,8 +68,11 @@ export const connectIdentityVerifier = ( ): Promise => Client.from(clientOptions(contractId)) -export const connectShareToken = (contractId: string): Promise => - Client.from(clientOptions(contractId)) +export const connectShareToken = ( + contractId: string, + signer?: Signer, +): Promise => + Client.from(clientOptions(contractId, signer)) export const connectAsset = (contractId: string): Promise => Client.from(clientOptions(contractId)) diff --git a/app/src/components/admin/CycleActionRail.module.css b/app/src/components/admin/CycleActionRail.module.css index 3e6cc473..6af65d6e 100644 --- a/app/src/components/admin/CycleActionRail.module.css +++ b/app/src/components/admin/CycleActionRail.module.css @@ -140,6 +140,7 @@ } .actionPrimary, +.actionDanger, .actionUnavailable { min-height: 48px; padding: 13px 18px; @@ -161,6 +162,17 @@ color: var(--vault-action); } +.actionDanger { + border: 1px solid var(--vault-danger); + background: var(--vault-danger); + color: var(--vault-surface-card); +} + +.actionDanger:hover:not(:disabled) { + background: var(--vault-danger-fill); + color: var(--vault-danger); +} + .actionUnavailable { border: 1px solid var(--vault-border-hairline); background: var(--vault-surface-inset); diff --git a/app/src/components/admin/CycleActionRail.test.tsx b/app/src/components/admin/CycleActionRail.test.tsx index 1174d3bd..ed79aa17 100644 --- a/app/src/components/admin/CycleActionRail.test.tsx +++ b/app/src/components/admin/CycleActionRail.test.tsx @@ -96,6 +96,7 @@ const renderRail = ( ) => render( { rerender( { rerender( { expect(screen.queryByText("No actions available.")).toBeNull() }) + it("names the surface it serves when its state could not be read", () => { + render( + , + ) + + expect(screen.getByText("Could not read the emergency state.")).toBeTruthy() + }) + it("lists each condition as met or not met", () => { renderRail([fulfillAction]) expand("Fulfill epoch") diff --git a/app/src/components/admin/CycleActionRail.tsx b/app/src/components/admin/CycleActionRail.tsx index 80933364..f53ce07d 100644 --- a/app/src/components/admin/CycleActionRail.tsx +++ b/app/src/components/admin/CycleActionRail.tsx @@ -2,17 +2,16 @@ import React, { useState } from "react" import { type ActionField as ActionFieldSpec, type Condition, - type CycleAction, - type CycleActionId, + type RailAction, type Readiness, readiness, readyCount, -} from "../../pages/cycle" +} from "../../pages/actions" import typeStyles from "../../styles/type.module.css" import Skeleton from "../Skeleton" import styles from "./CycleActionRail.module.css" -type OnInput = (action: CycleAction, value: string) => void +type OnInput = (action: A, value: string) => void const conditionValue = (condition: Condition): string => { if (condition.met === null) return "—" @@ -25,12 +24,17 @@ const summaryOf = (state: Readiness): string => { return `${state.count} unmet` } -const ActionField: React.FC<{ - action: CycleAction +function ActionField({ + id, + action, + field, + onInput, +}: { + id: string + action: A field: ActionFieldSpec - onInput: OnInput | undefined -}> = ({ action, field, onInput }) => { - const id = `cycle-${action.id}-field` + onInput: OnInput | undefined +}) { return (
({ + fieldId, + action, + onRun, + onInput, +}: { + fieldId: string + action: A + onRun: (action: A) => void + onInput: OnInput | undefined +}) { + return ( + <> + {action.field !== undefined && ( + + )} +
    + {action.conditions.map((condition) => ( +
  • + + {condition.label} + + + {conditionValue(condition)} + +
  • + ))} +
+ {action.note !== undefined && ( +

{action.note}

+ )} + {action.outcome !== null && ( +

+ {action.outcome} +

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

+ {action.unavailable} +

+ )} + + ) +} + +function CycleActionRail
({ + scope, + actions, + pending, + unreadable, + onRun, + onInput, +}: { + scope: string + actions: A[] pending: boolean unreadable: boolean - onRun: (action: CycleAction) => void - onInput?: OnInput -}> = ({ actions, pending, unreadable, onRun, onInput }) => { - const [open, setOpen] = useState() + onRun: (action: A) => void + onInput?: OnInput +}) { + const [open, setOpen] = useState() if (open === undefined && actions.length > 0) setOpen(actions.find((action) => action.enabled)?.id ?? null) @@ -137,7 +168,7 @@ const CycleActionRail: React.FC<{ ) : (

{unreadable - ? "Could not read the cycle state." + ? `Could not read the ${scope} state.` : "No actions available."}

))} @@ -146,7 +177,7 @@ const CycleActionRail: React.FC<{ {actions.map((action) => { const state = readiness(action) const expanded = open === action.id - const id = `cycle-${action.id}-action` + const id = `${scope}-${action.id}-action` return (
  • ) diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index a8e93eaa..3958e5ee 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -187,6 +187,7 @@ const CycleSection: React.FC<{ activity={toActivityList(cycleEvents)} rail={ ({ + status: { status: "idle" } as TransactionStatus, + submit: vi.fn(() => true), + reset: vi.fn(), +}) + +const { + useVaultPausedMock, + useTokenPausedMock, + useOracleStateMock, + pauseVaultMock, + unpauseVaultMock, + raiseRipcordMock, + setRipcordMock, + clearLatestMock, + pauseTokenMock, + unpauseTokenMock, +} = vi.hoisted(() => { + const tx = () => ({ + status: { status: "idle" } as { status: string }, + submit: vi.fn(() => true), + reset: vi.fn(), + }) + return { + useVaultPausedMock: vi.fn(), + useTokenPausedMock: vi.fn(), + useOracleStateMock: vi.fn(), + pauseVaultMock: tx(), + unpauseVaultMock: tx(), + raiseRipcordMock: tx(), + setRipcordMock: tx(), + clearLatestMock: tx(), + pauseTokenMock: tx(), + unpauseTokenMock: tx(), + } +}) + +vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ + ...(await importOriginal()), + shortAddress: (address: string) => + `${address.slice(0, 4)}...${address.slice(-4)}`, +})) +vi.mock("../../hooks/useVaultPaused", () => ({ + useVaultPaused: useVaultPausedMock, +})) +vi.mock("../../hooks/useTokenPaused", () => ({ + useTokenPaused: useTokenPausedMock, +})) +vi.mock("../../hooks/useOracleState", () => ({ + useOracleState: useOracleStateMock, +})) +vi.mock("../../hooks/usePauseVault", () => ({ + usePauseVault: () => pauseVaultMock, +})) +vi.mock("../../hooks/useUnpauseVault", () => ({ + useUnpauseVault: () => unpauseVaultMock, +})) +vi.mock("../../hooks/useRaiseRipcord", () => ({ + useRaiseRipcord: () => raiseRipcordMock, +})) +vi.mock("../../hooks/useSetRipcord", () => ({ + useSetRipcord: () => setRipcordMock, +})) +vi.mock("../../hooks/useClearLatest", () => ({ + useClearLatest: () => clearLatestMock, +})) +vi.mock("../../hooks/usePauseToken", () => ({ + usePauseToken: () => pauseTokenMock, +})) +vi.mock("../../hooks/useUnpauseToken", () => ({ + useUnpauseToken: () => unpauseTokenMock, +})) + +const WALLET = "GWALLETADDRESS1234" + +const grantOf = (role: string): Grant => ({ + role, + authority: WALLET, + standing: "signs-alone", +}) + +const guardian = grantOf("vault guardian") +const oracleAdmin = grantOf("oracle admin") +const tokenAdmin = grantOf("share-token admin") + +const openRow = (name: string) => { + const toggle = screen + .getAllByRole("button", { name }) + .find((candidate) => candidate.hasAttribute("aria-expanded")) + if (toggle?.getAttribute("aria-expanded") === "false") fireEvent.click(toggle) +} + +const button = (name: string) => { + openRow(name) + return screen + .getAllByRole("button", { name }) + .find((candidate) => !candidate.hasAttribute("aria-expanded"))! +} + +const setState = (vault: string, token: string, oracle: string): void => { + useVaultPausedMock.mockReturnValue({ pause: vault }) + useTokenPausedMock.mockReturnValue({ pause: token }) + useOracleStateMock.mockReturnValue({ oracle }) +} + +describe("EmergencySection", () => { + beforeEach(() => { + vi.clearAllMocks() + for (const mock of [ + pauseVaultMock, + unpauseVaultMock, + raiseRipcordMock, + setRipcordMock, + clearLatestMock, + pauseTokenMock, + unpauseTokenMock, + ]) { + Object.assign(mock, transaction()) + } + setState("open", "open", "valid") + }) + + it("shows the vault, share token and oracle state first", () => { + setState("paused", "open", "paused") + render() + + const overview = screen.getByRole("region", { name: "Status" }) + expect(within(overview).getByText("Vault")).toBeTruthy() + expect(within(overview).getAllByText("Paused")).toHaveLength(2) + expect(within(overview).getByText("Open")).toBeTruthy() + expect(within(overview).getByText("Ripcord raised")).toBeTruthy() + }) + + it("offers the vault guardian only the pause, saying what it stops and what stays open", () => { + render() + + expect(button("Pause the vault").disabled).toBe(false) + expect( + screen.queryByRole("button", { name: "Unpause the vault", hidden: true }), + ).toBeNull() + expect( + screen.getByText( + "Stops new deposits, pricing and upgrades. Redemptions, claims, cancels and closing epochs stay open.", + ), + ).toBeTruthy() + }) + + it("submits the vault pause and follows it in the modal", () => { + const { rerender } = render( + , + ) + + fireEvent.click(button("Pause the vault")) + expect(pauseVaultMock.submit).toHaveBeenCalled() + + pauseVaultMock.status = { status: "preparing" } + rerender() + + expect( + screen.getByRole("heading", { name: "Preparing to pause the vault" }), + ).toBeTruthy() + }) + + it("raises the ripcord for an oracle admin through set_ripcord, shown as raising", () => { + const { rerender } = render( + , + ) + + fireEvent.click(button("Raise the ripcord")) + expect(setRipcordMock.submit).toHaveBeenCalledWith(true) + expect(raiseRipcordMock.submit).not.toHaveBeenCalled() + + setRipcordMock.status = { status: "preparing" } + rerender() + + expect( + screen.getByRole("heading", { name: "Preparing to raise the ripcord" }), + ).toBeTruthy() + }) + + it("lowers the ripcord and clears the latest price once raised", () => { + setState("open", "open", "paused") + const { rerender } = render( + , + ) + + fireEvent.click(button("Lower the ripcord")) + expect(setRipcordMock.submit).toHaveBeenCalledWith(false) + + setRipcordMock.status = { status: "preparing" } + rerender() + expect( + screen.getByRole("heading", { name: "Preparing to lower the ripcord" }), + ).toBeTruthy() + + fireEvent.click(button("Clear the latest price")) + expect(clearLatestMock.submit).toHaveBeenCalled() + }) + + it("holds the share-token unpause while the token is open", () => { + render() + + expect(button("Pause the share token").disabled).toBe(false) + expect(button("Unpause the share token").disabled).toBe(true) + fireEvent.click(button("Pause the share token")) + expect(pauseTokenMock.submit).toHaveBeenCalled() + }) + + it("shows no actions while the state is still being read", () => { + setState("checking", "open", "valid") + render() + + expect( + screen.queryAllByRole("button", { name: "Pause the vault" }), + ).toHaveLength(0) + }) + + it("shows the flow modal only while the surface is active", () => { + pauseVaultMock.status = { status: "preparing" } + const { rerender } = render( + , + ) + expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1) + + rerender( + , + ) + expect(screen.queryAllByRole("dialog", { hidden: true })).toHaveLength(0) + expect(pauseVaultMock.reset).not.toHaveBeenCalled() + }) + + it("polls the state only while the surface is active", () => { + const { rerender } = render( + , + ) + expect(useVaultPausedMock).toHaveBeenLastCalledWith({ poll: false }) + expect(useTokenPausedMock).toHaveBeenLastCalledWith({ poll: false }) + expect(useOracleStateMock).toHaveBeenLastCalledWith({ poll: false }) + + rerender() + expect(useVaultPausedMock).toHaveBeenLastCalledWith({ poll: true }) + }) +}) diff --git a/app/src/components/admin/EmergencySection.tsx b/app/src/components/admin/EmergencySection.tsx new file mode 100644 index 00000000..9beb7343 --- /dev/null +++ b/app/src/components/admin/EmergencySection.tsx @@ -0,0 +1,143 @@ +import React from "react" +import { type Grant } from "../../hooks/useAdminAuthority" +import { useClearLatest } from "../../hooks/useClearLatest" +import { type TransactionStatus } from "../../hooks/useContractTransaction" +import { useOracleState } from "../../hooks/useOracleState" +import { usePauseToken } from "../../hooks/usePauseToken" +import { usePauseVault } from "../../hooks/usePauseVault" +import { usePendingTransaction } from "../../hooks/usePendingTransaction" +import { useRaiseRipcord } from "../../hooks/useRaiseRipcord" +import { useSetRipcord } from "../../hooks/useSetRipcord" +import { useTokenPaused } from "../../hooks/useTokenPaused" +import { useUnpauseToken } from "../../hooks/useUnpauseToken" +import { useUnpauseVault } from "../../hooks/useUnpauseVault" +import { useVaultPaused } from "../../hooks/useVaultPaused" +import { + type EmergencyAction, + type EmergencyState, + toEmergencyActions, + toEmergencyTiles, +} from "../../pages/emergency" +import typeStyles from "../../styles/type.module.css" +import MetricsStrip from "../vault/MetricsStrip" +import TransactionModal, { + type TransactionModalProps, +} from "../vault/TransactionModal" +import CycleActionRail from "./CycleActionRail" +import styles from "./EmergencySection.module.css" + +const RAISE = "raise" +const LOWER = "lower" + +const EmergencySection: React.FC<{ + grants: Grant[] + wallet: string + contracts?: React.ReactNode + active?: boolean +}> = ({ grants, wallet, contracts, active = true }) => { + const { pause: vault } = useVaultPaused({ poll: active }) + const { pause: token } = useTokenPaused({ poll: active }) + const { oracle } = useOracleState({ poll: active }) + const pauseVault = usePendingTransaction(usePauseVault()) + const unpauseVault = usePendingTransaction(useUnpauseVault()) + const raiseRipcord = usePendingTransaction(useRaiseRipcord()) + const setRipcord = usePendingTransaction(useSetRipcord()) + const clearLatest = usePendingTransaction(useClearLatest()) + const pauseToken = usePendingTransaction(usePauseToken()) + const unpauseToken = usePendingTransaction(useUnpauseToken()) + + const state: EmergencyState = { vault, token, oracle } + const actions = toEmergencyActions(state, grants, wallet) + + const run = (action: EmergencyAction) => { + switch (action.id) { + case "pause-vault": + pauseVault.submit(undefined, "") + return + case "unpause-vault": + unpauseVault.submit(undefined, "") + return + case "raise-ripcord": + if (action.viaAdmin) setRipcord.submit(true, RAISE) + else raiseRipcord.submit(undefined, "") + return + case "lower-ripcord": + setRipcord.submit(false, LOWER) + return + case "clear-latest": + clearLatest.submit(undefined, "") + return + case "pause-token": + pauseToken.submit(undefined, "") + return + case "unpause-token": + unpauseToken.submit(undefined, "") + } + } + + const flow = ( + action: TransactionModalProps["action"] & EmergencyAction["id"], + transaction: { + status: TransactionStatus + reset: () => void + retry: () => void + }, + ): TransactionModalProps => ({ + action, + status: transaction.status, + onClose: transaction.reset, + onRetry: transaction.retry, + }) + + const modalFlows: TransactionModalProps[] = [ + flow("pause-vault", pauseVault), + flow("unpause-vault", unpauseVault), + flow("raise-ripcord", raiseRipcord), + flow( + setRipcord.amountLabel === RAISE ? "raise-ripcord" : "lower-ripcord", + setRipcord, + ), + flow("clear-latest", clearLatest), + flow("pause-token", pauseToken), + flow("unpause-token", unpauseToken), + ] + const activeModal = modalFlows.find((props) => props.status.status !== "idle") + + return ( + <> +
    +
    + +
    +
    +

    + Status +

    + +
    + {contracts} +
    +
    +
    + {active && activeModal !== undefined && ( + + )} + + ) +} + +export default EmergencySection diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 439d17a7..5f8da528 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -10,13 +10,14 @@ 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 TransactionStatus } 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" import { type RequestRedeemStatus } from "../../hooks/useRequestRedeem" -import TransactionModal from "./TransactionModal" +import TransactionModal, { type EmergencyModalAction } from "./TransactionModal" const { explorerTransactionMock } = vi.hoisted(() => ({ explorerTransactionMock: vi.fn(() => null as string | null), @@ -1947,3 +1948,104 @@ describe("TransactionModal, finalizing a wind-down round", () => { expect(screen.queryByText(/your requests/)).toBeNull() }) }) + +const renderEmergencyModal = ( + action: EmergencyModalAction, + status: TransactionStatus, +) => + render( + , + ) + +describe("TransactionModal, emergency actions", () => { + it.each<[EmergencyModalAction, string, string]>([ + ["pause-vault", "Preparing to pause the vault", "Vault paused"], + ["unpause-vault", "Preparing to unpause the vault", "Vault unpaused"], + ["raise-ripcord", "Preparing to raise the ripcord", "Ripcord raised"], + ["lower-ripcord", "Preparing to lower the ripcord", "Ripcord lowered"], + [ + "clear-latest", + "Preparing to clear the latest price", + "Latest price cleared", + ], + ["pause-token", "Preparing to pause the share token", "Share token paused"], + [ + "unpause-token", + "Preparing to unpause the share token", + "Share token unpaused", + ], + ])("walks %s from preparing to confirmed", (action, preparing, confirmed) => { + const { rerender } = renderEmergencyModal(action, { status: "preparing" }) + expect(screen.getByRole("heading", { name: preparing })).toBeTruthy() + + rerender( + , + ) + expect(screen.getByRole("heading", { name: confirmed })).toBeTruthy() + }) + + it("tells the signer what pausing the vault stops and what stays open", () => { + renderEmergencyModal("pause-vault", { status: "awaiting-signature" }) + + expect( + screen.getByText("Signing stops new deposits, pricing and upgrades."), + ).toBeTruthy() + }) + + it.each<[EmergencyModalAction, number, string, RegExp]>([ + ["pause-vault", 1000, "vault", /The vault is already paused/], + ["unpause-vault", 1001, "vault", /The vault is not paused/], + ["pause-token", 1000, "share token", /The share token is already paused/], + ["unpause-token", 1001, "share token", /The share token is not paused/], + ["raise-ripcord", 2000, "oracle", /does not hold the role/], + ["clear-latest", 3008, "oracle", /The ripcord is not raised/], + ])( + "names the reason %s was refused with code %i", + (action, code, refuser, sentence) => { + renderEmergencyModal(action, { + status: "failed", + failure: { kind: "contract-error", code }, + }) + + expect( + screen.getByRole("heading", { + name: `The ${refuser} refused this request`, + }), + ).toBeTruthy() + expect(screen.getByText(sentence)).toBeTruthy() + }, + ) + + it("falls back to a generic refusal from the right contract without the raw code", () => { + renderEmergencyModal("pause-token", { + status: "failed", + failure: { kind: "contract-error", code: 9999 }, + }) + + expect( + screen.getByText(/The share token refused this request\. Try again/), + ).toBeTruthy() + expect(screen.queryByText(/9999/)).toBeNull() + }) + + it("sends an unknown outcome to the Emergency tab", () => { + renderEmergencyModal("raise-ripcord", { + status: "failed", + failure: { kind: "unknown" }, + }) + + expect( + screen.getByText(/check the Emergency tab before trying again/), + ).toBeTruthy() + }) +}) diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index f8867ada..d1fb0a89 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -14,7 +14,10 @@ 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 TransactionFailure, + type TransactionStatus, +} from "../../hooks/useContractTransaction" import { type DeployToCustodianStatus } from "../../hooks/useDeployToCustodian" import { type FinalizeWindDownRoundStatus } from "../../hooks/useFinalizeWindDownRound" import { type FulfillEpochStatus } from "../../hooks/useFulfillEpoch" @@ -26,7 +29,22 @@ import Close from "../icons/Close" import ExternalLink from "../icons/ExternalLink" import styles from "./TransactionModal.module.css" +export type EmergencyModalAction = + | "pause-vault" + | "unpause-vault" + | "raise-ripcord" + | "lower-ripcord" + | "clear-latest" + | "pause-token" + | "unpause-token" + export type TransactionModalProps = + | { + action: EmergencyModalAction + status: TransactionStatus + onClose: () => void + onRetry: () => void + } | { action: "subscribe" status: RequestDepositStatus @@ -347,11 +365,13 @@ const finalizeRoundContractErrorReason = (code: number): string => { const INVESTOR_FOLLOW_UP = "check your requests before doing anything else" const OPERATOR_FOLLOW_UP = "check the Cycle card before trying again" +const EMERGENCY_FOLLOW_UP = "check the Emergency tab before trying again" const describeFailure = ( failure: TransactionFailure, contractErrorReason: (code: number) => string, followUp = INVESTOR_FOLLOW_UP, + refuser = "vault", ): { heading: string; body: string } => { switch (failure.kind) { case "declined": @@ -361,7 +381,7 @@ const describeFailure = ( } case "contract-error": return { - heading: "The vault refused this request", + heading: `The ${refuser} refused this request`, body: contractErrorReason(failure.code), } case "interrupted": @@ -914,6 +934,123 @@ const describeFinalizeRoundStatus = ( } } +type EmergencyCopy = { + refuser: "vault" | "share token" | "oracle" + verb: string + signing: string + done: string + doneBody: string +} + +const EMERGENCY_COPY: Record = { + "pause-vault": { + refuser: "vault", + verb: "pause the vault", + signing: "Signing stops new deposits, pricing and upgrades.", + done: "Vault paused", + doneBody: + "New deposits, pricing and upgrades are stopped. Redemptions, claims, cancels and closing epochs stay open.", + }, + "unpause-vault": { + refuser: "vault", + verb: "unpause the vault", + signing: "Signing resumes deposits, pricing and upgrades.", + done: "Vault unpaused", + doneBody: "Deposits, pricing and upgrades are open again.", + }, + "raise-ripcord": { + refuser: "oracle", + verb: "raise the ripcord", + signing: "Signing stops pricing until the ripcord is lowered.", + done: "Ripcord raised", + doneBody: + "Pricing is stopped. No epoch can be fulfilled until the ripcord is lowered.", + }, + "lower-ripcord": { + refuser: "oracle", + verb: "lower the ripcord", + signing: "Signing resumes pricing.", + done: "Ripcord lowered", + doneBody: "Epochs can be fulfilled again while the price is valid.", + }, + "clear-latest": { + refuser: "oracle", + verb: "clear the latest price", + signing: "Signing removes the recorded price.", + done: "Latest price cleared", + doneBody: + "The next attestation is not limited by its distance from the last. Pricing stays stopped until the ripcord is lowered.", + }, + "pause-token": { + refuser: "share token", + verb: "pause the share token", + signing: "Signing stops share transfers between holders.", + done: "Share token paused", + doneBody: + "Transfers between holders are stopped. Minting, burning, claims and forced transfers stay open.", + }, + "unpause-token": { + refuser: "share token", + verb: "unpause the share token", + signing: "Signing resumes share transfers between holders.", + done: "Share token unpaused", + doneBody: "Share transfers between holders are open again.", + }, +} + +const emergencyContractErrorReason = + (refuser: EmergencyCopy["refuser"]) => + (code: number): string => { + switch (code) { + case 1000: + return `The ${refuser} is already paused.` + case 1001: + return `The ${refuser} is not paused, so there is nothing to unpause.` + case 2000: + return "This wallet does not hold the role this action needs." + case 3008: + return "The ripcord is not raised, so the latest price cannot be cleared." + default: + return `The ${refuser} refused this request. Try again, and contact support if it keeps happening.` + } + } + +const describeEmergencyStatus = ( + action: EmergencyModalAction, + status: TransactionStatus, +): { heading: string; body: string; hash?: string } | undefined => { + const copy = EMERGENCY_COPY[action] + switch (status.status) { + case "idle": + return undefined + case "preparing": + return { + heading: `Preparing to ${copy.verb}`, + body: "We are getting it ready. Your wallet will ask you to approve it next.", + } + case "awaiting-signature": + return { heading: "Confirm in your wallet", body: copy.signing } + case "submitted": + return { + heading: "Sending", + body: "It 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: copy.done, body: copy.doneBody, hash: status.hash } + case "failed": + return { + ...describeFailure( + status.failure, + emergencyContractErrorReason(copy.refuser), + EMERGENCY_FOLLOW_UP, + copy.refuser, + ), + hash: status.hash, + } + } +} + const describeStatus = ( props: TransactionModalProps, ): { heading: string; body: string; hash?: string } | undefined => { @@ -959,6 +1096,14 @@ const describeStatus = ( return describeActivateWindDownStatus(props.status) case "finalize-round": return describeFinalizeRoundStatus(props.status, props.round) + case "pause-vault": + case "unpause-vault": + case "raise-ripcord": + case "lower-ripcord": + case "clear-latest": + case "pause-token": + case "unpause-token": + return describeEmergencyStatus(props.action, props.status) } } @@ -987,7 +1132,8 @@ const computeSteps = ( | DeployToCustodianStatus | FundStatus | ActivateWindDownStatus - | FinalizeWindDownRoundStatus, + | FinalizeWindDownRoundStatus + | TransactionStatus, ): Record | undefined => { switch (status.status) { case "idle": diff --git a/app/src/config/clients.ts b/app/src/config/clients.ts index 7ac03deb..fce5369b 100644 --- a/app/src/config/clients.ts +++ b/app/src/config/clients.ts @@ -56,6 +56,10 @@ export const navOracleWriter = writer((signer) => connectNavOracle(addresses.nav_oracle, signer), ) +export const shareTokenWriter = writer((signer) => + connectShareToken(addresses.share_token, signer), +) + let oracle: Promise | undefined export const navOracle = (): Promise => { diff --git a/app/src/hooks/useClearLatest.test.ts b/app/src/hooks/useClearLatest.test.ts new file mode 100644 index 00000000..0f608758 --- /dev/null +++ b/app/src/hooks/useClearLatest.test.ts @@ -0,0 +1,110 @@ +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 { useClearLatest } from "./useClearLatest" +import { oracleWriteKeys } from "./useOracleState" + +const { clientMock, writerMock } = vi.hoisted(() => ({ + clientMock: { clear_latest: vi.fn() }, + writerMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ + navOracleWriter: writerMock, +})) + +const renderClearLatest = () => renderWithWallet(useClearLatest) + +describe("useClearLatest", () => { + beforeEach(() => { + vi.clearAllMocks() + writerMock.mockResolvedValue(clientMock) + }) + + it("signs clear_latest as the connected wallet and confirms", async () => { + const gate = deferred() + clientMock.clear_latest.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn( + async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { + await gate.promise + watcher.onSubmitted() + return { + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + } + }, + ), + }) + const { result } = renderClearLatest() + + expect(result.current.submit()).toBe(true) + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + gate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ status: "confirmed" }), + ) + expect(clientMock.clear_latest).toHaveBeenCalledWith({ + caller: investorAddress, + }) + }) + + it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { + const signAndSend = vi.fn() + clientMock.clear_latest.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #3008)" }, + signAndSend, + }) + const { result, queryClient } = renderClearLatest() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 3008 }, + }) + expect(signAndSend).not.toHaveBeenCalled() + expect(invalidateQueries).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + clientMock.clear_latest.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderClearLatest() + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "declined" }, + }) + }) + + it("refreshes the state it changed once confirmed", async () => { + clientMock.clear_latest.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + }), + }) + const { result, queryClient } = renderClearLatest() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + for (const queryKey of oracleWriteKeys) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + }) +}) diff --git a/app/src/hooks/useClearLatest.ts b/app/src/hooks/useClearLatest.ts new file mode 100644 index 00000000..b2150fdc --- /dev/null +++ b/app/src/hooks/useClearLatest.ts @@ -0,0 +1,25 @@ +import { type Signer } from "@stellar-scaffold/app-lib" +import { navOracleWriter } from "../config/clients" +import { + useContractTransaction, + type UseContractTransaction, +} from "./useContractTransaction" +import { oracleWriteKeys } from "./useOracleState" + +const call = (signer: Signer) => + navOracleWriter(signer).then((oracle) => + oracle.clear_latest({ caller: signer.publicKey }), + ) + +const toConfirmed = () => ({}) + +export function useClearLatest(): UseContractTransaction< + void, + Record +> { + return useContractTransaction>( + call, + toConfirmed, + oracleWriteKeys, + ) +} diff --git a/app/src/hooks/useOracleState.test.ts b/app/src/hooks/useOracleState.test.ts new file mode 100644 index 00000000..8220bb6b --- /dev/null +++ b/app/src/hooks/useOracleState.test.ts @@ -0,0 +1,52 @@ +import { type ContractRead, type OracleState } from "@stellar-scaffold/app-lib" +import { waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { renderWithWallet } from "./testSupport" +import { classifyOracle, useOracleState } from "./useOracleState" + +const { oracleMock } = vi.hoisted(() => ({ + oracleMock: { state: vi.fn() }, +})) + +vi.mock("../config/clients", () => ({ + navOracle: async () => oracleMock, +})) + +const tagged = (tag: OracleState["tag"]): ContractRead => ({ + kind: "value", + value: { tag, values: undefined }, +}) + +describe("classifyOracle", () => { + it.each<[string, ContractRead, string]>([ + ["Valid", tagged("Valid"), "valid"], + ["Stale", tagged("Stale"), "stale"], + ["Paused", tagged("Paused"), "paused"], + ["a contract error", { kind: "contract-error", code: 1 }, "unreadable"], + ["an unreadable read", { kind: "unreadable" }, "unreadable"], + ])("classifies %s as %s", (_name, read, expected) => { + expect(classifyOracle(read)).toBe(expected) + }) +}) + +describe("useOracleState", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("reports the oracle as paused while the ripcord is raised", async () => { + oracleMock.state.mockResolvedValue({ + result: { tag: "Paused", values: undefined }, + }) + const { result } = renderWithWallet(() => useOracleState()) + + await waitFor(() => expect(result.current.oracle).toBe("paused")) + }) + + it("reports unreadable when the read fails", async () => { + oracleMock.state.mockRejectedValue(new Error("simulation failed")) + const { result } = renderWithWallet(() => useOracleState()) + + await waitFor(() => expect(result.current.oracle).toBe("unreadable")) + }) +}) diff --git a/app/src/hooks/useOracleState.ts b/app/src/hooks/useOracleState.ts new file mode 100644 index 00000000..fa539c7d --- /dev/null +++ b/app/src/hooks/useOracleState.ts @@ -0,0 +1,47 @@ +import { + type ContractRead, + type OracleState, + readContract, +} from "@stellar-scaffold/app-lib" +import { useQuery } from "@tanstack/react-query" +import { navOracle } from "../config/clients" +import { cycleReadsKey } from "./useCycleState" +import { PAUSE_REFRESH_MS } from "./useVaultPaused" + +export type OracleStatus = + "checking" | "valid" | "stale" | "paused" | "unreadable" + +export const oracleStateKey = ["oracle", "state"] as const +export const oracleWriteKeys = [ + oracleStateKey, + cycleReadsKey, + ["nav", "latest"], +] as const + +const STATUS_BY_TAG = { + Valid: "valid", + Stale: "stale", + Paused: "paused", +} as const satisfies Record + +export function classifyOracle(read: ContractRead): OracleStatus { + return read.kind === "value" ? STATUS_BY_TAG[read.value.tag] : "unreadable" +} + +async function fetchOracleState(): Promise { + const read = await readContract(async () => (await navOracle()).state()) + return classifyOracle(read) +} + +export function useOracleState({ poll = false }: { poll?: boolean } = {}): { + oracle: OracleStatus +} { + const { data } = useQuery({ + queryKey: oracleStateKey, + queryFn: fetchOracleState, + staleTime: 30_000, + refetchInterval: poll ? PAUSE_REFRESH_MS : false, + }) + + return { oracle: data ?? "checking" } +} diff --git a/app/src/hooks/usePauseToken.test.ts b/app/src/hooks/usePauseToken.test.ts new file mode 100644 index 00000000..34cb20c3 --- /dev/null +++ b/app/src/hooks/usePauseToken.test.ts @@ -0,0 +1,108 @@ +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 { usePauseToken } from "./usePauseToken" +import { tokenPausedKey } from "./useTokenPaused" + +const { clientMock, writerMock } = vi.hoisted(() => ({ + clientMock: { pause: vi.fn() }, + writerMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ + shareTokenWriter: writerMock, +})) + +const renderPauseToken = () => renderWithWallet(usePauseToken) + +describe("usePauseToken", () => { + beforeEach(() => { + vi.clearAllMocks() + writerMock.mockResolvedValue(clientMock) + }) + + it("signs pause as the connected wallet and confirms", async () => { + const gate = deferred() + clientMock.pause.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn( + async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { + await gate.promise + watcher.onSubmitted() + return { + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + } + }, + ), + }) + const { result } = renderPauseToken() + + expect(result.current.submit()).toBe(true) + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + gate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ status: "confirmed" }), + ) + expect(clientMock.pause).toHaveBeenCalledWith({ caller: investorAddress }) + }) + + it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { + const signAndSend = vi.fn() + clientMock.pause.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #1000)" }, + signAndSend, + }) + const { result, queryClient } = renderPauseToken() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 1000 }, + }) + expect(signAndSend).not.toHaveBeenCalled() + expect(invalidateQueries).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + clientMock.pause.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderPauseToken() + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "declined" }, + }) + }) + + it("refreshes the state it changed once confirmed", async () => { + clientMock.pause.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + }), + }) + const { result, queryClient } = renderPauseToken() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + for (const queryKey of [tokenPausedKey]) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + }) +}) diff --git a/app/src/hooks/usePauseToken.ts b/app/src/hooks/usePauseToken.ts new file mode 100644 index 00000000..226eec96 --- /dev/null +++ b/app/src/hooks/usePauseToken.ts @@ -0,0 +1,27 @@ +import { type Signer } from "@stellar-scaffold/app-lib" +import { shareTokenWriter } from "../config/clients" +import { + useContractTransaction, + type UseContractTransaction, +} from "./useContractTransaction" +import { tokenPausedKey } from "./useTokenPaused" + +const WRITE_KEYS = [tokenPausedKey] + +const call = (signer: Signer) => + shareTokenWriter(signer).then((token) => + token.pause({ caller: signer.publicKey }), + ) + +const toConfirmed = () => ({}) + +export function usePauseToken(): UseContractTransaction< + void, + Record +> { + return useContractTransaction>( + call, + toConfirmed, + WRITE_KEYS, + ) +} diff --git a/app/src/hooks/usePauseVault.test.ts b/app/src/hooks/usePauseVault.test.ts new file mode 100644 index 00000000..1e947ba0 --- /dev/null +++ b/app/src/hooks/usePauseVault.test.ts @@ -0,0 +1,108 @@ +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 { usePauseVault } from "./usePauseVault" +import { vaultPausedKey } from "./useVaultPaused" + +const { clientMock, writerMock } = vi.hoisted(() => ({ + clientMock: { pause: vi.fn() }, + writerMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ + asyncVaultWriter: writerMock, +})) + +const renderPauseVault = () => renderWithWallet(usePauseVault) + +describe("usePauseVault", () => { + beforeEach(() => { + vi.clearAllMocks() + writerMock.mockResolvedValue(clientMock) + }) + + it("signs pause as the connected wallet and confirms", async () => { + const gate = deferred() + clientMock.pause.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn( + async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { + await gate.promise + watcher.onSubmitted() + return { + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + } + }, + ), + }) + const { result } = renderPauseVault() + + expect(result.current.submit()).toBe(true) + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + gate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ status: "confirmed" }), + ) + expect(clientMock.pause).toHaveBeenCalledWith({ caller: investorAddress }) + }) + + it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { + const signAndSend = vi.fn() + clientMock.pause.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #1000)" }, + signAndSend, + }) + const { result, queryClient } = renderPauseVault() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 1000 }, + }) + expect(signAndSend).not.toHaveBeenCalled() + expect(invalidateQueries).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + clientMock.pause.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderPauseVault() + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "declined" }, + }) + }) + + it("refreshes the state it changed once confirmed", async () => { + clientMock.pause.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + }), + }) + const { result, queryClient } = renderPauseVault() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + for (const queryKey of [vaultPausedKey]) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + }) +}) diff --git a/app/src/hooks/usePauseVault.ts b/app/src/hooks/usePauseVault.ts new file mode 100644 index 00000000..2301ddd6 --- /dev/null +++ b/app/src/hooks/usePauseVault.ts @@ -0,0 +1,27 @@ +import { type Signer } from "@stellar-scaffold/app-lib" +import { asyncVaultWriter } from "../config/clients" +import { + useContractTransaction, + type UseContractTransaction, +} from "./useContractTransaction" +import { vaultPausedKey } from "./useVaultPaused" + +const WRITE_KEYS = [vaultPausedKey] + +const call = (signer: Signer) => + asyncVaultWriter(signer).then((vault) => + vault.pause({ caller: signer.publicKey }), + ) + +const toConfirmed = () => ({}) + +export function usePauseVault(): UseContractTransaction< + void, + Record +> { + return useContractTransaction>( + call, + toConfirmed, + WRITE_KEYS, + ) +} diff --git a/app/src/hooks/useRaiseRipcord.test.ts b/app/src/hooks/useRaiseRipcord.test.ts new file mode 100644 index 00000000..f3fb99a9 --- /dev/null +++ b/app/src/hooks/useRaiseRipcord.test.ts @@ -0,0 +1,110 @@ +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 { oracleWriteKeys } from "./useOracleState" +import { useRaiseRipcord } from "./useRaiseRipcord" + +const { clientMock, writerMock } = vi.hoisted(() => ({ + clientMock: { raise_ripcord: vi.fn() }, + writerMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ + navOracleWriter: writerMock, +})) + +const renderRaiseRipcord = () => renderWithWallet(useRaiseRipcord) + +describe("useRaiseRipcord", () => { + beforeEach(() => { + vi.clearAllMocks() + writerMock.mockResolvedValue(clientMock) + }) + + it("signs raise_ripcord as the connected wallet and confirms", async () => { + const gate = deferred() + clientMock.raise_ripcord.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn( + async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { + await gate.promise + watcher.onSubmitted() + return { + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + } + }, + ), + }) + const { result } = renderRaiseRipcord() + + expect(result.current.submit()).toBe(true) + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + gate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ status: "confirmed" }), + ) + expect(clientMock.raise_ripcord).toHaveBeenCalledWith({ + caller: investorAddress, + }) + }) + + it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { + const signAndSend = vi.fn() + clientMock.raise_ripcord.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #2000)" }, + signAndSend, + }) + const { result, queryClient } = renderRaiseRipcord() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 2000 }, + }) + expect(signAndSend).not.toHaveBeenCalled() + expect(invalidateQueries).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + clientMock.raise_ripcord.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderRaiseRipcord() + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "declined" }, + }) + }) + + it("refreshes the state it changed once confirmed", async () => { + clientMock.raise_ripcord.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + }), + }) + const { result, queryClient } = renderRaiseRipcord() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + for (const queryKey of oracleWriteKeys) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + }) +}) diff --git a/app/src/hooks/useRaiseRipcord.ts b/app/src/hooks/useRaiseRipcord.ts new file mode 100644 index 00000000..625f455f --- /dev/null +++ b/app/src/hooks/useRaiseRipcord.ts @@ -0,0 +1,25 @@ +import { type Signer } from "@stellar-scaffold/app-lib" +import { navOracleWriter } from "../config/clients" +import { + useContractTransaction, + type UseContractTransaction, +} from "./useContractTransaction" +import { oracleWriteKeys } from "./useOracleState" + +const call = (signer: Signer) => + navOracleWriter(signer).then((oracle) => + oracle.raise_ripcord({ caller: signer.publicKey }), + ) + +const toConfirmed = () => ({}) + +export function useRaiseRipcord(): UseContractTransaction< + void, + Record +> { + return useContractTransaction>( + call, + toConfirmed, + oracleWriteKeys, + ) +} diff --git a/app/src/hooks/useSetRipcord.test.ts b/app/src/hooks/useSetRipcord.test.ts new file mode 100644 index 00000000..70b8cb2d --- /dev/null +++ b/app/src/hooks/useSetRipcord.test.ts @@ -0,0 +1,111 @@ +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 { oracleWriteKeys } from "./useOracleState" +import { useSetRipcord } from "./useSetRipcord" + +const { clientMock, writerMock } = vi.hoisted(() => ({ + clientMock: { set_ripcord: vi.fn() }, + writerMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ + navOracleWriter: writerMock, +})) + +const renderSetRipcord = () => renderWithWallet(useSetRipcord) + +describe("useSetRipcord", () => { + beforeEach(() => { + vi.clearAllMocks() + writerMock.mockResolvedValue(clientMock) + }) + + it("signs set_ripcord as the connected wallet and confirms", async () => { + const gate = deferred() + clientMock.set_ripcord.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn( + async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { + await gate.promise + watcher.onSubmitted() + return { + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + } + }, + ), + }) + const { result } = renderSetRipcord() + + expect(result.current.submit(false)).toBe(true) + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + gate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ status: "confirmed" }), + ) + expect(clientMock.set_ripcord).toHaveBeenCalledWith({ + paused: false, + caller: investorAddress, + }) + }) + + it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { + const signAndSend = vi.fn() + clientMock.set_ripcord.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #2000)" }, + signAndSend, + }) + const { result, queryClient } = renderSetRipcord() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(false)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 2000 }, + }) + expect(signAndSend).not.toHaveBeenCalled() + expect(invalidateQueries).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + clientMock.set_ripcord.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderSetRipcord() + + await act(() => result.current.submit(false)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "declined" }, + }) + }) + + it("refreshes the state it changed once confirmed", async () => { + clientMock.set_ripcord.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + }), + }) + const { result, queryClient } = renderSetRipcord() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(false)) + + for (const queryKey of oracleWriteKeys) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + }) +}) diff --git a/app/src/hooks/useSetRipcord.ts b/app/src/hooks/useSetRipcord.ts new file mode 100644 index 00000000..75fd9642 --- /dev/null +++ b/app/src/hooks/useSetRipcord.ts @@ -0,0 +1,25 @@ +import { type Signer } from "@stellar-scaffold/app-lib" +import { navOracleWriter } from "../config/clients" +import { + useContractTransaction, + type UseContractTransaction, +} from "./useContractTransaction" +import { oracleWriteKeys } from "./useOracleState" + +const call = (signer: Signer, paused: boolean) => + navOracleWriter(signer).then((oracle) => + oracle.set_ripcord({ paused, caller: signer.publicKey }), + ) + +const toConfirmed = () => ({}) + +export function useSetRipcord(): UseContractTransaction< + boolean, + Record +> { + return useContractTransaction>( + call, + toConfirmed, + oracleWriteKeys, + ) +} diff --git a/app/src/hooks/useTokenPaused.test.ts b/app/src/hooks/useTokenPaused.test.ts new file mode 100644 index 00000000..c872ac14 --- /dev/null +++ b/app/src/hooks/useTokenPaused.test.ts @@ -0,0 +1,35 @@ +import { waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { renderWithWallet } from "./testSupport" +import { useTokenPaused } from "./useTokenPaused" + +const { tokenMock } = vi.hoisted(() => ({ + tokenMock: { paused: vi.fn() }, +})) + +vi.mock("../config/clients", () => ({ + shareToken: async () => tokenMock, +})) + +describe("useTokenPaused", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each<[boolean, string]>([ + [true, "paused"], + [false, "open"], + ])("reads paused() %s as %s", async (value, expected) => { + tokenMock.paused.mockResolvedValue({ result: value }) + const { result } = renderWithWallet(() => useTokenPaused()) + + await waitFor(() => expect(result.current.pause).toBe(expected)) + }) + + it("reports unreadable when the read fails, never open", async () => { + tokenMock.paused.mockRejectedValue(new Error("simulation failed")) + const { result } = renderWithWallet(() => useTokenPaused()) + + await waitFor(() => expect(result.current.pause).toBe("unreadable")) + }) +}) diff --git a/app/src/hooks/useTokenPaused.ts b/app/src/hooks/useTokenPaused.ts new file mode 100644 index 00000000..2a5d42b7 --- /dev/null +++ b/app/src/hooks/useTokenPaused.ts @@ -0,0 +1,28 @@ +import { readContract } from "@stellar-scaffold/app-lib" +import { useQuery } from "@tanstack/react-query" +import { shareToken } from "../config/clients" +import { + classifyPause, + PAUSE_REFRESH_MS, + type PauseState, +} from "./useVaultPaused" + +export const tokenPausedKey = ["token", "paused"] as const + +async function fetchTokenPause(): Promise { + const read = await readContract(async () => (await shareToken()).paused()) + return classifyPause(read) +} + +export function useTokenPaused({ poll = false }: { poll?: boolean } = {}): { + pause: PauseState +} { + const { data } = useQuery({ + queryKey: tokenPausedKey, + queryFn: fetchTokenPause, + staleTime: 30_000, + refetchInterval: poll ? PAUSE_REFRESH_MS : false, + }) + + return { pause: data ?? "checking" } +} diff --git a/app/src/hooks/useUnpauseToken.test.ts b/app/src/hooks/useUnpauseToken.test.ts new file mode 100644 index 00000000..10bbcd3f --- /dev/null +++ b/app/src/hooks/useUnpauseToken.test.ts @@ -0,0 +1,108 @@ +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 { tokenPausedKey } from "./useTokenPaused" +import { useUnpauseToken } from "./useUnpauseToken" + +const { clientMock, writerMock } = vi.hoisted(() => ({ + clientMock: { unpause: vi.fn() }, + writerMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ + shareTokenWriter: writerMock, +})) + +const renderUnpauseToken = () => renderWithWallet(useUnpauseToken) + +describe("useUnpauseToken", () => { + beforeEach(() => { + vi.clearAllMocks() + writerMock.mockResolvedValue(clientMock) + }) + + it("signs unpause as the connected wallet and confirms", async () => { + const gate = deferred() + clientMock.unpause.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn( + async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { + await gate.promise + watcher.onSubmitted() + return { + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + } + }, + ), + }) + const { result } = renderUnpauseToken() + + expect(result.current.submit()).toBe(true) + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + gate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ status: "confirmed" }), + ) + expect(clientMock.unpause).toHaveBeenCalledWith({ caller: investorAddress }) + }) + + it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { + const signAndSend = vi.fn() + clientMock.unpause.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #1001)" }, + signAndSend, + }) + const { result, queryClient } = renderUnpauseToken() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 1001 }, + }) + expect(signAndSend).not.toHaveBeenCalled() + expect(invalidateQueries).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + clientMock.unpause.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderUnpauseToken() + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "declined" }, + }) + }) + + it("refreshes the state it changed once confirmed", async () => { + clientMock.unpause.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + }), + }) + const { result, queryClient } = renderUnpauseToken() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + for (const queryKey of [tokenPausedKey]) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + }) +}) diff --git a/app/src/hooks/useUnpauseToken.ts b/app/src/hooks/useUnpauseToken.ts new file mode 100644 index 00000000..4a56443f --- /dev/null +++ b/app/src/hooks/useUnpauseToken.ts @@ -0,0 +1,27 @@ +import { type Signer } from "@stellar-scaffold/app-lib" +import { shareTokenWriter } from "../config/clients" +import { + useContractTransaction, + type UseContractTransaction, +} from "./useContractTransaction" +import { tokenPausedKey } from "./useTokenPaused" + +const WRITE_KEYS = [tokenPausedKey] + +const call = (signer: Signer) => + shareTokenWriter(signer).then((token) => + token.unpause({ caller: signer.publicKey }), + ) + +const toConfirmed = () => ({}) + +export function useUnpauseToken(): UseContractTransaction< + void, + Record +> { + return useContractTransaction>( + call, + toConfirmed, + WRITE_KEYS, + ) +} diff --git a/app/src/hooks/useUnpauseVault.test.ts b/app/src/hooks/useUnpauseVault.test.ts new file mode 100644 index 00000000..dbb84e5f --- /dev/null +++ b/app/src/hooks/useUnpauseVault.test.ts @@ -0,0 +1,108 @@ +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 { useUnpauseVault } from "./useUnpauseVault" +import { vaultPausedKey } from "./useVaultPaused" + +const { clientMock, writerMock } = vi.hoisted(() => ({ + clientMock: { unpause: vi.fn() }, + writerMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ + asyncVaultWriter: writerMock, +})) + +const renderUnpauseVault = () => renderWithWallet(useUnpauseVault) + +describe("useUnpauseVault", () => { + beforeEach(() => { + vi.clearAllMocks() + writerMock.mockResolvedValue(clientMock) + }) + + it("signs unpause as the connected wallet and confirms", async () => { + const gate = deferred() + clientMock.unpause.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn( + async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { + await gate.promise + watcher.onSubmitted() + return { + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + } + }, + ), + }) + const { result } = renderUnpauseVault() + + expect(result.current.submit()).toBe(true) + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + gate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ status: "confirmed" }), + ) + expect(clientMock.unpause).toHaveBeenCalledWith({ caller: investorAddress }) + }) + + it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { + const signAndSend = vi.fn() + clientMock.unpause.mockResolvedValue({ + simulation: { error: "HostError: Error(Contract, #1001)" }, + signAndSend, + }) + const { result, queryClient } = renderUnpauseVault() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: 1001 }, + }) + expect(signAndSend).not.toHaveBeenCalled() + expect(invalidateQueries).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + clientMock.unpause.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderUnpauseVault() + + await act(() => result.current.submit()) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "declined" }, + }) + }) + + it("refreshes the state it changed once confirmed", async () => { + clientMock.unpause.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + }), + }) + const { result, queryClient } = renderUnpauseVault() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit()) + + for (const queryKey of [vaultPausedKey]) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + }) +}) diff --git a/app/src/hooks/useUnpauseVault.ts b/app/src/hooks/useUnpauseVault.ts new file mode 100644 index 00000000..b6f7cb68 --- /dev/null +++ b/app/src/hooks/useUnpauseVault.ts @@ -0,0 +1,27 @@ +import { type Signer } from "@stellar-scaffold/app-lib" +import { asyncVaultWriter } from "../config/clients" +import { + useContractTransaction, + type UseContractTransaction, +} from "./useContractTransaction" +import { vaultPausedKey } from "./useVaultPaused" + +const WRITE_KEYS = [vaultPausedKey] + +const call = (signer: Signer) => + asyncVaultWriter(signer).then((vault) => + vault.unpause({ caller: signer.publicKey }), + ) + +const toConfirmed = () => ({}) + +export function useUnpauseVault(): UseContractTransaction< + void, + Record +> { + return useContractTransaction>( + call, + toConfirmed, + WRITE_KEYS, + ) +} diff --git a/app/src/hooks/useVaultPaused.ts b/app/src/hooks/useVaultPaused.ts index 0a116f6e..4f5b58f3 100644 --- a/app/src/hooks/useVaultPaused.ts +++ b/app/src/hooks/useVaultPaused.ts @@ -4,6 +4,9 @@ import { asyncVault } from "../config/clients" export type PauseState = "checking" | "open" | "paused" | "unreadable" +export const vaultPausedKey = ["vault", "paused"] as const +export const PAUSE_REFRESH_MS = 15_000 + export function classifyPause(read: ContractRead): PauseState { if (read.kind !== "value") return "unreadable" return read.value ? "paused" : "open" @@ -14,11 +17,14 @@ async function fetchVaultPause(): Promise { return classifyPause(read) } -export function useVaultPaused(): { pause: PauseState } { +export function useVaultPaused({ poll = false }: { poll?: boolean } = {}): { + pause: PauseState +} { const { data } = useQuery({ - queryKey: ["vault", "paused"], + queryKey: vaultPausedKey, queryFn: fetchVaultPause, staleTime: 30_000, + refetchInterval: poll ? PAUSE_REFRESH_MS : false, }) return { pause: data ?? "checking" } diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx index 0ecd7a60..d1d2936e 100644 --- a/app/src/pages/AdminPanel.test.tsx +++ b/app/src/pages/AdminPanel.test.tsx @@ -42,6 +42,25 @@ vi.mock("../components/admin/CycleSection", () => ({ ), })) +vi.mock("../components/admin/EmergencySection", () => ({ + default: ({ + grants, + contracts, + active, + }: { + grants: Grant[] + contracts: React.ReactNode + active: boolean + }) => ( +
    +

    + Emergency surface for {grants.map((grant) => grant.role).join(", ")} +

    + {contracts} +
    + ), +})) + const connectedAddress = "GCONNECTEDADDRESS1234567890" const emptyGrantedBy = { @@ -204,11 +223,39 @@ describe("AdminPanel", () => { ).toBeNull() expect( screen.getByText( - "GCON...7890 · vault manager · vault guardian · oracle guardian · vault governance", + "GCON...7890 · Current roles: Vault Manager · Vault Guardian · Oracle Guardian · Vault Governance", ), ).toBeTruthy() }) + it("renders the emergency actions with the emergency grants, active only on its tab", () => { + const manager = holderOf("vault manager") + const guardian = holderOf("vault guardian") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle", "emergency"]), + grantedBy: { + ...emptyGrantedBy, + cycle: [manager], + emergency: [guardian], + }, + grants: [manager, guardian], + }), + ) + renderAt("/admin/emergency") + + expect( + screen.getByText("Emergency surface for vault guardian"), + ).toBeTruthy() + expect( + screen.getByTestId("emergency-surface").getAttribute("data-active"), + ).toBe("true") + expect( + screen.getByTestId("cycle-surface").getAttribute("data-active"), + ).toBe("false") + }) + it("opens the first held surface at /admin", () => { const guardian = holderOf("vault guardian") const governance = holderOf("vault governance") @@ -384,7 +431,7 @@ describe("AdminPanel", () => { expect( screen.getByText( - "GCON...7890 · oracle attester (1 of 2 signatures) · share-token compliance (weight 1 of threshold 3) · vault governance (signs alone)", + "GCON...7890 · Current roles: Oracle Attester (1 of 2 signatures) · Share-Token Compliance (weight 1 of threshold 3) · Vault Governance (signs alone)", ), ).toBeTruthy() }) @@ -419,7 +466,12 @@ describe("AdminPanel", () => { ) renderAt("/admin") - expect(screen.getByText("GCON...7890 · vault manager")).toBeTruthy() + expect(screen.getByRole("heading", { level: 1 }).textContent).toBe( + "Operations Console", + ) + expect( + screen.getByText("GCON...7890 · Current role: Vault Manager"), + ).toBeTruthy() expect(screen.queryByText("Signer lookup unavailable.")).toBeNull() }) @@ -440,7 +492,9 @@ describe("AdminPanel", () => { renderAt("/admin") expect( - screen.getByText("GCON...7890 · vault governance (1 of 2 signatures)"), + screen.getByText( + "GCON...7890 · Current role: Vault Governance (1 of 2 signatures)", + ), ).toBeTruthy() }) diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index e52dad2a..4a51cbe2 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useRef } from "react" import { Navigate, useParams } from "react-router-dom" import CycleSection from "../components/admin/CycleSection" import Disclosure from "../components/admin/Disclosure" +import EmergencySection from "../components/admin/EmergencySection" import SurfaceTabs from "../components/admin/SurfaceTabs" import Skeleton from "../components/Skeleton" import AddressRows, { type AddressRow } from "../components/vault/AddressRows" @@ -36,10 +37,21 @@ const describeStanding = (standing: Standing): string => { const isSingleKey = (grant: Grant, wallet: string): boolean => grant.authority === wallet && grant.standing === "signs-alone" +const titleCase = (text: string): string => + text.replace( + /(^|[\s-])(\w)/g, + (_, gap: string, letter: string) => `${gap}${letter.toUpperCase()}`, + ) + const describeGrant = (grant: Grant, wallet: string): string => isSingleKey(grant, wallet) - ? grant.role - : `${grant.role} (${describeStanding(grant.standing)})` + ? titleCase(grant.role) + : `${titleCase(grant.role)} (${describeStanding(grant.standing)})` + +const describeRoles = (grants: Grant[], wallet: string): string => + `${grants.length === 1 ? "Current role" : "Current roles"}: ${grants + .map((grant) => describeGrant(grant, wallet)) + .join(" · ")}` const Contracts: React.FC<{ addresses: AdminAddresses }> = ({ addresses }) => ( @@ -47,6 +59,40 @@ const Contracts: React.FC<{ addresses: AdminAddresses }> = ({ addresses }) => ( ) +const SurfaceBody: React.FC<{ + surface: AdminSurface + active: boolean + grantedBy: Record + wallet: string + addresses: AdminAddresses +}> = ({ surface, active, grantedBy, wallet, addresses }) => { + const contracts = + switch (surface) { + case "cycle": + return ( + + ) + case "emergency": + return ( + + ) + case "compliance": + case "configuration": + case "governance": + return contracts + } +} + const HeldSurfaces: React.FC<{ held: AdminSurface[] grantedBy: Record @@ -90,16 +136,13 @@ const HeldSurfaces: React.FC<{ > {SURFACE_LABELS[surface]} - {surface === "cycle" ? ( - } - /> - ) : ( - - )} + ))} @@ -114,14 +157,12 @@ const AdminPanel: React.FC = () => { return (
    -

    Admin

    +

    Operations Console

    {status === "ready" && address !== undefined && (

    {shortAddress(address)} - {grants.length > 0 - ? ` · ${grants.map((grant) => describeGrant(grant, address)).join(" · ")}` - : ""} + {grants.length > 0 ? ` · ${describeRoles(grants, address)}` : ""}

    )} diff --git a/app/src/pages/actions.test.ts b/app/src/pages/actions.test.ts new file mode 100644 index 00000000..5f7b9e95 --- /dev/null +++ b/app/src/pages/actions.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from "vitest" +import { type Grant } from "../hooks/useAdminAuthority" +import { + grantFor, + type RailAction, + readiness, + readyCount, + unavailableFor, +} from "./actions" + +vi.mock("@stellar-scaffold/app-lib", () => ({ + shortAddress: (address: string) => + `${address.slice(0, 4)}...${address.slice(-4)}`, +})) + +const wallet = "GWALLETADDRESS1234" +const multisig = "GMULTISIGADDRESS5678" + +const actionWith = (overrides: Partial): RailAction => ({ + id: "fund", + label: "Fund the reserve", + conditions: [], + outcome: null, + enabled: false, + ...overrides, +}) + +const grant = (overrides: Partial): Grant => ({ + role: "vault guardian", + authority: wallet, + standing: "signs-alone", + ...overrides, +}) + +describe("readiness", () => { + it("reads an enabled action as ready", () => { + expect(readiness(actionWith({ enabled: true }))).toEqual({ kind: "ready" }) + }) + + it("counts the conditions not met, an undecided one included", () => { + const action = actionWith({ + conditions: [ + { label: "Amount above zero", met: false }, + { label: "Within the balance", met: null }, + { label: "Wind-down not active", met: true }, + ], + }) + + expect(readiness(action)).toEqual({ kind: "unmet", count: 2 }) + }) + + it("reads an action the wallet cannot sign as unavailable, keeping the reason", () => { + const reason = "Needs 2 signatures; collecting them is not supported yet." + const action = actionWith({ + conditions: [{ label: "Amount above zero", met: false }], + unavailable: reason, + }) + + expect(readiness(action)).toEqual({ kind: "unavailable", reason }) + }) +}) + +describe("readyCount", () => { + it("counts the enabled actions", () => { + expect( + readyCount([ + actionWith({ enabled: true }), + actionWith({}), + actionWith({ enabled: true }), + ]), + ).toBe(2) + expect(readyCount([])).toBe(0) + }) +}) + +describe("grantFor", () => { + it("prefers the grant the wallet holds itself over one it signs for", () => { + const own = grant({}) + const viaMultisig = grant({ authority: multisig }) + + expect(grantFor([viaMultisig, own], "vault guardian", wallet)).toBe(own) + }) + + it("falls back to any grant of the role, and to none for a role not held", () => { + const viaMultisig = grant({ authority: multisig }) + + expect(grantFor([viaMultisig], "vault guardian", wallet)).toBe(viaMultisig) + expect(grantFor([viaMultisig], "oracle admin", wallet)).toBeUndefined() + }) +}) + +describe("unavailableFor", () => { + it("lets a single key sign for itself", () => { + expect(unavailableFor(grant({}), wallet)).toBeUndefined() + }) + + it("explains the signatures a multisig grant still needs", () => { + expect(unavailableFor(grant({ standing: { needs: 2 } }), wallet)).toBe( + "Needs 2 signatures; collecting them is not supported yet.", + ) + }) + + it("explains that signing for another account is not supported", () => { + expect(unavailableFor(grant({ authority: multisig }), wallet)).toBe( + "Signs for GMUL...5678; acting on its behalf is not supported yet.", + ) + }) +}) diff --git a/app/src/pages/actions.ts b/app/src/pages/actions.ts new file mode 100644 index 00000000..99090375 --- /dev/null +++ b/app/src/pages/actions.ts @@ -0,0 +1,72 @@ +import { shortAddress } from "@stellar-scaffold/app-lib" +import { type Grant, type Standing } from "../hooks/useAdminAuthority" + +export type Condition = { label: string; met: boolean | null; detail?: string } + +export type ActionField = { + label: string + placeholder: string + value: string + invalid: boolean + max?: string +} + +export type RailAction = { + id: string + label: string + conditions: Condition[] + outcome: string | null + note?: string + field?: ActionField + enabled: boolean + unavailable?: string + tone?: "danger" +} + +export type Readiness = + | { kind: "ready" } + | { kind: "unmet"; count: number } + | { kind: "unavailable"; reason: string } + +export const readiness = (action: RailAction): Readiness => { + if (action.enabled) return { kind: "ready" } + if (action.unavailable !== undefined) + return { kind: "unavailable", reason: action.unavailable } + return { + kind: "unmet", + count: action.conditions.filter((condition) => condition.met !== true) + .length, + } +} + +export const readyCount = (actions: RailAction[]): number => + actions.filter((action) => action.enabled).length + +export const allMet = (conditions: Condition[]): boolean => + conditions.every((condition) => condition.met === true) + +const signaturesNeeded = (standing: Standing): number | null => { + if (standing === "signs-alone") return 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 +} diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index 946d8dc4..0656512d 100644 --- a/app/src/pages/cycle.test.ts +++ b/app/src/pages/cycle.test.ts @@ -28,8 +28,6 @@ import { type WindDownAction, formatDuration, formatTimestamp, - readiness, - readyCount, toActivityList, toCycleActions, toCycleRows, @@ -1697,55 +1695,3 @@ describe("toCycleActions, winding down", () => { expect(finalize?.enabled).toBe(false) }) }) - -const actionWith = (overrides: Partial): TreasuryAction => ({ - id: "fund", - group: "Reserve", - label: "Fund the reserve", - conditions: [], - outcome: null, - enabled: false, - amount: null, - ...overrides, -}) - -describe("readiness", () => { - it("reads an enabled action as ready", () => { - expect(readiness(actionWith({ enabled: true }))).toEqual({ kind: "ready" }) - }) - - it("counts the conditions not met, an undecided one included", () => { - const action = actionWith({ - conditions: [ - { label: "Amount above zero", met: false }, - { label: "Within the balance", met: null }, - { label: "Wind-down not active", met: true }, - ], - }) - - expect(readiness(action)).toEqual({ kind: "unmet", count: 2 }) - }) - - it("reads an action the wallet cannot sign as unavailable, keeping the reason", () => { - const reason = "Needs 2 signatures; collecting them is not supported yet." - const action = actionWith({ - conditions: [{ label: "Amount above zero", met: false }], - unavailable: reason, - }) - - expect(readiness(action)).toEqual({ kind: "unavailable", reason }) - }) -}) - -describe("readyCount", () => { - it("counts the enabled actions", () => { - expect( - readyCount([ - actionWith({ enabled: true }), - actionWith({}), - actionWith({ enabled: true }), - ]), - ).toBe(2) - expect(readyCount([])).toBe(0) - }) -}) diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index f033eccd..534220a6 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -19,7 +19,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 Grant } from "../hooks/useAdminAuthority" import { type CycleEventsRead, type CycleOracle, @@ -32,6 +32,14 @@ import { type EpochHistoryRead, type EpochRecord, } from "../hooks/useEpochHistory" +import { + type ActionField, + allMet, + type Condition, + grantFor, + type RailAction, + unavailableFor, +} from "./actions" type ReadyState = Extract @@ -350,26 +358,7 @@ export type CycleActionId = export type ActionGroup = "Epoch" | "Price" | "Reserve" | "Wind-down" -export type Condition = { label: string; met: boolean | null; detail?: string } - -export type ActionField = { - label: string - placeholder: string - value: string - invalid: boolean - max?: string -} - -type ActionBase = { - group: ActionGroup - label: string - conditions: Condition[] - outcome: string | null - note?: string - field?: ActionField - enabled: boolean - unavailable?: string -} +type ActionBase = Omit & { group: ActionGroup } export type EpochAction = ActionBase & { id: "close-epoch" | "fulfill-epoch" @@ -407,40 +396,11 @@ const ATTESTER_ROLE = "oracle attester" const TREASURY_ROLE = "vault treasury" const BPS_DENOMINATOR = 10_000n -const signaturesNeeded = (standing: Standing): number | null => { - if (standing === "signs-alone") return 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", }) -const allMet = (conditions: Condition[]): boolean => - conditions.every((condition) => condition.met === true) - const toEpochAction = ( id: EpochAction["id"], label: string, @@ -824,25 +784,6 @@ function toWindDownActions(state: ReadyState): WindDownAction[] { return actions } -export type Readiness = - | { kind: "ready" } - | { kind: "unmet"; count: number } - | { kind: "unavailable"; reason: string } - -export const readiness = (action: CycleAction): Readiness => { - if (action.enabled) return { kind: "ready" } - if (action.unavailable !== undefined) - return { kind: "unavailable", reason: action.unavailable } - return { - kind: "unmet", - count: action.conditions.filter((condition) => condition.met !== true) - .length, - } -} - -export const readyCount = (actions: CycleAction[]): number => - actions.filter((action) => action.enabled).length - export function toCycleActions( state: CycleState, grants: Grant[], diff --git a/app/src/pages/emergency.test.ts b/app/src/pages/emergency.test.ts new file mode 100644 index 00000000..70c8611d --- /dev/null +++ b/app/src/pages/emergency.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it, vi } from "vitest" +import { type Grant } from "../hooks/useAdminAuthority" +import { + type EmergencyState, + toEmergencyActions, + toEmergencyTiles, +} from "./emergency" + +vi.mock("@stellar-scaffold/app-lib", () => ({ + shortAddress: (address: string) => + `${address.slice(0, 4)}...${address.slice(-4)}`, +})) + +const wallet = "GWALLETADDRESS1234" + +const grantOf = (role: string, overrides: Partial = {}): Grant => ({ + role, + authority: wallet, + standing: "signs-alone", + ...overrides, +}) + +const everyRole = [ + grantOf("vault guardian"), + grantOf("vault governance"), + grantOf("oracle guardian"), + grantOf("oracle admin"), + grantOf("share-token admin"), +] + +const calm: EmergencyState = { vault: "open", token: "open", oracle: "valid" } +const alarmed: EmergencyState = { + vault: "paused", + token: "paused", + oracle: "paused", +} + +const ids = (state: EmergencyState, grants: Grant[]) => + toEmergencyActions(state, grants, wallet).map((action) => action.id) + +const actionOf = (state: EmergencyState, grants: Grant[], id: string) => { + const action = toEmergencyActions(state, grants, wallet).find( + (candidate) => candidate.id === id, + ) + if (action === undefined) throw new Error(`no ${id} action`) + return action +} + +describe("toEmergencyActions", () => { + it("offers nothing while any state is still being read", () => { + expect( + toEmergencyActions({ ...calm, token: "checking" }, everyRole, wallet), + ).toEqual([]) + }) + + it("offers nothing to a wallet with no emergency grant", () => { + expect(toEmergencyActions(calm, [], wallet)).toEqual([]) + }) + + it("offers every emergency entrypoint to a wallet that holds every role, once each", () => { + expect(ids(calm, everyRole)).toEqual([ + "pause-vault", + "unpause-vault", + "raise-ripcord", + "lower-ripcord", + "clear-latest", + "pause-token", + "unpause-token", + ]) + }) + + it.each<[string, string[]]>([ + ["vault guardian", ["pause-vault"]], + ["vault governance", ["unpause-vault"]], + ["oracle guardian", ["raise-ripcord"]], + ["oracle admin", ["raise-ripcord", "lower-ripcord", "clear-latest"]], + ["share-token admin", ["pause-token", "unpause-token"]], + ])("offers %s only what its guard permits", (role, expected) => { + expect(ids(calm, [grantOf(role)])).toEqual(expected) + }) + + it("raises through raise_ripcord for the oracle guardian and set_ripcord for the oracle admin", () => { + expect( + actionOf(calm, [grantOf("oracle guardian")], "raise-ripcord"), + ).toMatchObject({ viaAdmin: false }) + expect( + actionOf(calm, [grantOf("oracle admin")], "raise-ripcord"), + ).toMatchObject({ viaAdmin: true }) + expect(actionOf(calm, everyRole, "raise-ripcord")).toMatchObject({ + viaAdmin: false, + }) + }) + + it("raises through the oracle admin when the guardian grant needs more signatures", () => { + const grants = [ + grantOf("oracle guardian", { standing: { needs: 2 } }), + grantOf("oracle admin"), + ] + + expect(actionOf(calm, grants, "raise-ripcord")).toMatchObject({ + viaAdmin: true, + enabled: true, + }) + }) + + it("enables pausing an open vault and token, and not unpausing them", () => { + const actions = toEmergencyActions(calm, everyRole, wallet) + const enabled = actions + .filter((action) => action.enabled) + .map((action) => action.id) + + expect(enabled).toEqual(["pause-vault", "raise-ripcord", "pause-token"]) + }) + + it("enables unpausing, lowering and clearing once everything is paused", () => { + const actions = toEmergencyActions(alarmed, everyRole, wallet) + const enabled = actions + .filter((action) => action.enabled) + .map((action) => action.id) + + expect(enabled).toEqual([ + "unpause-vault", + "raise-ripcord", + "lower-ripcord", + "clear-latest", + "unpause-token", + ]) + }) + + it("states each precondition against the current state", () => { + expect(actionOf(calm, everyRole, "pause-vault").conditions).toEqual([ + { label: "Vault is open", met: true }, + ]) + expect(actionOf(calm, everyRole, "unpause-vault").conditions).toEqual([ + { label: "Vault is paused", met: false }, + ]) + expect(actionOf(calm, everyRole, "clear-latest").conditions).toEqual([ + { label: "Ripcord raised", met: false }, + ]) + expect(actionOf(alarmed, everyRole, "unpause-token").conditions).toEqual([ + { label: "Share token is paused", met: true }, + ]) + }) + + it("leaves a precondition undecided when its state is unreadable", () => { + const state: EmergencyState = { + vault: "unreadable", + token: "unreadable", + oracle: "unreadable", + } + const actions = toEmergencyActions(state, everyRole, wallet) + + expect(actionOf(state, everyRole, "pause-vault").conditions).toEqual([ + { label: "Vault is open", met: null }, + ]) + expect( + actions.filter((action) => action.enabled).map((action) => action.id), + ).toEqual(["raise-ripcord"]) + }) + + it("keeps raising the ripcord available once raised, noting it already is", () => { + const raise = actionOf(alarmed, everyRole, "raise-ripcord") + + expect(raise.enabled).toBe(true) + expect(raise.conditions).toEqual([]) + expect(raise.note).toBe("Ripcord already raised.") + expect(actionOf(calm, everyRole, "raise-ripcord").note).toBeUndefined() + }) + + it("says what each action blocks and what stays open", () => { + expect(actionOf(calm, everyRole, "pause-vault").outcome).toBe( + "Stops new deposits, pricing and upgrades. Redemptions, claims, cancels and closing epochs stay open.", + ) + expect(actionOf(calm, everyRole, "pause-token").outcome).toBe( + "Stops share transfers between holders. Minting, burning, claims and forced transfers stay open.", + ) + expect(actionOf(calm, everyRole, "raise-ripcord").outcome).toBe( + "Stops pricing: no epoch can be fulfilled until it is lowered. Attesting stays open.", + ) + }) + + it("marks the actions that stop something as danger, and the ones that resume as not", () => { + const tones = Object.fromEntries( + toEmergencyActions(calm, everyRole, wallet).map((action) => [ + action.id, + action.tone, + ]), + ) + + expect(tones).toEqual({ + "pause-vault": "danger", + "unpause-vault": undefined, + "raise-ripcord": "danger", + "lower-ripcord": undefined, + "clear-latest": "danger", + "pause-token": "danger", + "unpause-token": undefined, + }) + }) + + it("disables an action whose grant the wallet cannot sign alone, saying why", () => { + const pause = actionOf( + calm, + [grantOf("vault guardian", { standing: { needs: 2 } })], + "pause-vault", + ) + + expect(pause.enabled).toBe(false) + expect(pause.unavailable).toBe( + "Needs 2 signatures; collecting them is not supported yet.", + ) + }) +}) + +describe("toEmergencyTiles", () => { + it("shows pending tiles while reading", () => { + const tiles = toEmergencyTiles({ + vault: "checking", + token: "checking", + oracle: "checking", + }) + + expect(tiles.map(({ label, pending }) => [label, pending])).toEqual([ + ["Vault", true], + ["Share token", true], + ["Oracle", true], + ]) + }) + + it("states each contract's current pause and the ripcord", () => { + expect(toEmergencyTiles(calm)).toEqual([ + { label: "Vault", value: "Open" }, + { label: "Share token", value: "Open" }, + { label: "Oracle", value: "Valid", note: "Ripcord not raised" }, + ]) + expect(toEmergencyTiles(alarmed)).toEqual([ + { label: "Vault", value: "Paused" }, + { label: "Share token", value: "Paused" }, + { label: "Oracle", value: "Paused", note: "Ripcord raised" }, + ]) + }) + + it("shows a stale oracle as stale with the ripcord down, and an unreadable state as unknown", () => { + expect( + toEmergencyTiles({ vault: "unreadable", token: "open", oracle: "stale" }), + ).toEqual([ + { label: "Vault", value: null }, + { label: "Share token", value: "Open" }, + { label: "Oracle", value: "Stale", note: "Ripcord not raised" }, + ]) + expect(toEmergencyTiles({ ...calm, oracle: "unreadable" })[2]).toEqual({ + label: "Oracle", + value: null, + }) + }) +}) diff --git a/app/src/pages/emergency.ts b/app/src/pages/emergency.ts new file mode 100644 index 00000000..a8e88828 --- /dev/null +++ b/app/src/pages/emergency.ts @@ -0,0 +1,236 @@ +import { type Metric } from "../components/vault/MetricsStrip" +import { type Grant } from "../hooks/useAdminAuthority" +import { type OracleStatus } from "../hooks/useOracleState" +import { type PauseState } from "../hooks/useVaultPaused" +import { + allMet, + type Condition, + grantFor, + type RailAction, + unavailableFor, +} from "./actions" + +export type EmergencyState = { + vault: PauseState + token: PauseState + oracle: OracleStatus +} + +type SettledState = { + vault: Exclude + token: Exclude + oracle: Exclude +} + +type PlainId = + | "pause-vault" + | "unpause-vault" + | "lower-ripcord" + | "clear-latest" + | "pause-token" + | "unpause-token" + +export type EmergencyAction = RailAction & + ({ id: PlainId } | { id: "raise-ripcord"; viaAdmin: boolean }) + +const ROLES = { + vaultGuardian: "vault guardian", + governance: "vault governance", + oracleGuardian: "oracle guardian", + oracleAdmin: "oracle admin", + tokenAdmin: "share-token admin", +} as const + +const PAUSE_LABELS = { open: "Open", paused: "Paused" } as const + +const ORACLE_LABELS = { + valid: "Valid", + stale: "Stale", + paused: "Paused", +} as const + +const pauseIs = ( + label: string, + state: SettledState["vault"], + wanted: "open" | "paused", +): Condition => ({ + label, + met: state === "unreadable" ? null : state === wanted, +}) + +const ripcordRaised = (oracle: SettledState["oracle"]): Condition => ({ + label: "Ripcord raised", + met: oracle === "unreadable" ? null : oracle === "paused", +}) + +type Spec = { + id: PlainId + label: string + conditions: Condition[] + outcome: string + danger?: boolean +} + +const toAction = ( + spec: Spec, + grant: Grant, + wallet: string, +): EmergencyAction => { + const unavailable = unavailableFor(grant, wallet) + return { + id: spec.id, + label: spec.label, + conditions: spec.conditions, + outcome: spec.outcome, + enabled: unavailable === undefined && allMet(spec.conditions), + ...(spec.danger === true ? { tone: "danger" as const } : {}), + ...(unavailable === undefined ? {} : { unavailable }), + } +} + +const vaultSpecs = (state: SettledState): [Spec, Spec] => [ + { + id: "pause-vault", + label: "Pause the vault", + conditions: [pauseIs("Vault is open", state.vault, "open")], + outcome: + "Stops new deposits, pricing and upgrades. Redemptions, claims, cancels and closing epochs stay open.", + danger: true, + }, + { + id: "unpause-vault", + label: "Unpause the vault", + conditions: [pauseIs("Vault is paused", state.vault, "paused")], + outcome: + "Resumes deposits, pricing and upgrades. A pending upgrade waits longer by the time the vault spent paused.", + }, +] + +const oracleAdminSpecs = (state: SettledState): [Spec, Spec] => [ + { + id: "lower-ripcord", + label: "Lower the ripcord", + conditions: [ripcordRaised(state.oracle)], + outcome: + "Resumes pricing: epochs can be fulfilled again while the price is valid.", + }, + { + id: "clear-latest", + label: "Clear the latest price", + conditions: [ripcordRaised(state.oracle)], + outcome: + "Removes the recorded price, so the next attestation is not limited by its distance from the last. Pricing stays stopped until the ripcord is lowered.", + danger: true, + }, +] + +const tokenSpecs = (state: SettledState): [Spec, Spec] => [ + { + id: "pause-token", + label: "Pause the share token", + conditions: [pauseIs("Share token is open", state.token, "open")], + outcome: + "Stops share transfers between holders. Minting, burning, claims and forced transfers stay open.", + danger: true, + }, + { + id: "unpause-token", + label: "Unpause the share token", + conditions: [pauseIs("Share token is paused", state.token, "paused")], + outcome: "Resumes share transfers between holders.", + }, +] + +const raiseGrant = ( + guardian: Grant | undefined, + admin: Grant | undefined, + wallet: string, +): Grant | undefined => { + if (guardian !== undefined && unavailableFor(guardian, wallet) === undefined) + return guardian + if (admin !== undefined && unavailableFor(admin, wallet) === undefined) + return admin + return guardian ?? admin +} + +function toRaiseAction( + state: SettledState, + grant: Grant, + wallet: string, +): EmergencyAction { + const unavailable = unavailableFor(grant, wallet) + return { + id: "raise-ripcord", + viaAdmin: grant.role === ROLES.oracleAdmin, + label: "Raise the ripcord", + conditions: [], + outcome: + "Stops pricing: no epoch can be fulfilled until it is lowered. Attesting stays open.", + ...(state.oracle === "paused" ? { note: "Ripcord already raised." } : {}), + enabled: unavailable === undefined, + tone: "danger", + ...(unavailable === undefined ? {} : { unavailable }), + } +} + +const isSettled = (state: EmergencyState): state is SettledState => + state.vault !== "checking" && + state.token !== "checking" && + state.oracle !== "checking" + +export function toEmergencyActions( + state: EmergencyState, + grants: Grant[], + wallet: string, +): EmergencyAction[] { + if (!isSettled(state) || grants.length === 0) return [] + const held = (role: string) => grantFor(grants, role, wallet) + const [pauseVault, unpauseVault] = vaultSpecs(state) + const [lower, clear] = oracleAdminSpecs(state) + const [pauseToken, unpauseToken] = tokenSpecs(state) + const oracleAdmin = held(ROLES.oracleAdmin) + const tokenAdmin = held(ROLES.tokenAdmin) + const raiser = raiseGrant(held(ROLES.oracleGuardian), oracleAdmin, wallet) + + const actions: EmergencyAction[] = [] + const guardian = held(ROLES.vaultGuardian) + if (guardian !== undefined) + actions.push(toAction(pauseVault, guardian, wallet)) + const governance = held(ROLES.governance) + if (governance !== undefined) + actions.push(toAction(unpauseVault, governance, wallet)) + if (raiser !== undefined) actions.push(toRaiseAction(state, raiser, wallet)) + if (oracleAdmin !== undefined) + actions.push( + toAction(lower, oracleAdmin, wallet), + toAction(clear, oracleAdmin, wallet), + ) + if (tokenAdmin !== undefined) + actions.push( + toAction(pauseToken, tokenAdmin, wallet), + toAction(unpauseToken, tokenAdmin, wallet), + ) + return actions +} + +const pauseTile = (label: string, state: PauseState): Metric => { + if (state === "checking") return { label, value: null, pending: true } + return { label, value: state === "unreadable" ? null : PAUSE_LABELS[state] } +} + +const oracleTile = (oracle: OracleStatus): Metric => { + const label = "Oracle" + if (oracle === "checking") return { label, value: null, pending: true } + if (oracle === "unreadable") return { label, value: null } + return { + label, + value: ORACLE_LABELS[oracle], + note: oracle === "paused" ? "Ripcord raised" : "Ripcord not raised", + } +} + +export const toEmergencyTiles = (state: EmergencyState): Metric[] => [ + pauseTile("Vault", state.vault), + pauseTile("Share token", state.token), + oracleTile(state.oracle), +] From 914b80fcc924550ebf85860976d3ca6b32c0f317 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 16:34:57 -0300 Subject: [PATCH 03/12] refactor(app): share one admin surface layout --- .../components/admin/CycleSurface.module.css | 62 ----------------- app/src/components/admin/CycleSurface.tsx | 69 +++++++++---------- app/src/components/admin/EmergencySection.tsx | 56 ++++++++------- ...on.module.css => SurfaceLayout.module.css} | 0 app/src/components/admin/SurfaceLayout.tsx | 18 +++++ 5 files changed, 77 insertions(+), 128 deletions(-) rename app/src/components/admin/{EmergencySection.module.css => SurfaceLayout.module.css} (100%) create mode 100644 app/src/components/admin/SurfaceLayout.tsx diff --git a/app/src/components/admin/CycleSurface.module.css b/app/src/components/admin/CycleSurface.module.css index ac8a4962..41e980d0 100644 --- a/app/src/components/admin/CycleSurface.module.css +++ b/app/src/components/admin/CycleSurface.module.css @@ -1,65 +1,3 @@ -.layout { - container-type: inline-size; -} - -.body { - display: grid; - grid-template-columns: minmax(0, 2.2fr) minmax(320px, 1fr); - align-items: start; - gap: 18px 32px; -} - -.main { - display: flex; - flex-direction: column; - gap: 22px; - min-width: 0; - grid-column: 1; - grid-row: 1; -} - -.side { - position: sticky; - top: 24px; - max-height: calc(100dvh - 24px - 24px); - overflow-y: auto; - min-width: 0; - grid-column: 2; - grid-row: 1; -} - -@container (max-width: 1080px) { - .body { - grid-template-columns: minmax(0, 1fr); - } - - .main, - .side { - grid-column: 1; - grid-row: auto; - } - - .side { - position: static; - max-height: none; - overflow-y: visible; - } -} - -.group { - display: flex; - flex-direction: column; - gap: 11px; - border-top: 1px solid var(--vault-border-hairline); - padding-top: 20px; -} - -.groupTitle { - margin: 0; - color: var(--vault-ink); - font-weight: 600; -} - .rows { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/app/src/components/admin/CycleSurface.tsx b/app/src/components/admin/CycleSurface.tsx index b7bd6f90..405d63e5 100644 --- a/app/src/components/admin/CycleSurface.tsx +++ b/app/src/components/admin/CycleSurface.tsx @@ -10,6 +10,8 @@ import AddressRows from "../vault/AddressRows" import MetricsStrip, { type Metric } from "../vault/MetricsStrip" import styles from "./CycleSurface.module.css" import Disclosure from "./Disclosure" +import layoutStyles from "./SurfaceLayout.module.css" +import SurfaceLayout from "./SurfaceLayout" export type CycleGroup = FigureGroup & { tiles: Metric[] @@ -86,8 +88,10 @@ const FigureRows: React.FC<{ rows: FigureRow[] }> = ({ rows }) => ( ) const Group: React.FC<{ group: CycleGroup }> = ({ group }) => ( -
    -

    {group.title}

    +
    +

    + {group.title} +

    {group.addresses !== undefined && } @@ -106,8 +110,8 @@ const Section: React.FC<{ empty: string children: React.ReactNode }> = ({ title, state, unreadable, empty, children }) => ( -
    -

    {title}

    +
    +

    {title}

    {state.status === "checking" && ( )} @@ -205,39 +209,30 @@ const CycleSurface: React.FC = ({ rail, contracts, }) => ( -
    -
    - -
    - {unreadable ? ( -

    Could not read the vault's cycle.

    - ) : ( - groups.map((group) => ) - )} -
    - {epochs.status === "loaded" && } -
    -
    - {activity.status === "loaded" && ( - - )} -
    - {contracts} -
    -
    -
    + + {unreadable ? ( +

    Could not read the vault's cycle.

    + ) : ( + groups.map((group) => ) + )} +
    + {epochs.status === "loaded" && } +
    +
    + {activity.status === "loaded" && } +
    + {contracts} +
    ) export default CycleSurface diff --git a/app/src/components/admin/EmergencySection.tsx b/app/src/components/admin/EmergencySection.tsx index 9beb7343..46a1f4df 100644 --- a/app/src/components/admin/EmergencySection.tsx +++ b/app/src/components/admin/EmergencySection.tsx @@ -24,7 +24,8 @@ import TransactionModal, { type TransactionModalProps, } from "../vault/TransactionModal" import CycleActionRail from "./CycleActionRail" -import styles from "./EmergencySection.module.css" +import layoutStyles from "./SurfaceLayout.module.css" +import SurfaceLayout from "./SurfaceLayout" const RAISE = "raise" const LOWER = "lower" @@ -105,34 +106,31 @@ const EmergencySection: React.FC<{ return ( <> -
    -
    - -
    -
    -

    - Status -

    - -
    - {contracts} -
    -
    -
    + 0} + unreadable={false} + onRun={run} + /> + } + > +
    +

    + Status +

    + +
    + {contracts} +
    {active && activeModal !== undefined && ( )} diff --git a/app/src/components/admin/EmergencySection.module.css b/app/src/components/admin/SurfaceLayout.module.css similarity index 100% rename from app/src/components/admin/EmergencySection.module.css rename to app/src/components/admin/SurfaceLayout.module.css diff --git a/app/src/components/admin/SurfaceLayout.tsx b/app/src/components/admin/SurfaceLayout.tsx new file mode 100644 index 00000000..5e43c77c --- /dev/null +++ b/app/src/components/admin/SurfaceLayout.tsx @@ -0,0 +1,18 @@ +import React from "react" +import styles from "./SurfaceLayout.module.css" + +const SurfaceLayout: React.FC<{ + side: React.ReactNode + children: React.ReactNode +}> = ({ side, children }) => ( +
    +
    + +
    {children}
    +
    +
    +) + +export default SurfaceLayout From 5446d8f8a218708c731d5261cc84914490352814 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 16:37:04 -0300 Subject: [PATCH 04/12] refactor(app): merge the emergency transaction hooks --- .../admin/EmergencySection.test.tsx | 14 +- app/src/components/admin/EmergencySection.tsx | 16 +- app/src/hooks/useClearLatest.test.ts | 110 ---------- app/src/hooks/useClearLatest.ts | 25 --- .../hooks/useEmergencyTransactions.test.ts | 203 ++++++++++++++++++ app/src/hooks/useEmergencyTransactions.ts | 73 +++++++ app/src/hooks/usePauseToken.test.ts | 108 ---------- app/src/hooks/usePauseToken.ts | 27 --- app/src/hooks/usePauseVault.test.ts | 108 ---------- app/src/hooks/usePauseVault.ts | 27 --- app/src/hooks/useRaiseRipcord.test.ts | 110 ---------- app/src/hooks/useRaiseRipcord.ts | 25 --- app/src/hooks/useSetRipcord.test.ts | 111 ---------- app/src/hooks/useSetRipcord.ts | 25 --- app/src/hooks/useUnpauseToken.test.ts | 108 ---------- app/src/hooks/useUnpauseToken.ts | 27 --- app/src/hooks/useUnpauseVault.test.ts | 108 ---------- app/src/hooks/useUnpauseVault.ts | 27 --- 18 files changed, 286 insertions(+), 966 deletions(-) delete mode 100644 app/src/hooks/useClearLatest.test.ts delete mode 100644 app/src/hooks/useClearLatest.ts create mode 100644 app/src/hooks/useEmergencyTransactions.test.ts create mode 100644 app/src/hooks/useEmergencyTransactions.ts delete mode 100644 app/src/hooks/usePauseToken.test.ts delete mode 100644 app/src/hooks/usePauseToken.ts delete mode 100644 app/src/hooks/usePauseVault.test.ts delete mode 100644 app/src/hooks/usePauseVault.ts delete mode 100644 app/src/hooks/useRaiseRipcord.test.ts delete mode 100644 app/src/hooks/useRaiseRipcord.ts delete mode 100644 app/src/hooks/useSetRipcord.test.ts delete mode 100644 app/src/hooks/useSetRipcord.ts delete mode 100644 app/src/hooks/useUnpauseToken.test.ts delete mode 100644 app/src/hooks/useUnpauseToken.ts delete mode 100644 app/src/hooks/useUnpauseVault.test.ts delete mode 100644 app/src/hooks/useUnpauseVault.ts diff --git a/app/src/components/admin/EmergencySection.test.tsx b/app/src/components/admin/EmergencySection.test.tsx index 0a3f3918..46c2d2a5 100644 --- a/app/src/components/admin/EmergencySection.test.tsx +++ b/app/src/components/admin/EmergencySection.test.tsx @@ -55,25 +55,13 @@ vi.mock("../../hooks/useTokenPaused", () => ({ vi.mock("../../hooks/useOracleState", () => ({ useOracleState: useOracleStateMock, })) -vi.mock("../../hooks/usePauseVault", () => ({ +vi.mock("../../hooks/useEmergencyTransactions", () => ({ usePauseVault: () => pauseVaultMock, -})) -vi.mock("../../hooks/useUnpauseVault", () => ({ useUnpauseVault: () => unpauseVaultMock, -})) -vi.mock("../../hooks/useRaiseRipcord", () => ({ useRaiseRipcord: () => raiseRipcordMock, -})) -vi.mock("../../hooks/useSetRipcord", () => ({ useSetRipcord: () => setRipcordMock, -})) -vi.mock("../../hooks/useClearLatest", () => ({ useClearLatest: () => clearLatestMock, -})) -vi.mock("../../hooks/usePauseToken", () => ({ usePauseToken: () => pauseTokenMock, -})) -vi.mock("../../hooks/useUnpauseToken", () => ({ useUnpauseToken: () => unpauseTokenMock, })) diff --git a/app/src/components/admin/EmergencySection.tsx b/app/src/components/admin/EmergencySection.tsx index 46a1f4df..f05de771 100644 --- a/app/src/components/admin/EmergencySection.tsx +++ b/app/src/components/admin/EmergencySection.tsx @@ -1,16 +1,18 @@ import React from "react" import { type Grant } from "../../hooks/useAdminAuthority" -import { useClearLatest } from "../../hooks/useClearLatest" import { type TransactionStatus } from "../../hooks/useContractTransaction" +import { + useClearLatest, + usePauseToken, + usePauseVault, + useRaiseRipcord, + useSetRipcord, + useUnpauseToken, + useUnpauseVault, +} from "../../hooks/useEmergencyTransactions" import { useOracleState } from "../../hooks/useOracleState" -import { usePauseToken } from "../../hooks/usePauseToken" -import { usePauseVault } from "../../hooks/usePauseVault" import { usePendingTransaction } from "../../hooks/usePendingTransaction" -import { useRaiseRipcord } from "../../hooks/useRaiseRipcord" -import { useSetRipcord } from "../../hooks/useSetRipcord" import { useTokenPaused } from "../../hooks/useTokenPaused" -import { useUnpauseToken } from "../../hooks/useUnpauseToken" -import { useUnpauseVault } from "../../hooks/useUnpauseVault" import { useVaultPaused } from "../../hooks/useVaultPaused" import { type EmergencyAction, diff --git a/app/src/hooks/useClearLatest.test.ts b/app/src/hooks/useClearLatest.test.ts deleted file mode 100644 index 0f608758..00000000 --- a/app/src/hooks/useClearLatest.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -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 { useClearLatest } from "./useClearLatest" -import { oracleWriteKeys } from "./useOracleState" - -const { clientMock, writerMock } = vi.hoisted(() => ({ - clientMock: { clear_latest: vi.fn() }, - writerMock: vi.fn(), -})) - -vi.mock("../config/clients", () => ({ - navOracleWriter: writerMock, -})) - -const renderClearLatest = () => renderWithWallet(useClearLatest) - -describe("useClearLatest", () => { - beforeEach(() => { - vi.clearAllMocks() - writerMock.mockResolvedValue(clientMock) - }) - - it("signs clear_latest as the connected wallet and confirms", async () => { - const gate = deferred() - clientMock.clear_latest.mockResolvedValue({ - simulation: undefined, - signAndSend: vi.fn( - async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { - await gate.promise - watcher.onSubmitted() - return { - getTransactionResponse: { status: "SUCCESS" }, - result: undefined, - } - }, - ), - }) - const { result } = renderClearLatest() - - expect(result.current.submit()).toBe(true) - await waitFor(() => - expect(result.current.status).toEqual({ status: "awaiting-signature" }), - ) - - gate.resolve() - await waitFor(() => - expect(result.current.status).toEqual({ status: "confirmed" }), - ) - expect(clientMock.clear_latest).toHaveBeenCalledWith({ - caller: investorAddress, - }) - }) - - it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { - const signAndSend = vi.fn() - clientMock.clear_latest.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #3008)" }, - signAndSend, - }) - const { result, queryClient } = renderClearLatest() - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - - await act(() => result.current.submit()) - - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "contract-error", code: 3008 }, - }) - expect(signAndSend).not.toHaveBeenCalled() - expect(invalidateQueries).not.toHaveBeenCalled() - }) - - it("tells a declined signature apart from a failure", async () => { - clientMock.clear_latest.mockResolvedValue({ - simulation: undefined, - signAndSend: vi - .fn() - .mockRejectedValue( - new AssembledTransaction.Errors.UserRejected("User declined access"), - ), - }) - const { result } = renderClearLatest() - - await act(() => result.current.submit()) - - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "declined" }, - }) - }) - - it("refreshes the state it changed once confirmed", async () => { - clientMock.clear_latest.mockResolvedValue({ - simulation: undefined, - signAndSend: vi.fn().mockResolvedValue({ - getTransactionResponse: { status: "SUCCESS" }, - result: undefined, - }), - }) - const { result, queryClient } = renderClearLatest() - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - - await act(() => result.current.submit()) - - for (const queryKey of oracleWriteKeys) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) - }) -}) diff --git a/app/src/hooks/useClearLatest.ts b/app/src/hooks/useClearLatest.ts deleted file mode 100644 index b2150fdc..00000000 --- a/app/src/hooks/useClearLatest.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { type Signer } from "@stellar-scaffold/app-lib" -import { navOracleWriter } from "../config/clients" -import { - useContractTransaction, - type UseContractTransaction, -} from "./useContractTransaction" -import { oracleWriteKeys } from "./useOracleState" - -const call = (signer: Signer) => - navOracleWriter(signer).then((oracle) => - oracle.clear_latest({ caller: signer.publicKey }), - ) - -const toConfirmed = () => ({}) - -export function useClearLatest(): UseContractTransaction< - void, - Record -> { - return useContractTransaction>( - call, - toConfirmed, - oracleWriteKeys, - ) -} diff --git a/app/src/hooks/useEmergencyTransactions.test.ts b/app/src/hooks/useEmergencyTransactions.test.ts new file mode 100644 index 00000000..8a2d7120 --- /dev/null +++ b/app/src/hooks/useEmergencyTransactions.test.ts @@ -0,0 +1,203 @@ +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 { + useClearLatest, + usePauseToken, + usePauseVault, + useRaiseRipcord, + useSetRipcord, + useUnpauseToken, + useUnpauseVault, +} from "./useEmergencyTransactions" +import { oracleWriteKeys } from "./useOracleState" +import { tokenPausedKey } from "./useTokenPaused" +import { vaultPausedKey } from "./useVaultPaused" + +const { vault, token, oracle } = vi.hoisted(() => ({ + vault: { pause: vi.fn(), unpause: vi.fn() }, + token: { pause: vi.fn(), unpause: vi.fn() }, + oracle: { + raise_ripcord: vi.fn(), + set_ripcord: vi.fn(), + clear_latest: vi.fn(), + }, +})) + +vi.mock("../config/clients", () => ({ + asyncVaultWriter: async () => vault, + shareTokenWriter: async () => token, + navOracleWriter: async () => oracle, +})) + +type Case = { + name: string + useHook: () => { + status: unknown + submit: (arg: never) => boolean + } + method: ReturnType + arg: unknown + args: object + keys: readonly (readonly string[])[] + refusal: number +} + +const cases: Case[] = [ + { + name: "usePauseVault", + useHook: usePauseVault, + method: vault.pause, + arg: undefined, + args: { caller: investorAddress }, + keys: [vaultPausedKey], + refusal: 1000, + }, + { + name: "useUnpauseVault", + useHook: useUnpauseVault, + method: vault.unpause, + arg: undefined, + args: { caller: investorAddress }, + keys: [vaultPausedKey], + refusal: 1001, + }, + { + name: "usePauseToken", + useHook: usePauseToken, + method: token.pause, + arg: undefined, + args: { caller: investorAddress }, + keys: [tokenPausedKey], + refusal: 1000, + }, + { + name: "useUnpauseToken", + useHook: useUnpauseToken, + method: token.unpause, + arg: undefined, + args: { caller: investorAddress }, + keys: [tokenPausedKey], + refusal: 1001, + }, + { + name: "useRaiseRipcord", + useHook: useRaiseRipcord, + method: oracle.raise_ripcord, + arg: undefined, + args: { caller: investorAddress }, + keys: oracleWriteKeys, + refusal: 2000, + }, + { + name: "useClearLatest", + useHook: useClearLatest, + method: oracle.clear_latest, + arg: undefined, + args: { caller: investorAddress }, + keys: oracleWriteKeys, + refusal: 3008, + }, + { + name: "useSetRipcord", + useHook: useSetRipcord, + method: oracle.set_ripcord, + arg: false, + args: { paused: false, caller: investorAddress }, + keys: oracleWriteKeys, + refusal: 2000, + }, +] + +const confirmed = () => ({ + simulation: undefined, + signAndSend: vi.fn().mockResolvedValue({ + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + }), +}) + +describe.each(cases)("$name", (testCase) => { + const { useHook, method, arg, args, keys, refusal } = testCase + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("signs as the connected wallet and confirms", async () => { + const gate = deferred() + method.mockResolvedValue({ + simulation: undefined, + signAndSend: vi.fn( + async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { + await gate.promise + watcher.onSubmitted() + return { + getTransactionResponse: { status: "SUCCESS" }, + result: undefined, + } + }, + ), + }) + const { result } = renderWithWallet(useHook) + + expect(result.current.submit(arg as never)).toBe(true) + await waitFor(() => + expect(result.current.status).toEqual({ status: "awaiting-signature" }), + ) + + gate.resolve() + await waitFor(() => + expect(result.current.status).toEqual({ status: "confirmed" }), + ) + expect(method).toHaveBeenCalledWith(args) + }) + + it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { + const signAndSend = vi.fn() + method.mockResolvedValue({ + simulation: { error: `HostError: Error(Contract, #${refusal})` }, + signAndSend, + }) + const { result } = renderWithWallet(useHook) + + await act(() => result.current.submit(arg as never)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "contract-error", code: refusal }, + }) + expect(signAndSend).not.toHaveBeenCalled() + }) + + it("tells a declined signature apart from a failure", async () => { + method.mockResolvedValue({ + simulation: undefined, + signAndSend: vi + .fn() + .mockRejectedValue( + new AssembledTransaction.Errors.UserRejected("User declined access"), + ), + }) + const { result } = renderWithWallet(useHook) + + await act(() => result.current.submit(arg as never)) + + expect(result.current.status).toEqual({ + status: "failed", + failure: { kind: "declined" }, + }) + }) + + it("refreshes the state it changed once confirmed", async () => { + method.mockResolvedValue(confirmed()) + const { result, queryClient } = renderWithWallet(useHook) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + await act(() => result.current.submit(arg as never)) + + for (const queryKey of keys) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + }) +}) diff --git a/app/src/hooks/useEmergencyTransactions.ts b/app/src/hooks/useEmergencyTransactions.ts new file mode 100644 index 00000000..c3c7ff04 --- /dev/null +++ b/app/src/hooks/useEmergencyTransactions.ts @@ -0,0 +1,73 @@ +import { type AssembledTransaction } from "@stellar/stellar-sdk/contract" +import { type Signer } from "@stellar-scaffold/app-lib" +import { type QueryKey } from "@tanstack/react-query" +import { + asyncVaultWriter, + navOracleWriter, + shareTokenWriter, +} from "../config/clients" +import { + useContractTransaction, + type UseContractTransaction, +} from "./useContractTransaction" +import { oracleWriteKeys } from "./useOracleState" +import { tokenPausedKey } from "./useTokenPaused" +import { vaultPausedKey } from "./useVaultPaused" + +type NoResult = Record + +const toConfirmed = () => ({}) + +const emergencyTransaction = + ( + call: (signer: Signer, arg: TArg) => Promise>, + keys: readonly QueryKey[], + ) => + (): UseContractTransaction => + useContractTransaction(call, toConfirmed, keys) + +const vaultCall = (method: "pause" | "unpause") => (signer: Signer) => + asyncVaultWriter(signer).then((vault) => + vault[method]({ caller: signer.publicKey }), + ) + +const tokenCall = (method: "pause" | "unpause") => (signer: Signer) => + shareTokenWriter(signer).then((token) => + token[method]({ caller: signer.publicKey }), + ) + +const oracleCall = + (method: "raise_ripcord" | "clear_latest") => (signer: Signer) => + navOracleWriter(signer).then((oracle) => + oracle[method]({ caller: signer.publicKey }), + ) + +const setRipcordCall = (signer: Signer, paused: boolean) => + navOracleWriter(signer).then((oracle) => + oracle.set_ripcord({ paused, caller: signer.publicKey }), + ) + +export const usePauseVault = emergencyTransaction(vaultCall("pause"), [ + vaultPausedKey, +]) +export const useUnpauseVault = emergencyTransaction(vaultCall("unpause"), [ + vaultPausedKey, +]) +export const usePauseToken = emergencyTransaction(tokenCall("pause"), [ + tokenPausedKey, +]) +export const useUnpauseToken = emergencyTransaction(tokenCall("unpause"), [ + tokenPausedKey, +]) +export const useRaiseRipcord = emergencyTransaction( + oracleCall("raise_ripcord"), + oracleWriteKeys, +) +export const useClearLatest = emergencyTransaction( + oracleCall("clear_latest"), + oracleWriteKeys, +) +export const useSetRipcord = emergencyTransaction( + setRipcordCall, + oracleWriteKeys, +) diff --git a/app/src/hooks/usePauseToken.test.ts b/app/src/hooks/usePauseToken.test.ts deleted file mode 100644 index 34cb20c3..00000000 --- a/app/src/hooks/usePauseToken.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 { usePauseToken } from "./usePauseToken" -import { tokenPausedKey } from "./useTokenPaused" - -const { clientMock, writerMock } = vi.hoisted(() => ({ - clientMock: { pause: vi.fn() }, - writerMock: vi.fn(), -})) - -vi.mock("../config/clients", () => ({ - shareTokenWriter: writerMock, -})) - -const renderPauseToken = () => renderWithWallet(usePauseToken) - -describe("usePauseToken", () => { - beforeEach(() => { - vi.clearAllMocks() - writerMock.mockResolvedValue(clientMock) - }) - - it("signs pause as the connected wallet and confirms", async () => { - const gate = deferred() - clientMock.pause.mockResolvedValue({ - simulation: undefined, - signAndSend: vi.fn( - async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { - await gate.promise - watcher.onSubmitted() - return { - getTransactionResponse: { status: "SUCCESS" }, - result: undefined, - } - }, - ), - }) - const { result } = renderPauseToken() - - expect(result.current.submit()).toBe(true) - await waitFor(() => - expect(result.current.status).toEqual({ status: "awaiting-signature" }), - ) - - gate.resolve() - await waitFor(() => - expect(result.current.status).toEqual({ status: "confirmed" }), - ) - expect(clientMock.pause).toHaveBeenCalledWith({ caller: investorAddress }) - }) - - it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { - const signAndSend = vi.fn() - clientMock.pause.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #1000)" }, - signAndSend, - }) - const { result, queryClient } = renderPauseToken() - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - - await act(() => result.current.submit()) - - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "contract-error", code: 1000 }, - }) - expect(signAndSend).not.toHaveBeenCalled() - expect(invalidateQueries).not.toHaveBeenCalled() - }) - - it("tells a declined signature apart from a failure", async () => { - clientMock.pause.mockResolvedValue({ - simulation: undefined, - signAndSend: vi - .fn() - .mockRejectedValue( - new AssembledTransaction.Errors.UserRejected("User declined access"), - ), - }) - const { result } = renderPauseToken() - - await act(() => result.current.submit()) - - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "declined" }, - }) - }) - - it("refreshes the state it changed once confirmed", async () => { - clientMock.pause.mockResolvedValue({ - simulation: undefined, - signAndSend: vi.fn().mockResolvedValue({ - getTransactionResponse: { status: "SUCCESS" }, - result: undefined, - }), - }) - const { result, queryClient } = renderPauseToken() - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - - await act(() => result.current.submit()) - - for (const queryKey of [tokenPausedKey]) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) - }) -}) diff --git a/app/src/hooks/usePauseToken.ts b/app/src/hooks/usePauseToken.ts deleted file mode 100644 index 226eec96..00000000 --- a/app/src/hooks/usePauseToken.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { type Signer } from "@stellar-scaffold/app-lib" -import { shareTokenWriter } from "../config/clients" -import { - useContractTransaction, - type UseContractTransaction, -} from "./useContractTransaction" -import { tokenPausedKey } from "./useTokenPaused" - -const WRITE_KEYS = [tokenPausedKey] - -const call = (signer: Signer) => - shareTokenWriter(signer).then((token) => - token.pause({ caller: signer.publicKey }), - ) - -const toConfirmed = () => ({}) - -export function usePauseToken(): UseContractTransaction< - void, - Record -> { - return useContractTransaction>( - call, - toConfirmed, - WRITE_KEYS, - ) -} diff --git a/app/src/hooks/usePauseVault.test.ts b/app/src/hooks/usePauseVault.test.ts deleted file mode 100644 index 1e947ba0..00000000 --- a/app/src/hooks/usePauseVault.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 { usePauseVault } from "./usePauseVault" -import { vaultPausedKey } from "./useVaultPaused" - -const { clientMock, writerMock } = vi.hoisted(() => ({ - clientMock: { pause: vi.fn() }, - writerMock: vi.fn(), -})) - -vi.mock("../config/clients", () => ({ - asyncVaultWriter: writerMock, -})) - -const renderPauseVault = () => renderWithWallet(usePauseVault) - -describe("usePauseVault", () => { - beforeEach(() => { - vi.clearAllMocks() - writerMock.mockResolvedValue(clientMock) - }) - - it("signs pause as the connected wallet and confirms", async () => { - const gate = deferred() - clientMock.pause.mockResolvedValue({ - simulation: undefined, - signAndSend: vi.fn( - async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { - await gate.promise - watcher.onSubmitted() - return { - getTransactionResponse: { status: "SUCCESS" }, - result: undefined, - } - }, - ), - }) - const { result } = renderPauseVault() - - expect(result.current.submit()).toBe(true) - await waitFor(() => - expect(result.current.status).toEqual({ status: "awaiting-signature" }), - ) - - gate.resolve() - await waitFor(() => - expect(result.current.status).toEqual({ status: "confirmed" }), - ) - expect(clientMock.pause).toHaveBeenCalledWith({ caller: investorAddress }) - }) - - it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { - const signAndSend = vi.fn() - clientMock.pause.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #1000)" }, - signAndSend, - }) - const { result, queryClient } = renderPauseVault() - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - - await act(() => result.current.submit()) - - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "contract-error", code: 1000 }, - }) - expect(signAndSend).not.toHaveBeenCalled() - expect(invalidateQueries).not.toHaveBeenCalled() - }) - - it("tells a declined signature apart from a failure", async () => { - clientMock.pause.mockResolvedValue({ - simulation: undefined, - signAndSend: vi - .fn() - .mockRejectedValue( - new AssembledTransaction.Errors.UserRejected("User declined access"), - ), - }) - const { result } = renderPauseVault() - - await act(() => result.current.submit()) - - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "declined" }, - }) - }) - - it("refreshes the state it changed once confirmed", async () => { - clientMock.pause.mockResolvedValue({ - simulation: undefined, - signAndSend: vi.fn().mockResolvedValue({ - getTransactionResponse: { status: "SUCCESS" }, - result: undefined, - }), - }) - const { result, queryClient } = renderPauseVault() - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - - await act(() => result.current.submit()) - - for (const queryKey of [vaultPausedKey]) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) - }) -}) diff --git a/app/src/hooks/usePauseVault.ts b/app/src/hooks/usePauseVault.ts deleted file mode 100644 index 2301ddd6..00000000 --- a/app/src/hooks/usePauseVault.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { type Signer } from "@stellar-scaffold/app-lib" -import { asyncVaultWriter } from "../config/clients" -import { - useContractTransaction, - type UseContractTransaction, -} from "./useContractTransaction" -import { vaultPausedKey } from "./useVaultPaused" - -const WRITE_KEYS = [vaultPausedKey] - -const call = (signer: Signer) => - asyncVaultWriter(signer).then((vault) => - vault.pause({ caller: signer.publicKey }), - ) - -const toConfirmed = () => ({}) - -export function usePauseVault(): UseContractTransaction< - void, - Record -> { - return useContractTransaction>( - call, - toConfirmed, - WRITE_KEYS, - ) -} diff --git a/app/src/hooks/useRaiseRipcord.test.ts b/app/src/hooks/useRaiseRipcord.test.ts deleted file mode 100644 index f3fb99a9..00000000 --- a/app/src/hooks/useRaiseRipcord.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -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 { oracleWriteKeys } from "./useOracleState" -import { useRaiseRipcord } from "./useRaiseRipcord" - -const { clientMock, writerMock } = vi.hoisted(() => ({ - clientMock: { raise_ripcord: vi.fn() }, - writerMock: vi.fn(), -})) - -vi.mock("../config/clients", () => ({ - navOracleWriter: writerMock, -})) - -const renderRaiseRipcord = () => renderWithWallet(useRaiseRipcord) - -describe("useRaiseRipcord", () => { - beforeEach(() => { - vi.clearAllMocks() - writerMock.mockResolvedValue(clientMock) - }) - - it("signs raise_ripcord as the connected wallet and confirms", async () => { - const gate = deferred() - clientMock.raise_ripcord.mockResolvedValue({ - simulation: undefined, - signAndSend: vi.fn( - async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { - await gate.promise - watcher.onSubmitted() - return { - getTransactionResponse: { status: "SUCCESS" }, - result: undefined, - } - }, - ), - }) - const { result } = renderRaiseRipcord() - - expect(result.current.submit()).toBe(true) - await waitFor(() => - expect(result.current.status).toEqual({ status: "awaiting-signature" }), - ) - - gate.resolve() - await waitFor(() => - expect(result.current.status).toEqual({ status: "confirmed" }), - ) - expect(clientMock.raise_ripcord).toHaveBeenCalledWith({ - caller: investorAddress, - }) - }) - - it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { - const signAndSend = vi.fn() - clientMock.raise_ripcord.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #2000)" }, - signAndSend, - }) - const { result, queryClient } = renderRaiseRipcord() - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - - await act(() => result.current.submit()) - - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "contract-error", code: 2000 }, - }) - expect(signAndSend).not.toHaveBeenCalled() - expect(invalidateQueries).not.toHaveBeenCalled() - }) - - it("tells a declined signature apart from a failure", async () => { - clientMock.raise_ripcord.mockResolvedValue({ - simulation: undefined, - signAndSend: vi - .fn() - .mockRejectedValue( - new AssembledTransaction.Errors.UserRejected("User declined access"), - ), - }) - const { result } = renderRaiseRipcord() - - await act(() => result.current.submit()) - - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "declined" }, - }) - }) - - it("refreshes the state it changed once confirmed", async () => { - clientMock.raise_ripcord.mockResolvedValue({ - simulation: undefined, - signAndSend: vi.fn().mockResolvedValue({ - getTransactionResponse: { status: "SUCCESS" }, - result: undefined, - }), - }) - const { result, queryClient } = renderRaiseRipcord() - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - - await act(() => result.current.submit()) - - for (const queryKey of oracleWriteKeys) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) - }) -}) diff --git a/app/src/hooks/useRaiseRipcord.ts b/app/src/hooks/useRaiseRipcord.ts deleted file mode 100644 index 625f455f..00000000 --- a/app/src/hooks/useRaiseRipcord.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { type Signer } from "@stellar-scaffold/app-lib" -import { navOracleWriter } from "../config/clients" -import { - useContractTransaction, - type UseContractTransaction, -} from "./useContractTransaction" -import { oracleWriteKeys } from "./useOracleState" - -const call = (signer: Signer) => - navOracleWriter(signer).then((oracle) => - oracle.raise_ripcord({ caller: signer.publicKey }), - ) - -const toConfirmed = () => ({}) - -export function useRaiseRipcord(): UseContractTransaction< - void, - Record -> { - return useContractTransaction>( - call, - toConfirmed, - oracleWriteKeys, - ) -} diff --git a/app/src/hooks/useSetRipcord.test.ts b/app/src/hooks/useSetRipcord.test.ts deleted file mode 100644 index 70b8cb2d..00000000 --- a/app/src/hooks/useSetRipcord.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -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 { oracleWriteKeys } from "./useOracleState" -import { useSetRipcord } from "./useSetRipcord" - -const { clientMock, writerMock } = vi.hoisted(() => ({ - clientMock: { set_ripcord: vi.fn() }, - writerMock: vi.fn(), -})) - -vi.mock("../config/clients", () => ({ - navOracleWriter: writerMock, -})) - -const renderSetRipcord = () => renderWithWallet(useSetRipcord) - -describe("useSetRipcord", () => { - beforeEach(() => { - vi.clearAllMocks() - writerMock.mockResolvedValue(clientMock) - }) - - it("signs set_ripcord as the connected wallet and confirms", async () => { - const gate = deferred() - clientMock.set_ripcord.mockResolvedValue({ - simulation: undefined, - signAndSend: vi.fn( - async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { - await gate.promise - watcher.onSubmitted() - return { - getTransactionResponse: { status: "SUCCESS" }, - result: undefined, - } - }, - ), - }) - const { result } = renderSetRipcord() - - expect(result.current.submit(false)).toBe(true) - await waitFor(() => - expect(result.current.status).toEqual({ status: "awaiting-signature" }), - ) - - gate.resolve() - await waitFor(() => - expect(result.current.status).toEqual({ status: "confirmed" }), - ) - expect(clientMock.set_ripcord).toHaveBeenCalledWith({ - paused: false, - caller: investorAddress, - }) - }) - - it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { - const signAndSend = vi.fn() - clientMock.set_ripcord.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #2000)" }, - signAndSend, - }) - const { result, queryClient } = renderSetRipcord() - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - - await act(() => result.current.submit(false)) - - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "contract-error", code: 2000 }, - }) - expect(signAndSend).not.toHaveBeenCalled() - expect(invalidateQueries).not.toHaveBeenCalled() - }) - - it("tells a declined signature apart from a failure", async () => { - clientMock.set_ripcord.mockResolvedValue({ - simulation: undefined, - signAndSend: vi - .fn() - .mockRejectedValue( - new AssembledTransaction.Errors.UserRejected("User declined access"), - ), - }) - const { result } = renderSetRipcord() - - await act(() => result.current.submit(false)) - - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "declined" }, - }) - }) - - it("refreshes the state it changed once confirmed", async () => { - clientMock.set_ripcord.mockResolvedValue({ - simulation: undefined, - signAndSend: vi.fn().mockResolvedValue({ - getTransactionResponse: { status: "SUCCESS" }, - result: undefined, - }), - }) - const { result, queryClient } = renderSetRipcord() - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - - await act(() => result.current.submit(false)) - - for (const queryKey of oracleWriteKeys) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) - }) -}) diff --git a/app/src/hooks/useSetRipcord.ts b/app/src/hooks/useSetRipcord.ts deleted file mode 100644 index 75fd9642..00000000 --- a/app/src/hooks/useSetRipcord.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { type Signer } from "@stellar-scaffold/app-lib" -import { navOracleWriter } from "../config/clients" -import { - useContractTransaction, - type UseContractTransaction, -} from "./useContractTransaction" -import { oracleWriteKeys } from "./useOracleState" - -const call = (signer: Signer, paused: boolean) => - navOracleWriter(signer).then((oracle) => - oracle.set_ripcord({ paused, caller: signer.publicKey }), - ) - -const toConfirmed = () => ({}) - -export function useSetRipcord(): UseContractTransaction< - boolean, - Record -> { - return useContractTransaction>( - call, - toConfirmed, - oracleWriteKeys, - ) -} diff --git a/app/src/hooks/useUnpauseToken.test.ts b/app/src/hooks/useUnpauseToken.test.ts deleted file mode 100644 index 10bbcd3f..00000000 --- a/app/src/hooks/useUnpauseToken.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 { tokenPausedKey } from "./useTokenPaused" -import { useUnpauseToken } from "./useUnpauseToken" - -const { clientMock, writerMock } = vi.hoisted(() => ({ - clientMock: { unpause: vi.fn() }, - writerMock: vi.fn(), -})) - -vi.mock("../config/clients", () => ({ - shareTokenWriter: writerMock, -})) - -const renderUnpauseToken = () => renderWithWallet(useUnpauseToken) - -describe("useUnpauseToken", () => { - beforeEach(() => { - vi.clearAllMocks() - writerMock.mockResolvedValue(clientMock) - }) - - it("signs unpause as the connected wallet and confirms", async () => { - const gate = deferred() - clientMock.unpause.mockResolvedValue({ - simulation: undefined, - signAndSend: vi.fn( - async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { - await gate.promise - watcher.onSubmitted() - return { - getTransactionResponse: { status: "SUCCESS" }, - result: undefined, - } - }, - ), - }) - const { result } = renderUnpauseToken() - - expect(result.current.submit()).toBe(true) - await waitFor(() => - expect(result.current.status).toEqual({ status: "awaiting-signature" }), - ) - - gate.resolve() - await waitFor(() => - expect(result.current.status).toEqual({ status: "confirmed" }), - ) - expect(clientMock.unpause).toHaveBeenCalledWith({ caller: investorAddress }) - }) - - it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { - const signAndSend = vi.fn() - clientMock.unpause.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #1001)" }, - signAndSend, - }) - const { result, queryClient } = renderUnpauseToken() - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - - await act(() => result.current.submit()) - - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "contract-error", code: 1001 }, - }) - expect(signAndSend).not.toHaveBeenCalled() - expect(invalidateQueries).not.toHaveBeenCalled() - }) - - it("tells a declined signature apart from a failure", async () => { - clientMock.unpause.mockResolvedValue({ - simulation: undefined, - signAndSend: vi - .fn() - .mockRejectedValue( - new AssembledTransaction.Errors.UserRejected("User declined access"), - ), - }) - const { result } = renderUnpauseToken() - - await act(() => result.current.submit()) - - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "declined" }, - }) - }) - - it("refreshes the state it changed once confirmed", async () => { - clientMock.unpause.mockResolvedValue({ - simulation: undefined, - signAndSend: vi.fn().mockResolvedValue({ - getTransactionResponse: { status: "SUCCESS" }, - result: undefined, - }), - }) - const { result, queryClient } = renderUnpauseToken() - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - - await act(() => result.current.submit()) - - for (const queryKey of [tokenPausedKey]) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) - }) -}) diff --git a/app/src/hooks/useUnpauseToken.ts b/app/src/hooks/useUnpauseToken.ts deleted file mode 100644 index 4a56443f..00000000 --- a/app/src/hooks/useUnpauseToken.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { type Signer } from "@stellar-scaffold/app-lib" -import { shareTokenWriter } from "../config/clients" -import { - useContractTransaction, - type UseContractTransaction, -} from "./useContractTransaction" -import { tokenPausedKey } from "./useTokenPaused" - -const WRITE_KEYS = [tokenPausedKey] - -const call = (signer: Signer) => - shareTokenWriter(signer).then((token) => - token.unpause({ caller: signer.publicKey }), - ) - -const toConfirmed = () => ({}) - -export function useUnpauseToken(): UseContractTransaction< - void, - Record -> { - return useContractTransaction>( - call, - toConfirmed, - WRITE_KEYS, - ) -} diff --git a/app/src/hooks/useUnpauseVault.test.ts b/app/src/hooks/useUnpauseVault.test.ts deleted file mode 100644 index dbb84e5f..00000000 --- a/app/src/hooks/useUnpauseVault.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 { useUnpauseVault } from "./useUnpauseVault" -import { vaultPausedKey } from "./useVaultPaused" - -const { clientMock, writerMock } = vi.hoisted(() => ({ - clientMock: { unpause: vi.fn() }, - writerMock: vi.fn(), -})) - -vi.mock("../config/clients", () => ({ - asyncVaultWriter: writerMock, -})) - -const renderUnpauseVault = () => renderWithWallet(useUnpauseVault) - -describe("useUnpauseVault", () => { - beforeEach(() => { - vi.clearAllMocks() - writerMock.mockResolvedValue(clientMock) - }) - - it("signs unpause as the connected wallet and confirms", async () => { - const gate = deferred() - clientMock.unpause.mockResolvedValue({ - simulation: undefined, - signAndSend: vi.fn( - async ({ watcher }: { watcher: { onSubmitted: () => void } }) => { - await gate.promise - watcher.onSubmitted() - return { - getTransactionResponse: { status: "SUCCESS" }, - result: undefined, - } - }, - ), - }) - const { result } = renderUnpauseVault() - - expect(result.current.submit()).toBe(true) - await waitFor(() => - expect(result.current.status).toEqual({ status: "awaiting-signature" }), - ) - - gate.resolve() - await waitFor(() => - expect(result.current.status).toEqual({ status: "confirmed" }), - ) - expect(clientMock.unpause).toHaveBeenCalledWith({ caller: investorAddress }) - }) - - it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { - const signAndSend = vi.fn() - clientMock.unpause.mockResolvedValue({ - simulation: { error: "HostError: Error(Contract, #1001)" }, - signAndSend, - }) - const { result, queryClient } = renderUnpauseVault() - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - - await act(() => result.current.submit()) - - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "contract-error", code: 1001 }, - }) - expect(signAndSend).not.toHaveBeenCalled() - expect(invalidateQueries).not.toHaveBeenCalled() - }) - - it("tells a declined signature apart from a failure", async () => { - clientMock.unpause.mockResolvedValue({ - simulation: undefined, - signAndSend: vi - .fn() - .mockRejectedValue( - new AssembledTransaction.Errors.UserRejected("User declined access"), - ), - }) - const { result } = renderUnpauseVault() - - await act(() => result.current.submit()) - - expect(result.current.status).toEqual({ - status: "failed", - failure: { kind: "declined" }, - }) - }) - - it("refreshes the state it changed once confirmed", async () => { - clientMock.unpause.mockResolvedValue({ - simulation: undefined, - signAndSend: vi.fn().mockResolvedValue({ - getTransactionResponse: { status: "SUCCESS" }, - result: undefined, - }), - }) - const { result, queryClient } = renderUnpauseVault() - const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") - - await act(() => result.current.submit()) - - for (const queryKey of [vaultPausedKey]) - expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) - }) -}) diff --git a/app/src/hooks/useUnpauseVault.ts b/app/src/hooks/useUnpauseVault.ts deleted file mode 100644 index b6f7cb68..00000000 --- a/app/src/hooks/useUnpauseVault.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { type Signer } from "@stellar-scaffold/app-lib" -import { asyncVaultWriter } from "../config/clients" -import { - useContractTransaction, - type UseContractTransaction, -} from "./useContractTransaction" -import { vaultPausedKey } from "./useVaultPaused" - -const WRITE_KEYS = [vaultPausedKey] - -const call = (signer: Signer) => - asyncVaultWriter(signer).then((vault) => - vault.unpause({ caller: signer.publicKey }), - ) - -const toConfirmed = () => ({}) - -export function useUnpauseVault(): UseContractTransaction< - void, - Record -> { - return useContractTransaction>( - call, - toConfirmed, - WRITE_KEYS, - ) -} From 8b8bebed0062a49c150410f1010847376b3391ed Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 16:38:00 -0300 Subject: [PATCH 05/12] fix(app): state what the token pause enforces --- .../vault/TransactionModal.test.tsx | 31 +++++++++++++++++++ app/src/components/vault/TransactionModal.tsx | 8 ++--- app/src/pages/emergency.test.ts | 5 ++- app/src/pages/emergency.ts | 4 +-- 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 5f8da528..2a9d9bd4 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -2002,6 +2002,37 @@ describe("TransactionModal, emergency actions", () => { ).toBeTruthy() }) + it.each<[EmergencyModalAction, string, string]>([ + [ + "pause-token", + "Signing stops holder transfers and redeem cancellations.", + "Holder transfers and redeem cancellations are stopped. Minting, burning and forced transfers stay open.", + ], + [ + "unpause-token", + "Signing resumes holder transfers and redeem cancellations.", + "Holder transfers and redeem cancellations are open again.", + ], + ])( + "words %s after what the share token enforces", + (action, signing, done) => { + const { rerender } = renderEmergencyModal(action, { + status: "awaiting-signature", + }) + expect(screen.getByText(signing)).toBeTruthy() + + rerender( + , + ) + expect(screen.getByText(done)).toBeTruthy() + }, + ) + it.each<[EmergencyModalAction, number, string, RegExp]>([ ["pause-vault", 1000, "vault", /The vault is already paused/], ["unpause-vault", 1001, "vault", /The vault is not paused/], diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index d1fb0a89..c1c94ff0 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -984,17 +984,17 @@ const EMERGENCY_COPY: Record = { "pause-token": { refuser: "share token", verb: "pause the share token", - signing: "Signing stops share transfers between holders.", + signing: "Signing stops holder transfers and redeem cancellations.", done: "Share token paused", doneBody: - "Transfers between holders are stopped. Minting, burning, claims and forced transfers stay open.", + "Holder transfers and redeem cancellations are stopped. Minting, burning and forced transfers stay open.", }, "unpause-token": { refuser: "share token", verb: "unpause the share token", - signing: "Signing resumes share transfers between holders.", + signing: "Signing resumes holder transfers and redeem cancellations.", done: "Share token unpaused", - doneBody: "Share transfers between holders are open again.", + doneBody: "Holder transfers and redeem cancellations are open again.", }, } diff --git a/app/src/pages/emergency.test.ts b/app/src/pages/emergency.test.ts index 70c8611d..60dba16f 100644 --- a/app/src/pages/emergency.test.ts +++ b/app/src/pages/emergency.test.ts @@ -172,7 +172,10 @@ describe("toEmergencyActions", () => { "Stops new deposits, pricing and upgrades. Redemptions, claims, cancels and closing epochs stay open.", ) expect(actionOf(calm, everyRole, "pause-token").outcome).toBe( - "Stops share transfers between holders. Minting, burning, claims and forced transfers stay open.", + "Stops holder transfers and redeem cancellations. Minting, burning and forced transfers stay open.", + ) + expect(actionOf(alarmed, everyRole, "unpause-token").outcome).toBe( + "Resumes holder transfers and redeem cancellations.", ) expect(actionOf(calm, everyRole, "raise-ripcord").outcome).toBe( "Stops pricing: no epoch can be fulfilled until it is lowered. Attesting stays open.", diff --git a/app/src/pages/emergency.ts b/app/src/pages/emergency.ts index a8e88828..6693b0c4 100644 --- a/app/src/pages/emergency.ts +++ b/app/src/pages/emergency.ts @@ -130,14 +130,14 @@ const tokenSpecs = (state: SettledState): [Spec, Spec] => [ label: "Pause the share token", conditions: [pauseIs("Share token is open", state.token, "open")], outcome: - "Stops share transfers between holders. Minting, burning, claims and forced transfers stay open.", + "Stops holder transfers and redeem cancellations. Minting, burning and forced transfers stay open.", danger: true, }, { id: "unpause-token", label: "Unpause the share token", conditions: [pauseIs("Share token is paused", state.token, "paused")], - outcome: "Resumes share transfers between holders.", + outcome: "Resumes holder transfers and redeem cancellations.", }, ] From 4c18ccc933193bcfb4f9cff2215d5a8e9ee96404 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 16:39:03 -0300 Subject: [PATCH 06/12] fix(app): do not gate actions on unknown state --- .../admin/EmergencySection.test.tsx | 10 ++-- app/src/components/admin/EmergencySection.tsx | 2 +- app/src/pages/emergency.test.ts | 41 ++++++++++--- app/src/pages/emergency.ts | 60 +++++++++---------- 4 files changed, 66 insertions(+), 47 deletions(-) diff --git a/app/src/components/admin/EmergencySection.test.tsx b/app/src/components/admin/EmergencySection.test.tsx index 46c2d2a5..6c3d13e8 100644 --- a/app/src/components/admin/EmergencySection.test.tsx +++ b/app/src/components/admin/EmergencySection.test.tsx @@ -172,7 +172,7 @@ describe("EmergencySection", () => { ).toBeTruthy() }) - it("lowers the ripcord and clears the latest price once raised", () => { + it("lowers the ripcord, and clears the latest price once raised", () => { setState("open", "open", "paused") const { rerender } = render( , @@ -200,13 +200,11 @@ describe("EmergencySection", () => { expect(pauseTokenMock.submit).toHaveBeenCalled() }) - it("shows no actions while the state is still being read", () => { - setState("checking", "open", "valid") + it("keeps the actions available while the state is still being read", () => { + setState("checking", "checking", "checking") render() - expect( - screen.queryAllByRole("button", { name: "Pause the vault" }), - ).toHaveLength(0) + expect(button("Pause the vault").disabled).toBe(false) }) it("shows the flow modal only while the surface is active", () => { diff --git a/app/src/components/admin/EmergencySection.tsx b/app/src/components/admin/EmergencySection.tsx index f05de771..9b6895df 100644 --- a/app/src/components/admin/EmergencySection.tsx +++ b/app/src/components/admin/EmergencySection.tsx @@ -113,7 +113,7 @@ const EmergencySection: React.FC<{ 0} + pending={false} unreadable={false} onRun={run} /> diff --git a/app/src/pages/emergency.test.ts b/app/src/pages/emergency.test.ts index 60dba16f..b8aa97cc 100644 --- a/app/src/pages/emergency.test.ts +++ b/app/src/pages/emergency.test.ts @@ -47,10 +47,33 @@ const actionOf = (state: EmergencyState, grants: Grant[], id: string) => { } describe("toEmergencyActions", () => { - it("offers nothing while any state is still being read", () => { + it("holds no action back on a state still being read, showing the condition as unknown", () => { + const reading: EmergencyState = { + vault: "checking", + token: "checking", + oracle: "checking", + } + expect( - toEmergencyActions({ ...calm, token: "checking" }, everyRole, wallet), - ).toEqual([]) + toEmergencyActions(reading, everyRole, wallet).every( + (action) => action.enabled, + ), + ).toBe(true) + expect(actionOf(reading, everyRole, "pause-vault").conditions).toEqual([ + { label: "Vault is open", met: null }, + ]) + }) + + it("decides each action from its own state only", () => { + const state: EmergencyState = { + vault: "checking", + token: "paused", + oracle: "checking", + } + + expect(actionOf(state, everyRole, "pause-vault").enabled).toBe(true) + expect(actionOf(state, everyRole, "pause-token").enabled).toBe(false) + expect(actionOf(state, everyRole, "unpause-token").enabled).toBe(true) }) it("offers nothing to a wallet with no emergency grant", () => { @@ -109,7 +132,12 @@ describe("toEmergencyActions", () => { .filter((action) => action.enabled) .map((action) => action.id) - expect(enabled).toEqual(["pause-vault", "raise-ripcord", "pause-token"]) + expect(enabled).toEqual([ + "pause-vault", + "raise-ripcord", + "lower-ripcord", + "pause-token", + ]) }) it("enables unpausing, lowering and clearing once everything is paused", () => { @@ -137,6 +165,7 @@ describe("toEmergencyActions", () => { expect(actionOf(calm, everyRole, "clear-latest").conditions).toEqual([ { label: "Ripcord raised", met: false }, ]) + expect(actionOf(calm, everyRole, "lower-ripcord").conditions).toEqual([]) expect(actionOf(alarmed, everyRole, "unpause-token").conditions).toEqual([ { label: "Share token is paused", met: true }, ]) @@ -153,9 +182,7 @@ describe("toEmergencyActions", () => { expect(actionOf(state, everyRole, "pause-vault").conditions).toEqual([ { label: "Vault is open", met: null }, ]) - expect( - actions.filter((action) => action.enabled).map((action) => action.id), - ).toEqual(["raise-ripcord"]) + expect(actions.every((action) => action.enabled)).toBe(true) }) it("keeps raising the ripcord available once raised, noting it already is", () => { diff --git a/app/src/pages/emergency.ts b/app/src/pages/emergency.ts index 6693b0c4..2d502cd8 100644 --- a/app/src/pages/emergency.ts +++ b/app/src/pages/emergency.ts @@ -3,7 +3,6 @@ import { type Grant } from "../hooks/useAdminAuthority" import { type OracleStatus } from "../hooks/useOracleState" import { type PauseState } from "../hooks/useVaultPaused" import { - allMet, type Condition, grantFor, type RailAction, @@ -16,12 +15,6 @@ export type EmergencyState = { oracle: OracleStatus } -type SettledState = { - vault: Exclude - token: Exclude - oracle: Exclude -} - type PlainId = | "pause-vault" | "unpause-vault" @@ -49,18 +42,21 @@ const ORACLE_LABELS = { paused: "Paused", } as const +const isKnown = (state: PauseState | OracleStatus): boolean => + state !== "checking" && state !== "unreadable" + const pauseIs = ( label: string, - state: SettledState["vault"], + state: PauseState, wanted: "open" | "paused", ): Condition => ({ label, - met: state === "unreadable" ? null : state === wanted, + met: isKnown(state) ? state === wanted : null, }) -const ripcordRaised = (oracle: SettledState["oracle"]): Condition => ({ +const ripcordRaised = (oracle: OracleStatus): Condition => ({ label: "Ripcord raised", - met: oracle === "unreadable" ? null : oracle === "paused", + met: isKnown(oracle) ? oracle === "paused" : null, }) type Spec = { @@ -82,17 +78,19 @@ const toAction = ( label: spec.label, conditions: spec.conditions, outcome: spec.outcome, - enabled: unavailable === undefined && allMet(spec.conditions), + enabled: + unavailable === undefined && + spec.conditions.every((condition) => condition.met !== false), ...(spec.danger === true ? { tone: "danger" as const } : {}), ...(unavailable === undefined ? {} : { unavailable }), } } -const vaultSpecs = (state: SettledState): [Spec, Spec] => [ +const vaultSpecs = (vault: PauseState): [Spec, Spec] => [ { id: "pause-vault", label: "Pause the vault", - conditions: [pauseIs("Vault is open", state.vault, "open")], + conditions: [pauseIs("Vault is open", vault, "open")], outcome: "Stops new deposits, pricing and upgrades. Redemptions, claims, cancels and closing epochs stay open.", danger: true, @@ -100,35 +98,35 @@ const vaultSpecs = (state: SettledState): [Spec, Spec] => [ { id: "unpause-vault", label: "Unpause the vault", - conditions: [pauseIs("Vault is paused", state.vault, "paused")], + conditions: [pauseIs("Vault is paused", vault, "paused")], outcome: "Resumes deposits, pricing and upgrades. A pending upgrade waits longer by the time the vault spent paused.", }, ] -const oracleAdminSpecs = (state: SettledState): [Spec, Spec] => [ +const oracleAdminSpecs = (oracle: OracleStatus): [Spec, Spec] => [ { id: "lower-ripcord", label: "Lower the ripcord", - conditions: [ripcordRaised(state.oracle)], + conditions: [], outcome: "Resumes pricing: epochs can be fulfilled again while the price is valid.", }, { id: "clear-latest", label: "Clear the latest price", - conditions: [ripcordRaised(state.oracle)], + conditions: [ripcordRaised(oracle)], outcome: "Removes the recorded price, so the next attestation is not limited by its distance from the last. Pricing stays stopped until the ripcord is lowered.", danger: true, }, ] -const tokenSpecs = (state: SettledState): [Spec, Spec] => [ +const tokenSpecs = (token: PauseState): [Spec, Spec] => [ { id: "pause-token", label: "Pause the share token", - conditions: [pauseIs("Share token is open", state.token, "open")], + conditions: [pauseIs("Share token is open", token, "open")], outcome: "Stops holder transfers and redeem cancellations. Minting, burning and forced transfers stay open.", danger: true, @@ -136,7 +134,7 @@ const tokenSpecs = (state: SettledState): [Spec, Spec] => [ { id: "unpause-token", label: "Unpause the share token", - conditions: [pauseIs("Share token is paused", state.token, "paused")], + conditions: [pauseIs("Share token is paused", token, "paused")], outcome: "Resumes holder transfers and redeem cancellations.", }, ] @@ -154,7 +152,7 @@ const raiseGrant = ( } function toRaiseAction( - state: SettledState, + oracle: OracleStatus, grant: Grant, wallet: string, ): EmergencyAction { @@ -166,28 +164,23 @@ function toRaiseAction( conditions: [], outcome: "Stops pricing: no epoch can be fulfilled until it is lowered. Attesting stays open.", - ...(state.oracle === "paused" ? { note: "Ripcord already raised." } : {}), + ...(oracle === "paused" ? { note: "Ripcord already raised." } : {}), enabled: unavailable === undefined, tone: "danger", ...(unavailable === undefined ? {} : { unavailable }), } } -const isSettled = (state: EmergencyState): state is SettledState => - state.vault !== "checking" && - state.token !== "checking" && - state.oracle !== "checking" - export function toEmergencyActions( state: EmergencyState, grants: Grant[], wallet: string, ): EmergencyAction[] { - if (!isSettled(state) || grants.length === 0) return [] + if (grants.length === 0) return [] const held = (role: string) => grantFor(grants, role, wallet) - const [pauseVault, unpauseVault] = vaultSpecs(state) - const [lower, clear] = oracleAdminSpecs(state) - const [pauseToken, unpauseToken] = tokenSpecs(state) + const [pauseVault, unpauseVault] = vaultSpecs(state.vault) + const [lower, clear] = oracleAdminSpecs(state.oracle) + const [pauseToken, unpauseToken] = tokenSpecs(state.token) const oracleAdmin = held(ROLES.oracleAdmin) const tokenAdmin = held(ROLES.tokenAdmin) const raiser = raiseGrant(held(ROLES.oracleGuardian), oracleAdmin, wallet) @@ -199,7 +192,8 @@ export function toEmergencyActions( const governance = held(ROLES.governance) if (governance !== undefined) actions.push(toAction(unpauseVault, governance, wallet)) - if (raiser !== undefined) actions.push(toRaiseAction(state, raiser, wallet)) + if (raiser !== undefined) + actions.push(toRaiseAction(state.oracle, raiser, wallet)) if (oracleAdmin !== undefined) actions.push( toAction(lower, oracleAdmin, wallet), From 4da1a42d0cea95df973928b09c97f9ba481b2b5b Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 16:40:15 -0300 Subject: [PATCH 07/12] fix(app): refresh state after a contract refusal --- app/src/hooks/useActivateWindDown.test.ts | 6 ++++-- app/src/hooks/useAttest.test.ts | 6 ++++-- app/src/hooks/useCloseEpoch.test.ts | 6 ++++-- app/src/hooks/useContractTransaction.test.ts | 7 +++++-- app/src/hooks/useContractTransaction.ts | 10 +++++++--- app/src/hooks/useDeployToCustodian.test.ts | 6 ++++-- app/src/hooks/useEmergencyTransactions.test.ts | 7 +++++-- app/src/hooks/useFinalizeWindDownRound.test.ts | 6 ++++-- app/src/hooks/useFulfillEpoch.test.ts | 6 ++++-- app/src/hooks/useFund.test.ts | 6 ++++-- 10 files changed, 45 insertions(+), 21 deletions(-) diff --git a/app/src/hooks/useActivateWindDown.test.ts b/app/src/hooks/useActivateWindDown.test.ts index 66a68e19..06ab2ecc 100644 --- a/app/src/hooks/useActivateWindDown.test.ts +++ b/app/src/hooks/useActivateWindDown.test.ts @@ -145,7 +145,7 @@ describe("useActivateWindDown", () => { } }) - it("does not touch the cached cycle when the vault refuses at simulation", async () => { + it("refreshes the cached cycle when the vault refuses at simulation", async () => { vaultMock.activate_wind_down.mockResolvedValue({ simulation: { error: "HostError: Error(Contract, #6047)" }, signAndSend: vi.fn(), @@ -155,6 +155,8 @@ describe("useActivateWindDown", () => { await act(() => result.current.submit()) - expect(invalidateQueries).not.toHaveBeenCalled() + for (const queryKey of cycleWriteKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } }) }) diff --git a/app/src/hooks/useAttest.test.ts b/app/src/hooks/useAttest.test.ts index 32794773..f85fb60e 100644 --- a/app/src/hooks/useAttest.test.ts +++ b/app/src/hooks/useAttest.test.ts @@ -160,7 +160,7 @@ describe("useAttest", () => { }) }) - it("does not touch the cached cycle when the oracle refuses at simulation", async () => { + it("refreshes the cached cycle when the oracle refuses at simulation", async () => { oracleMock.attest.mockResolvedValue({ simulation: { error: "HostError: Error(Contract, #3002)" }, signAndSend: vi.fn(), @@ -170,6 +170,8 @@ describe("useAttest", () => { await act(() => result.current.submit(attestation)) - expect(invalidateQueries).not.toHaveBeenCalled() + for (const queryKey of attestWriteKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } }) }) diff --git a/app/src/hooks/useCloseEpoch.test.ts b/app/src/hooks/useCloseEpoch.test.ts index 19591c2e..4691e904 100644 --- a/app/src/hooks/useCloseEpoch.test.ts +++ b/app/src/hooks/useCloseEpoch.test.ts @@ -148,7 +148,7 @@ describe("useCloseEpoch", () => { }) }) - it("does not touch the cached cycle when the vault refuses at simulation", async () => { + it("refreshes the cached cycle when the vault refuses at simulation", async () => { vaultMock.close_epoch.mockResolvedValue({ simulation: { error: "HostError: Error(Contract, #6046)" }, signAndSend: vi.fn(), @@ -158,6 +158,8 @@ describe("useCloseEpoch", () => { await act(() => result.current.submit()) - expect(invalidateQueries).not.toHaveBeenCalled() + for (const queryKey of cycleWriteKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } }) }) diff --git a/app/src/hooks/useContractTransaction.test.ts b/app/src/hooks/useContractTransaction.test.ts index 1f3a2162..c5ce300c 100644 --- a/app/src/hooks/useContractTransaction.test.ts +++ b/app/src/hooks/useContractTransaction.test.ts @@ -45,7 +45,7 @@ describe("useContractTransaction", () => { }) }) - it("leaves every cache alone when the vault refuses at simulation", async () => { + it("refreshes only the extra query keys when the contract refuses at simulation", async () => { const { result, queryClient } = renderTransaction( "HostError: Error(Contract, #6046)", ) @@ -53,7 +53,10 @@ describe("useContractTransaction", () => { await act(() => result.current.submit(undefined)) - expect(invalidateQueries).not.toHaveBeenCalled() + expect(invalidateQueries).toHaveBeenCalledTimes(extraKeys.length) + for (const queryKey of extraKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } }) it("keeps the same submit across rerenders when no extra keys are given", () => { diff --git a/app/src/hooks/useContractTransaction.ts b/app/src/hooks/useContractTransaction.ts index 568eac90..4f1198d2 100644 --- a/app/src/hooks/useContractTransaction.ts +++ b/app/src/hooks/useContractTransaction.ts @@ -62,6 +62,11 @@ export function useContractTransaction< lastStatus.current = next if (!dismissed.current) setStatus(next) } + const invalidateExtraKeys = () => { + for (const queryKey of extraKeys) { + void queryClient.invalidateQueries({ queryKey }) + } + } const invalidateRequestData = () => { void queryClient.invalidateQueries({ queryKey: investorRequestsKey(owner), @@ -72,9 +77,7 @@ export function useContractTransaction< void queryClient.invalidateQueries({ queryKey: sharePositionKey(owner), }) - for (const queryKey of extraKeys) { - void queryClient.invalidateQueries({ queryKey }) - } + invalidateExtraKeys() } applyStatus({ status: "preparing" }) try { @@ -85,6 +88,7 @@ export function useContractTransaction< status: "failed", failure: { kind: "contract-error", code }, }) + invalidateExtraKeys() return } signatureRequested = true diff --git a/app/src/hooks/useDeployToCustodian.test.ts b/app/src/hooks/useDeployToCustodian.test.ts index f247cff8..762b23ec 100644 --- a/app/src/hooks/useDeployToCustodian.test.ts +++ b/app/src/hooks/useDeployToCustodian.test.ts @@ -163,7 +163,7 @@ describe("useDeployToCustodian", () => { }) }) - it("does not touch the cached cycle when the vault refuses at simulation", async () => { + it("refreshes the cached cycle when the vault refuses at simulation", async () => { vaultMock.deploy_to_custodian.mockResolvedValue({ simulation: { error: "HostError: Error(Contract, #6012)" }, signAndSend: vi.fn(), @@ -173,6 +173,8 @@ describe("useDeployToCustodian", () => { await act(() => result.current.submit(assets)) - expect(invalidateQueries).not.toHaveBeenCalled() + for (const queryKey of cycleWriteKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } }) }) diff --git a/app/src/hooks/useEmergencyTransactions.test.ts b/app/src/hooks/useEmergencyTransactions.test.ts index 8a2d7120..decc684e 100644 --- a/app/src/hooks/useEmergencyTransactions.test.ts +++ b/app/src/hooks/useEmergencyTransactions.test.ts @@ -154,13 +154,14 @@ describe.each(cases)("$name", (testCase) => { expect(method).toHaveBeenCalledWith(args) }) - it("refuses before asking for a signature when the simulation carries the contract's reason", async () => { + it("refuses before asking for a signature and refreshes the state it read", async () => { const signAndSend = vi.fn() method.mockResolvedValue({ simulation: { error: `HostError: Error(Contract, #${refusal})` }, signAndSend, }) - const { result } = renderWithWallet(useHook) + const { result, queryClient } = renderWithWallet(useHook) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") await act(() => result.current.submit(arg as never)) @@ -169,6 +170,8 @@ describe.each(cases)("$name", (testCase) => { failure: { kind: "contract-error", code: refusal }, }) expect(signAndSend).not.toHaveBeenCalled() + for (const queryKey of keys) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) }) it("tells a declined signature apart from a failure", async () => { diff --git a/app/src/hooks/useFinalizeWindDownRound.test.ts b/app/src/hooks/useFinalizeWindDownRound.test.ts index c03052fc..90e647fd 100644 --- a/app/src/hooks/useFinalizeWindDownRound.test.ts +++ b/app/src/hooks/useFinalizeWindDownRound.test.ts @@ -151,7 +151,7 @@ describe("useFinalizeWindDownRound", () => { } }) - it("does not touch the cached cycle when the vault refuses at simulation", async () => { + it("refreshes 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(), @@ -161,6 +161,8 @@ describe("useFinalizeWindDownRound", () => { await act(() => result.current.submit()) - expect(invalidateQueries).not.toHaveBeenCalled() + for (const queryKey of cycleWriteKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } }) }) diff --git a/app/src/hooks/useFulfillEpoch.test.ts b/app/src/hooks/useFulfillEpoch.test.ts index ed404087..c1cb42ce 100644 --- a/app/src/hooks/useFulfillEpoch.test.ts +++ b/app/src/hooks/useFulfillEpoch.test.ts @@ -153,7 +153,7 @@ describe("useFulfillEpoch", () => { } }) - it("does not touch the cached cycle when the vault refuses at simulation", async () => { + it("refreshes the cached cycle when the vault refuses at simulation", async () => { vaultMock.fulfill_epoch.mockResolvedValue({ simulation: { error: "HostError: Error(Contract, #6044)" }, signAndSend: vi.fn(), @@ -163,6 +163,8 @@ describe("useFulfillEpoch", () => { await act(() => result.current.submit(epochId)) - expect(invalidateQueries).not.toHaveBeenCalled() + for (const queryKey of cycleWriteKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } }) }) diff --git a/app/src/hooks/useFund.test.ts b/app/src/hooks/useFund.test.ts index 0e147e33..7803d49d 100644 --- a/app/src/hooks/useFund.test.ts +++ b/app/src/hooks/useFund.test.ts @@ -182,7 +182,7 @@ describe("useFund", () => { expect(result.current.submit).toBe(firstSubmit) }) - it("does not touch the cached cycle when the vault refuses at simulation", async () => { + it("refreshes the cached cycle when the vault refuses at simulation", async () => { vaultMock.fund.mockResolvedValue({ simulation: { error: "HostError: Error(Contract, #6014)" }, signAndSend: vi.fn(), @@ -192,6 +192,8 @@ describe("useFund", () => { await act(() => result.current.submit(assets)) - expect(invalidateQueries).not.toHaveBeenCalled() + for (const queryKey of cycleWriteKeys) { + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + } }) }) From 108d6edd4afb1a7f1814ff780097b6d708a88ddf Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 16:43:30 -0300 Subject: [PATCH 08/12] fix(app): log refusals without naming the vault --- app/src/components/vault/TransactionModal.test.tsx | 2 +- app/src/components/vault/TransactionModal.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 2a9d9bd4..889c92e2 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -569,7 +569,7 @@ describe("TransactionModal", () => { ) expect(consoleError).toHaveBeenCalledTimes(1) - expect(consoleError).toHaveBeenCalledWith("Vault contract error 9999") + expect(consoleError).toHaveBeenCalledWith("Contract error 9999") consoleError.mockRestore() }) diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index c1c94ff0..72599019 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -1191,7 +1191,7 @@ const TransactionModal: React.FC = (props) => { React.useEffect(() => { if (contractErrorCode !== undefined) { - console.error(`Vault contract error ${contractErrorCode}`) + console.error(`Contract error ${contractErrorCode}`) } }, [contractErrorCode]) From e80348729a2fbda869d3c38e5615d257a3960cfd Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 16:45:42 -0300 Subject: [PATCH 09/12] refactor(app): name the nav price key once --- app/src/hooks/useNavPrice.ts | 4 +++- app/src/hooks/useOracleState.ts | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/hooks/useNavPrice.ts b/app/src/hooks/useNavPrice.ts index 29161403..5c9a4803 100644 --- a/app/src/hooks/useNavPrice.ts +++ b/app/src/hooks/useNavPrice.ts @@ -39,6 +39,8 @@ export function classifyNav( : { status: "unreadable" } } +export const navLatestKey = ["nav", "latest"] as const + async function fetchNav(): Promise { const [state, latest] = await Promise.all([ readContract(async () => (await navOracle()).state()), @@ -54,7 +56,7 @@ export function useNavPrice(): { canEnter: boolean } { const { data, isPending } = useQuery({ - queryKey: ["nav", "latest"], + queryKey: navLatestKey, queryFn: fetchNav, staleTime: 30_000, }) diff --git a/app/src/hooks/useOracleState.ts b/app/src/hooks/useOracleState.ts index fa539c7d..df046442 100644 --- a/app/src/hooks/useOracleState.ts +++ b/app/src/hooks/useOracleState.ts @@ -6,16 +6,17 @@ import { import { useQuery } from "@tanstack/react-query" import { navOracle } from "../config/clients" import { cycleReadsKey } from "./useCycleState" +import { navLatestKey } from "./useNavPrice" import { PAUSE_REFRESH_MS } from "./useVaultPaused" export type OracleStatus = "checking" | "valid" | "stale" | "paused" | "unreadable" -export const oracleStateKey = ["oracle", "state"] as const +const oracleStateKey = ["oracle", "state"] as const export const oracleWriteKeys = [ oracleStateKey, cycleReadsKey, - ["nav", "latest"], + navLatestKey, ] as const const STATUS_BY_TAG = { From 494b4a4ed998a007c419dd77782f1cd6fa799a8a Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 16:47:30 -0300 Subject: [PATCH 10/12] refactor(app): build the raise action from a spec --- app/src/pages/emergency.ts | 45 +++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/app/src/pages/emergency.ts b/app/src/pages/emergency.ts index 2d502cd8..4f6a9dd7 100644 --- a/app/src/pages/emergency.ts +++ b/app/src/pages/emergency.ts @@ -59,25 +59,27 @@ const ripcordRaised = (oracle: OracleStatus): Condition => ({ met: isKnown(oracle) ? oracle === "paused" : null, }) -type Spec = { - id: PlainId +type Spec = { + id: Id label: string conditions: Condition[] outcome: string + note?: string danger?: boolean } -const toAction = ( - spec: Spec, +const toAction = ( + spec: Spec, grant: Grant, wallet: string, -): EmergencyAction => { +): RailAction & { id: Id } => { const unavailable = unavailableFor(grant, wallet) return { id: spec.id, label: spec.label, conditions: spec.conditions, outcome: spec.outcome, + ...(spec.note === undefined ? {} : { note: spec.note }), enabled: unavailable === undefined && spec.conditions.every((condition) => condition.met !== false), @@ -151,25 +153,15 @@ const raiseGrant = ( return guardian ?? admin } -function toRaiseAction( - oracle: OracleStatus, - grant: Grant, - wallet: string, -): EmergencyAction { - const unavailable = unavailableFor(grant, wallet) - return { - id: "raise-ripcord", - viaAdmin: grant.role === ROLES.oracleAdmin, - label: "Raise the ripcord", - conditions: [], - outcome: - "Stops pricing: no epoch can be fulfilled until it is lowered. Attesting stays open.", - ...(oracle === "paused" ? { note: "Ripcord already raised." } : {}), - enabled: unavailable === undefined, - tone: "danger", - ...(unavailable === undefined ? {} : { unavailable }), - } -} +const raiseSpec = (oracle: OracleStatus): Spec<"raise-ripcord"> => ({ + id: "raise-ripcord", + label: "Raise the ripcord", + conditions: [], + outcome: + "Stops pricing: no epoch can be fulfilled until it is lowered. Attesting stays open.", + ...(oracle === "paused" ? { note: "Ripcord already raised." } : {}), + danger: true, +}) export function toEmergencyActions( state: EmergencyState, @@ -193,7 +185,10 @@ export function toEmergencyActions( if (governance !== undefined) actions.push(toAction(unpauseVault, governance, wallet)) if (raiser !== undefined) - actions.push(toRaiseAction(state.oracle, raiser, wallet)) + actions.push({ + ...toAction(raiseSpec(state.oracle), raiser, wallet), + viaAdmin: raiser.role === ROLES.oracleAdmin, + }) if (oracleAdmin !== undefined) actions.push( toAction(lower, oracleAdmin, wallet), From 124f4a16f6f3f925e0d295fbafd6dbbeefe57a6a Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 16:49:34 -0300 Subject: [PATCH 11/12] style(app): order the surface layout imports --- app/src/components/admin/CycleSurface.tsx | 2 +- app/src/components/admin/EmergencySection.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/components/admin/CycleSurface.tsx b/app/src/components/admin/CycleSurface.tsx index 405d63e5..43a73fdd 100644 --- a/app/src/components/admin/CycleSurface.tsx +++ b/app/src/components/admin/CycleSurface.tsx @@ -10,8 +10,8 @@ import AddressRows from "../vault/AddressRows" import MetricsStrip, { type Metric } from "../vault/MetricsStrip" import styles from "./CycleSurface.module.css" import Disclosure from "./Disclosure" -import layoutStyles from "./SurfaceLayout.module.css" import SurfaceLayout from "./SurfaceLayout" +import layoutStyles from "./SurfaceLayout.module.css" export type CycleGroup = FigureGroup & { tiles: Metric[] diff --git a/app/src/components/admin/EmergencySection.tsx b/app/src/components/admin/EmergencySection.tsx index 9b6895df..2f020388 100644 --- a/app/src/components/admin/EmergencySection.tsx +++ b/app/src/components/admin/EmergencySection.tsx @@ -26,8 +26,8 @@ import TransactionModal, { type TransactionModalProps, } from "../vault/TransactionModal" import CycleActionRail from "./CycleActionRail" -import layoutStyles from "./SurfaceLayout.module.css" import SurfaceLayout from "./SurfaceLayout" +import layoutStyles from "./SurfaceLayout.module.css" const RAISE = "raise" const LOWER = "lower" From d5f8facfd15689a50301b096fbe50c24d050ae65 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 16:52:21 -0300 Subject: [PATCH 12/12] fix(app): cover redeem claims in token pause copy --- app/src/components/vault/TransactionModal.test.tsx | 8 ++++---- app/src/components/vault/TransactionModal.tsx | 8 ++++---- app/src/hooks/useAttest.test.ts | 3 ++- app/src/hooks/useAttest.ts | 3 ++- app/src/pages/emergency.test.ts | 4 ++-- app/src/pages/emergency.ts | 4 ++-- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 889c92e2..14d8b9e1 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -2005,13 +2005,13 @@ describe("TransactionModal, emergency actions", () => { it.each<[EmergencyModalAction, string, string]>([ [ "pause-token", - "Signing stops holder transfers and redeem cancellations.", - "Holder transfers and redeem cancellations are stopped. Minting, burning and forced transfers stay open.", + "Signing stops holder transfers and returns of escrowed shares.", + "Holder transfers and returns of escrowed shares are stopped. Minting, burning and forced transfers stay open.", ], [ "unpause-token", - "Signing resumes holder transfers and redeem cancellations.", - "Holder transfers and redeem cancellations are open again.", + "Signing resumes holder transfers and returns of escrowed shares.", + "Holder transfers and returns of escrowed shares are open again.", ], ])( "words %s after what the share token enforces", diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index 72599019..08fad38c 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -984,17 +984,17 @@ const EMERGENCY_COPY: Record = { "pause-token": { refuser: "share token", verb: "pause the share token", - signing: "Signing stops holder transfers and redeem cancellations.", + signing: "Signing stops holder transfers and returns of escrowed shares.", done: "Share token paused", doneBody: - "Holder transfers and redeem cancellations are stopped. Minting, burning and forced transfers stay open.", + "Holder transfers and returns of escrowed shares are stopped. Minting, burning and forced transfers stay open.", }, "unpause-token": { refuser: "share token", verb: "unpause the share token", - signing: "Signing resumes holder transfers and redeem cancellations.", + signing: "Signing resumes holder transfers and returns of escrowed shares.", done: "Share token unpaused", - doneBody: "Holder transfers and redeem cancellations are open again.", + doneBody: "Holder transfers and returns of escrowed shares are open again.", }, } diff --git a/app/src/hooks/useAttest.test.ts b/app/src/hooks/useAttest.test.ts index f85fb60e..8db22ae6 100644 --- a/app/src/hooks/useAttest.test.ts +++ b/app/src/hooks/useAttest.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { attestWriteKeys, useAttest } from "./useAttest" import { investorRequestsKey } from "./useInvestorRequests" +import { navLatestKey } from "./useNavPrice" const { oracleMock, navOracleWriterMock } = vi.hoisted(() => ({ oracleMock: { attest: vi.fn() }, @@ -153,7 +154,7 @@ describe("useAttest", () => { expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) } expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ["nav", "latest"], + queryKey: navLatestKey, }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: investorRequestsKey(investorAddress), diff --git a/app/src/hooks/useAttest.ts b/app/src/hooks/useAttest.ts index a6916997..ff94526d 100644 --- a/app/src/hooks/useAttest.ts +++ b/app/src/hooks/useAttest.ts @@ -6,12 +6,13 @@ import { type TransactionStatus, } from "./useContractTransaction" import { cycleWriteKeys } from "./useCycleState" +import { navLatestKey } from "./useNavPrice" export type AttestStatus = TransactionStatus export type Attestation = { price: Price; freshness: bigint } -export const attestWriteKeys = [...cycleWriteKeys, ["nav", "latest"]] as const +export const attestWriteKeys = [...cycleWriteKeys, navLatestKey] as const export interface UseAttest { status: AttestStatus diff --git a/app/src/pages/emergency.test.ts b/app/src/pages/emergency.test.ts index b8aa97cc..737c319a 100644 --- a/app/src/pages/emergency.test.ts +++ b/app/src/pages/emergency.test.ts @@ -199,10 +199,10 @@ describe("toEmergencyActions", () => { "Stops new deposits, pricing and upgrades. Redemptions, claims, cancels and closing epochs stay open.", ) expect(actionOf(calm, everyRole, "pause-token").outcome).toBe( - "Stops holder transfers and redeem cancellations. Minting, burning and forced transfers stay open.", + "Stops holder transfers and returns of escrowed shares. Minting, burning and forced transfers stay open.", ) expect(actionOf(alarmed, everyRole, "unpause-token").outcome).toBe( - "Resumes holder transfers and redeem cancellations.", + "Resumes holder transfers and returns of escrowed shares.", ) expect(actionOf(calm, everyRole, "raise-ripcord").outcome).toBe( "Stops pricing: no epoch can be fulfilled until it is lowered. Attesting stays open.", diff --git a/app/src/pages/emergency.ts b/app/src/pages/emergency.ts index 4f6a9dd7..626a815f 100644 --- a/app/src/pages/emergency.ts +++ b/app/src/pages/emergency.ts @@ -130,14 +130,14 @@ const tokenSpecs = (token: PauseState): [Spec, Spec] => [ label: "Pause the share token", conditions: [pauseIs("Share token is open", token, "open")], outcome: - "Stops holder transfers and redeem cancellations. Minting, burning and forced transfers stay open.", + "Stops holder transfers and returns of escrowed shares. Minting, burning and forced transfers stay open.", danger: true, }, { id: "unpause-token", label: "Unpause the share token", conditions: [pauseIs("Share token is paused", token, "paused")], - outcome: "Resumes holder transfers and redeem cancellations.", + outcome: "Resumes holder transfers and returns of escrowed shares.", }, ]