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={ = ({ 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.test.tsx b/app/src/components/admin/EmergencySection.test.tsx new file mode 100644 index 00000000..6c3d13e8 --- /dev/null +++ b/app/src/components/admin/EmergencySection.test.tsx @@ -0,0 +1,235 @@ +import { fireEvent, render, screen, within } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { type Grant } from "../../hooks/useAdminAuthority" +import { type TransactionStatus } from "../../hooks/useContractTransaction" +import EmergencySection from "./EmergencySection" + +const transaction = () => ({ + 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/useEmergencyTransactions", () => ({ + usePauseVault: () => pauseVaultMock, + useUnpauseVault: () => unpauseVaultMock, + useRaiseRipcord: () => raiseRipcordMock, + useSetRipcord: () => setRipcordMock, + useClearLatest: () => clearLatestMock, + usePauseToken: () => pauseTokenMock, + 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("keeps the actions available while the state is still being read", () => { + setState("checking", "checking", "checking") + render() + + expect(button("Pause the vault").disabled).toBe(false) + }) + + 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..2f020388 --- /dev/null +++ b/app/src/components/admin/EmergencySection.tsx @@ -0,0 +1,143 @@ +import React from "react" +import { type Grant } from "../../hooks/useAdminAuthority" +import { type TransactionStatus } from "../../hooks/useContractTransaction" +import { + useClearLatest, + usePauseToken, + usePauseVault, + useRaiseRipcord, + useSetRipcord, + useUnpauseToken, + useUnpauseVault, +} from "../../hooks/useEmergencyTransactions" +import { useOracleState } from "../../hooks/useOracleState" +import { usePendingTransaction } from "../../hooks/usePendingTransaction" +import { useTokenPaused } from "../../hooks/useTokenPaused" +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 SurfaceLayout from "./SurfaceLayout" +import layoutStyles from "./SurfaceLayout.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/admin/SurfaceLayout.module.css b/app/src/components/admin/SurfaceLayout.module.css new file mode 100644 index 00000000..3236301f --- /dev/null +++ b/app/src/components/admin/SurfaceLayout.module.css @@ -0,0 +1,61 @@ +.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; +} 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 diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 439d17a7..14d8b9e1 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), @@ -568,7 +569,7 @@ describe("TransactionModal", () => { ) expect(consoleError).toHaveBeenCalledTimes(1) - expect(consoleError).toHaveBeenCalledWith("Vault contract error 9999") + expect(consoleError).toHaveBeenCalledWith("Contract error 9999") consoleError.mockRestore() }) @@ -1947,3 +1948,135 @@ 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, string, string]>([ + [ + "pause-token", + "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 returns of escrowed shares.", + "Holder transfers and returns of escrowed shares 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/], + ["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..08fad38c 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 holder transfers and returns of escrowed shares.", + done: "Share token paused", + doneBody: + "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 returns of escrowed shares.", + done: "Share token unpaused", + doneBody: "Holder transfers and returns of escrowed shares 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": @@ -1045,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]) 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/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/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), } diff --git a/app/src/hooks/useAttest.test.ts b/app/src/hooks/useAttest.test.ts index 32794773..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,14 +154,14 @@ describe("useAttest", () => { expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) } expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ["nav", "latest"], + queryKey: navLatestKey, }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: investorRequestsKey(investorAddress), }) }) - 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 +171,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/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/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 new file mode 100644 index 00000000..decc684e --- /dev/null +++ b/app/src/hooks/useEmergencyTransactions.test.ts @@ -0,0 +1,206 @@ +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 and refreshes the state it read", async () => { + const signAndSend = vi.fn() + method.mockResolvedValue({ + simulation: { error: `HostError: Error(Contract, #${refusal})` }, + signAndSend, + }) + const { result, queryClient } = renderWithWallet(useHook) + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + + 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() + for (const queryKey of keys) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey }) + }) + + 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/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 }) + } }) }) 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.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..df046442 --- /dev/null +++ b/app/src/hooks/useOracleState.ts @@ -0,0 +1,48 @@ +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 { navLatestKey } from "./useNavPrice" +import { PAUSE_REFRESH_MS } from "./useVaultPaused" + +export type OracleStatus = + "checking" | "valid" | "stale" | "paused" | "unreadable" + +const oracleStateKey = ["oracle", "state"] as const +export const oracleWriteKeys = [ + oracleStateKey, + cycleReadsKey, + navLatestKey, +] 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/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/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..737c319a --- /dev/null +++ b/app/src/pages/emergency.test.ts @@ -0,0 +1,286 @@ +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("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(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", () => { + 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", + "lower-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(calm, everyRole, "lower-ripcord").conditions).toEqual([]) + 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.every((action) => action.enabled)).toBe(true) + }) + + 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 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 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.", + ) + }) + + 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..626a815f --- /dev/null +++ b/app/src/pages/emergency.ts @@ -0,0 +1,225 @@ +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 { + type Condition, + grantFor, + type RailAction, + unavailableFor, +} from "./actions" + +export type EmergencyState = { + vault: PauseState + token: PauseState + oracle: OracleStatus +} + +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 isKnown = (state: PauseState | OracleStatus): boolean => + state !== "checking" && state !== "unreadable" + +const pauseIs = ( + label: string, + state: PauseState, + wanted: "open" | "paused", +): Condition => ({ + label, + met: isKnown(state) ? state === wanted : null, +}) + +const ripcordRaised = (oracle: OracleStatus): Condition => ({ + label: "Ripcord raised", + met: isKnown(oracle) ? oracle === "paused" : null, +}) + +type Spec = { + id: Id + label: string + conditions: Condition[] + outcome: string + note?: string + danger?: boolean +} + +const toAction = ( + spec: Spec, + grant: Grant, + wallet: string, +): 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), + ...(spec.danger === true ? { tone: "danger" as const } : {}), + ...(unavailable === undefined ? {} : { unavailable }), + } +} + +const vaultSpecs = (vault: PauseState): [Spec, Spec] => [ + { + id: "pause-vault", + label: "Pause the vault", + conditions: [pauseIs("Vault is open", 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", vault, "paused")], + outcome: + "Resumes deposits, pricing and upgrades. A pending upgrade waits longer by the time the vault spent paused.", + }, +] + +const oracleAdminSpecs = (oracle: OracleStatus): [Spec, Spec] => [ + { + id: "lower-ripcord", + label: "Lower the ripcord", + conditions: [], + outcome: + "Resumes pricing: epochs can be fulfilled again while the price is valid.", + }, + { + id: "clear-latest", + label: "Clear the latest price", + 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 = (token: PauseState): [Spec, Spec] => [ + { + id: "pause-token", + label: "Pause the share token", + conditions: [pauseIs("Share token is open", token, "open")], + outcome: + "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 returns of escrowed shares.", + }, +] + +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 +} + +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, + grants: Grant[], + wallet: string, +): EmergencyAction[] { + if (grants.length === 0) return [] + const held = (role: string) => grantFor(grants, role, wallet) + 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) + + 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({ + ...toAction(raiseSpec(state.oracle), raiser, wallet), + viaAdmin: raiser.role === ROLES.oracleAdmin, + }) + 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), +]