From 3598fa3ddb6c66a1202098072e0350a3f27d89ab Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 18:08:36 -0300 Subject: [PATCH 01/10] feat(app): set vault, oracle and token parameters --- .../admin/ConfigurationSection.test.tsx | 214 +++++++ .../components/admin/ConfigurationSection.tsx | 168 ++++++ .../admin/CycleActionRail.module.css | 23 + .../components/admin/CycleActionRail.test.tsx | 84 +++ app/src/components/admin/CycleActionRail.tsx | 59 +- .../vault/TransactionModal.test.tsx | 143 ++++- app/src/components/vault/TransactionModal.tsx | 98 +++- app/src/hooks/useAdminAuthority.test.ts | 20 +- app/src/hooks/useAdminAuthority.ts | 8 +- ...ns.test.ts => useAdminTransaction.test.ts} | 103 +++- app/src/hooks/useAdminTransaction.ts | 19 + app/src/hooks/useConfigurationState.test.ts | 108 ++++ app/src/hooks/useConfigurationState.ts | 72 +++ app/src/hooks/useConfigurationTransactions.ts | 72 +++ app/src/hooks/useCycleState.ts | 19 +- app/src/hooks/useEmergencyTransactions.ts | 36 +- app/src/pages/AdminPanel.test.tsx | 47 ++ app/src/pages/AdminPanel.tsx | 11 +- app/src/pages/actions.test.ts | 75 +++ app/src/pages/actions.ts | 46 ++ app/src/pages/configuration.test.ts | 344 +++++++++++ app/src/pages/configuration.ts | 543 ++++++++++++++++++ app/src/pages/cycle.test.ts | 31 + app/src/pages/cycle.ts | 56 +- app/src/pages/emergency.ts | 48 +- 25 files changed, 2317 insertions(+), 130 deletions(-) create mode 100644 app/src/components/admin/ConfigurationSection.test.tsx create mode 100644 app/src/components/admin/ConfigurationSection.tsx rename app/src/hooks/{useEmergencyTransactions.test.ts => useAdminTransaction.test.ts} (67%) create mode 100644 app/src/hooks/useAdminTransaction.ts create mode 100644 app/src/hooks/useConfigurationState.test.ts create mode 100644 app/src/hooks/useConfigurationState.ts create mode 100644 app/src/hooks/useConfigurationTransactions.ts create mode 100644 app/src/pages/configuration.test.ts create mode 100644 app/src/pages/configuration.ts diff --git a/app/src/components/admin/ConfigurationSection.test.tsx b/app/src/components/admin/ConfigurationSection.test.tsx new file mode 100644 index 0000000..43a79e6 --- /dev/null +++ b/app/src/components/admin/ConfigurationSection.test.tsx @@ -0,0 +1,214 @@ +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 ConfigurationState } from "../../hooks/useConfigurationState" +import { type TransactionStatus } from "../../hooks/useContractTransaction" +import ConfigurationSection from "./ConfigurationSection" + +const { + useConfigurationStateMock, + depositCapMock, + custodianMock, + noticeMock, + windDownDelayMock, + oracleConfigMock, + complianceMock, + identityVerifierMock, +} = vi.hoisted(() => { + const tx = () => ({ + status: { status: "idle" } as { status: string }, + submit: vi.fn(() => true), + reset: vi.fn(), + }) + return { + useConfigurationStateMock: vi.fn(), + depositCapMock: tx(), + custodianMock: tx(), + noticeMock: tx(), + windDownDelayMock: tx(), + oracleConfigMock: tx(), + complianceMock: tx(), + identityVerifierMock: tx(), + } +}) + +vi.mock("../../hooks/useConfigurationState", () => ({ + useConfigurationState: useConfigurationStateMock, +})) +vi.mock("../../hooks/useConfigurationTransactions", () => ({ + useSetDepositCap: () => depositCapMock, + useSetCustodian: () => custodianMock, + useSetNotice: () => noticeMock, + useSetWindDownDelay: () => windDownDelayMock, + useSetOracleConfig: () => oracleConfigMock, + useSetCompliance: () => complianceMock, + useSetIdentityVerifier: () => identityVerifierMock, +})) + +const WALLET = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H" +const CUSTODIAN = "CACAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAINCW" +const COMPLIANCE = "CACQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQLC2U" +const VERIFIER = "CADAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMSST" +const DAY = 86_400n +const PRICE_ONE = 10n ** 18n + +const grantOf = (role: string): Grant => ({ + role, + authority: WALLET, + standing: "signs-alone", +}) + +const governance = grantOf("vault governance") +const oracleAdmin = grantOf("oracle admin") + +const READY: ConfigurationState = { + status: "ready", + depositCap: { kind: "uncapped" }, + custodian: CUSTODIAN, + notice: DAY, + windDownDelay: 7n * DAY, + upgradeDelay: 3n * DAY, + oracle: { + freshness: DAY, + cooldown: 3_600n, + maxUpBps: 1_000, + maxDownBps: null, + min: PRICE_ONE / 2n, + max: 2n * PRICE_ONE, + }, + compliance: COMPLIANCE, + identityVerifier: VERIFIER, +} + +const expand = (name: string) => { + const toggle = screen + .getAllByRole("button", { name }) + .find((candidate) => candidate.hasAttribute("aria-expanded")) + if (toggle?.getAttribute("aria-expanded") === "false") fireEvent.click(toggle) +} + +const submit = (name: string) => + screen + .getAllByRole("button", { name }) + .find((candidate) => !candidate.hasAttribute("aria-expanded"))! + +describe("ConfigurationSection", () => { + beforeEach(() => { + vi.clearAllMocks() + for (const mock of [ + depositCapMock, + custodianMock, + noticeMock, + windDownDelayMock, + oracleConfigMock, + complianceMock, + identityVerifierMock, + ]) + Object.assign(mock, { + status: { status: "idle" } as TransactionStatus, + submit: vi.fn(() => true), + reset: vi.fn(), + }) + useConfigurationStateMock.mockReturnValue({ configuration: READY }) + }) + + it("shows the current parameters grouped by contract", () => { + render() + + const vault = screen.getByRole("region", { name: "Vault" }) + expect(within(vault).getByText("No cap")).toBeTruthy() + expect(within(vault).getByText("Upgrade delay")).toBeTruthy() + expect(within(vault).getByText("Custodian")).toBeTruthy() + const oracle = screen.getByRole("region", { name: "Oracle" }) + expect(within(oracle).getByText("10%")).toBeTruthy() + const token = screen.getByRole("region", { name: "Share token" }) + expect(within(token).getByText("Identity verifier")).toBeTruthy() + }) + + it("submits a new notice once typed, and follows it in the modal", () => { + const { rerender } = render( + , + ) + expand("Set the notice") + expect(submit("Set the notice").disabled).toBe(true) + + fireEvent.change(screen.getByRole("textbox", { name: "Notice" }), { + target: { value: "2d" }, + }) + fireEvent.click(submit("Set the notice")) + expect(noticeMock.submit).toHaveBeenCalledWith(2n * DAY) + + noticeMock.status = { status: "awaiting-signature" } + rerender() + expect(screen.getByText("New notice: 2d.")).toBeTruthy() + }) + + it("sends every oracle limit when one changes", () => { + render() + expand("Set the oracle limits") + + fireEvent.change(screen.getByRole("textbox", { name: "Largest rise" }), { + target: { value: "5" }, + }) + fireEvent.click(submit("Set the oracle limits")) + + expect(oracleConfigMock.submit).toHaveBeenCalledWith({ + freshness_duration: DAY, + cooldown_secs: 3_600n, + max_up_bps: 500, + max_down_bps: null, + min_answer: PRICE_ONE / 2n, + max_answer: 2n * PRICE_ONE, + }) + }) + + it("returns the fields to the current values once a change is recorded", () => { + const { rerender } = render( + , + ) + expand("Set the notice") + const field = () => + screen.getByRole("textbox", { name: "Notice" }) + fireEvent.change(field(), { target: { value: "2d" } }) + + noticeMock.status = { status: "confirmed" } + rerender() + + expect(field().value).toBe("1d") + }) + + it("shows the flow modal only while the surface is active", () => { + noticeMock.status = { status: "preparing" } + const { rerender } = render( + , + ) + expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1) + + rerender( + , + ) + expect(screen.queryAllByRole("dialog", { hidden: true })).toHaveLength(0) + }) + + it("reads the parameters with polling only while active", () => { + const { rerender } = render( + , + ) + expect(useConfigurationStateMock).toHaveBeenLastCalledWith({ + active: false, + }) + + rerender( + , + ) + expect(useConfigurationStateMock).toHaveBeenLastCalledWith({ active: true }) + }) +}) diff --git a/app/src/components/admin/ConfigurationSection.tsx b/app/src/components/admin/ConfigurationSection.tsx new file mode 100644 index 0000000..dd0ee7e --- /dev/null +++ b/app/src/components/admin/ConfigurationSection.tsx @@ -0,0 +1,168 @@ +import React, { useEffect, useState } from "react" +import { type Grant } from "../../hooks/useAdminAuthority" +import { useConfigurationState } from "../../hooks/useConfigurationState" +import { + useSetCompliance, + useSetCustodian, + useSetDepositCap, + useSetIdentityVerifier, + useSetNotice, + useSetOracleConfig, + useSetWindDownDelay, +} from "../../hooks/useConfigurationTransactions" +import { + usePendingTransaction, + type UsePendingTransaction, +} from "../../hooks/usePendingTransaction" +import { + type ConfigurationAction, + type ConfigurationInputs, + toConfigurationActions, + toConfigurationGroups, +} from "../../pages/configuration" +import typeStyles from "../../styles/type.module.css" +import AddressRows from "../vault/AddressRows" +import MetricsStrip from "../vault/MetricsStrip" +import TransactionModal, { + type ConfigurationModalAction, + type TransactionModalProps, +} from "../vault/TransactionModal" +import CycleActionRail from "./CycleActionRail" +import SurfaceLayout from "./SurfaceLayout" +import layoutStyles from "./SurfaceLayout.module.css" + +type Flow = Pick< + UsePendingTransaction, + "status" | "amountLabel" | "reset" | "retry" +> + +const flow = ( + action: ConfigurationModalAction, + transaction: Flow, +): TransactionModalProps => ({ + action, + status: transaction.status, + value: transaction.amountLabel, + onClose: transaction.reset, + onRetry: transaction.retry, +}) + +const ConfigurationSection: React.FC<{ + grants: Grant[] + wallet: string + contracts?: React.ReactNode + active?: boolean +}> = ({ grants, wallet, contracts, active = true }) => { + const { configuration } = useConfigurationState({ active }) + const depositCap = usePendingTransaction(useSetDepositCap()) + const custodian = usePendingTransaction(useSetCustodian()) + const notice = usePendingTransaction(useSetNotice()) + const windDownDelay = usePendingTransaction(useSetWindDownDelay()) + const oracleConfig = usePendingTransaction(useSetOracleConfig()) + const compliance = usePendingTransaction(useSetCompliance()) + const identityVerifier = usePendingTransaction(useSetIdentityVerifier()) + const [inputs, setInputs] = useState({}) + + const modalFlows: TransactionModalProps[] = [ + flow("set-deposit-cap", depositCap), + flow("set-custodian", custodian), + flow("set-notice", notice), + flow("set-wind-down-delay", windDownDelay), + flow("set-oracle-config", oracleConfig), + flow("set-compliance", compliance), + flow("set-identity-verifier", identityVerifier), + ] + const recorded = modalFlows.some( + (props) => props.status.status === "confirmed", + ) + useEffect(() => { + if (recorded) setInputs({}) + }, [recorded]) + + const actions = toConfigurationActions(configuration, grants, wallet, inputs) + + const run = (action: ConfigurationAction) => { + switch (action.id) { + case "set-deposit-cap": + if (action.arg !== undefined) depositCap.submit(action.arg, action.next) + return + case "set-custodian": + if (action.arg !== undefined) custodian.submit(action.arg, action.next) + return + case "set-notice": + if (action.arg !== undefined) notice.submit(action.arg, action.next) + return + case "set-wind-down-delay": + if (action.arg !== undefined) + windDownDelay.submit(action.arg, action.next) + return + case "set-oracle-config": + if (action.arg !== undefined) + oracleConfig.submit(action.arg, action.next) + return + case "set-compliance": + if (action.arg !== undefined) compliance.submit(action.arg, action.next) + return + case "set-identity-verifier": + if (action.arg !== undefined) + identityVerifier.submit(action.arg, action.next) + } + } + + const onInput = (_: ConfigurationAction, value: string, key?: string) => { + if (key !== undefined) + setInputs((previous) => ({ ...previous, [key]: value })) + } + + const activeModal = modalFlows.find((props) => props.status.status !== "idle") + + return ( + <> + + } + > + {toConfigurationGroups(configuration).map((group, index) => { + const headingId = `configuration-group-${index}` + return ( +
+

+ {group.title} +

+ {group.strips.map((strip) => ( + + ))} + {group.addresses.length > 0 && ( + + )} +
+ ) + })} + {contracts} +
+ {active && activeModal !== undefined && ( + + )} + + ) +} + +export default ConfigurationSection diff --git a/app/src/components/admin/CycleActionRail.module.css b/app/src/components/admin/CycleActionRail.module.css index 6af65d6..ac0ee34 100644 --- a/app/src/components/admin/CycleActionRail.module.css +++ b/app/src/components/admin/CycleActionRail.module.css @@ -195,6 +195,29 @@ border: 2px solid var(--vault-danger); } +.keyed { + display: flex; + flex-direction: column; + gap: 6px; +} + +.keyedHead { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: baseline; + gap: 4px 12px; +} + +.fieldLabel { + color: var(--vault-ink); +} + +.fieldValue { + color: var(--vault-text-muted); + overflow-wrap: anywhere; +} + .fieldInput { flex: 1; min-width: 0; diff --git a/app/src/components/admin/CycleActionRail.test.tsx b/app/src/components/admin/CycleActionRail.test.tsx index ed79aa1..fccfe6b 100644 --- a/app/src/components/admin/CycleActionRail.test.tsx +++ b/app/src/components/admin/CycleActionRail.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, within } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" +import { type RailAction } from "../../pages/actions" import { type CycleAction } from "../../pages/cycle" import CycleActionRail from "./CycleActionRail" @@ -422,3 +423,86 @@ describe("CycleActionRail", () => { expect(within(list).queryByText(/Ripcord/)).toBeNull() }) }) + +describe("CycleActionRail with keyed fields", () => { + const setLimits: RailAction = { + id: "set-oracle-config", + label: "Set the oracle limits", + conditions: [{ label: "Differs from current", met: true }], + outcome: null, + fields: [ + { + key: "freshness", + label: "Freshness window", + placeholder: "7d", + value: "2d", + invalid: false, + text: true, + current: "1d", + next: "2d", + }, + { + key: "floor", + label: "Price floor", + placeholder: "0.00", + value: "0.5", + invalid: false, + current: "0.50", + next: null, + }, + ], + enabled: true, + } + + const renderKeyed = (onInput = vi.fn()) => { + render( + , + ) + return onInput + } + + it("labels every field and reports each keystroke with the field's key", () => { + const onInput = renderKeyed() + + const freshness = screen.getByRole("textbox", { name: "Freshness window" }) + fireEvent.change(freshness, { target: { value: "3d" } }) + fireEvent.change(screen.getByRole("textbox", { name: "Price floor" }), { + target: { value: "0.6" }, + }) + + expect(onInput).toHaveBeenNthCalledWith(1, setLimits, "3d", "freshness") + expect(onInput).toHaveBeenNthCalledWith(2, setLimits, "0.6", "floor") + }) + + it("shows the current value next to the field and the new one once it reads", () => { + renderKeyed() + + expect(screen.getByText("Current 1d")).toBeTruthy() + expect(screen.getByText("New 2d")).toBeTruthy() + expect(screen.getByText("Current 0.50")).toBeTruthy() + expect(screen.queryByText(/^New 0/)).toBeNull() + }) + + it("asks for text where the field is not a figure", () => { + renderKeyed() + + expect( + ( + screen.getByRole("textbox", { + name: "Freshness window", + }) as HTMLInputElement + ).inputMode, + ).toBe("text") + expect( + (screen.getByRole("textbox", { name: "Price floor" }) as HTMLInputElement) + .inputMode, + ).toBe("decimal") + }) +}) diff --git a/app/src/components/admin/CycleActionRail.tsx b/app/src/components/admin/CycleActionRail.tsx index f53ce07..a18756a 100644 --- a/app/src/components/admin/CycleActionRail.tsx +++ b/app/src/components/admin/CycleActionRail.tsx @@ -11,7 +11,7 @@ import typeStyles from "../../styles/type.module.css" import Skeleton from "../Skeleton" import styles from "./CycleActionRail.module.css" -type OnInput = (action: A, value: string) => void +type OnInput = (action: A, value: string, key?: string) => void const conditionValue = (condition: Condition): string => { if (condition.met === null) return "—" @@ -35,32 +35,66 @@ function ActionField({ field: ActionFieldSpec onInput: OnInput | undefined }) { - return ( + const report = (value: string) => + field.key === undefined + ? onInput?.(action, value) + : onInput?.(action, value, field.key) + const label = ( + + ) + const input = (
- + {field.current === undefined && label} onInput?.(action, event.target.value)} + onChange={(event) => report(event.target.value)} placeholder={field.placeholder} - inputMode="decimal" + inputMode={field.text === true ? "text" : "decimal"} /> {field.max !== undefined && ( )}
) + if (field.current === undefined) return input + return ( +
+
+ {label} + + Current {field.current} + +
+ {input} + {field.next !== undefined && field.next !== null && ( + + New {field.next} + + )} +
+ ) } +const fieldsOf = (action: RailAction): ActionFieldSpec[] => + action.fields ?? (action.field === undefined ? [] : [action.field]) + const buttonClass = (action: RailAction): string | undefined => { if (!action.enabled) return styles.actionUnavailable return action.tone === "danger" ? styles.actionDanger : styles.actionPrimary @@ -79,14 +113,15 @@ function ActionBody
({ }) { return ( <> - {action.field !== undefined && ( + {fieldsOf(action).map((field) => ( - )} + ))}
    ({ explorerTransactionMock: vi.fn(() => null as string | null), @@ -2080,3 +2083,141 @@ describe("TransactionModal, emergency actions", () => { ).toBeTruthy() }) }) + +const renderConfigurationModal = ( + action: ConfigurationModalAction, + status: TransactionStatus, + value = "", +) => + render( + , + ) + +describe("TransactionModal, configuration actions", () => { + it.each<[ConfigurationModalAction, string, string]>([ + [ + "set-deposit-cap", + "Preparing to set the deposit cap", + "Deposit cap updated", + ], + ["set-custodian", "Preparing to set the custodian", "Custodian updated"], + ["set-notice", "Preparing to set the notice", "Notice updated"], + [ + "set-wind-down-delay", + "Preparing to set the wind-down delay", + "Wind-down delay updated", + ], + [ + "set-oracle-config", + "Preparing to set the oracle limits", + "Oracle limits updated", + ], + [ + "set-compliance", + "Preparing to set the compliance contract", + "Compliance contract updated", + ], + [ + "set-identity-verifier", + "Preparing to set the identity verifier", + "Identity verifier updated", + ], + ])("walks %s from preparing to confirmed", (action, preparing, confirmed) => { + const { rerender } = renderConfigurationModal(action, { + status: "preparing", + }) + expect(screen.getByRole("heading", { name: preparing })).toBeTruthy() + + rerender( + , + ) + expect(screen.getByRole("heading", { name: confirmed })).toBeTruthy() + }) + + it("states the new value before signing and once recorded", () => { + const { rerender } = renderConfigurationModal( + "set-deposit-cap", + { status: "awaiting-signature" }, + "5,000.00", + ) + expect(screen.getByText("New deposit cap: 5,000.00.")).toBeTruthy() + + rerender( + , + ) + expect(screen.getByText("Deposit cap: 5,000.00.")).toBeTruthy() + }) + + it.each<[ConfigurationModalAction, string]>([ + ["set-custodian", "Treasury deploys to the new address from now on."], + ["set-notice", "Applies to epochs closed from now on."], + ["set-wind-down-delay", "A proposed wind-down keeps its activation time."], + ])("names the side effect of %s once recorded", (action, effect) => { + renderConfigurationModal(action, { status: "confirmed" }, "3d") + + expect(screen.getByText(effect)).toBeTruthy() + }) + + it.each<[ConfigurationModalAction, number, string, RegExp]>([ + ["set-deposit-cap", 6007, "vault", /The deposit cap cannot be negative/], + ["set-notice", 6045, "vault", /The notice cannot exceed 30 days/], + ["set-notice", 6061, "vault", /The notice cannot exceed the upgrade delay/], + [ + "set-wind-down-delay", + 6053, + "vault", + /The wind-down delay cannot exceed 90 days/, + ], + [ + "set-oracle-config", + 3001, + "oracle", + /One of the oracle limits is out of range/, + ], + ["set-compliance", 2000, "share token", /does not hold the role/], + ])( + "names the reason %s was refused with code %i", + (action, code, refuser, sentence) => { + renderConfigurationModal(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("sends an unknown outcome to the Configuration tab", () => { + renderConfigurationModal("set-notice", { + status: "failed", + failure: { kind: "unknown" }, + }) + + expect( + screen.getByText(/check the Configuration tab before trying again/), + ).toBeTruthy() + }) +}) diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index 08fad38..d7137e7 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -24,6 +24,7 @@ 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 { EFFECTS } from "../../pages/configuration" import typeStyles from "../../styles/type.module.css" import Close from "../icons/Close" import ExternalLink from "../icons/ExternalLink" @@ -38,6 +39,15 @@ export type EmergencyModalAction = | "pause-token" | "unpause-token" +export type ConfigurationModalAction = + | "set-deposit-cap" + | "set-custodian" + | "set-notice" + | "set-wind-down-delay" + | "set-oracle-config" + | "set-compliance" + | "set-identity-verifier" + export type TransactionModalProps = | { action: EmergencyModalAction @@ -45,6 +55,13 @@ export type TransactionModalProps = onClose: () => void onRetry: () => void } + | { + action: ConfigurationModalAction + status: TransactionStatus + value: string + onClose: () => void + onRetry: () => void + } | { action: "subscribe" status: RequestDepositStatus @@ -366,6 +383,8 @@ 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 CONFIGURATION_FOLLOW_UP = + "check the Configuration tab before trying again" const describeFailure = ( failure: TransactionFailure, @@ -934,7 +953,7 @@ const describeFinalizeRoundStatus = ( } } -type EmergencyCopy = { +type AdminCopy = { refuser: "vault" | "share token" | "oracle" verb: string signing: string @@ -942,7 +961,7 @@ type EmergencyCopy = { doneBody: string } -const EMERGENCY_COPY: Record = { +const EMERGENCY_COPY: Record = { "pause-vault": { refuser: "vault", verb: "pause the vault", @@ -998,8 +1017,41 @@ const EMERGENCY_COPY: Record = { }, } -const emergencyContractErrorReason = - (refuser: EmergencyCopy["refuser"]) => +const setterCopy = + (refuser: AdminCopy["refuser"], subject: string, effect?: string) => + (value: string): AdminCopy => ({ + refuser, + verb: `set the ${subject.toLowerCase()}`, + signing: `New ${subject.toLowerCase()}: ${value}.`, + done: `${subject} updated`, + doneBody: effect ?? `${subject}: ${value}.`, + }) + +const CONFIGURATION_COPY: Record< + ConfigurationModalAction, + (value: string) => AdminCopy +> = { + "set-deposit-cap": setterCopy("vault", "Deposit cap"), + "set-custodian": setterCopy("vault", "Custodian", EFFECTS.custodian), + "set-notice": setterCopy("vault", "Notice", EFFECTS.notice), + "set-wind-down-delay": setterCopy( + "vault", + "Wind-down delay", + EFFECTS.windDownDelay, + ), + "set-oracle-config": () => ({ + refuser: "oracle", + verb: "set the oracle limits", + signing: "Signing replaces every oracle limit.", + done: "Oracle limits updated", + doneBody: "The new limits are recorded.", + }), + "set-compliance": setterCopy("share token", "Compliance contract"), + "set-identity-verifier": setterCopy("share token", "Identity verifier"), +} + +const adminContractErrorReason = + (refuser: AdminCopy["refuser"]) => (code: number): string => { switch (code) { case 1000: @@ -1008,18 +1060,28 @@ const emergencyContractErrorReason = 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 3001: + return "One of the oracle limits is out of range." case 3008: return "The ripcord is not raised, so the latest price cannot be cleared." + case 6007: + return "The deposit cap cannot be negative." + case 6045: + return "The notice cannot exceed 30 days." + case 6053: + return "The wind-down delay cannot exceed 90 days." + case 6061: + return "The notice cannot exceed the upgrade delay." default: return `The ${refuser} refused this request. Try again, and contact support if it keeps happening.` } } -const describeEmergencyStatus = ( - action: EmergencyModalAction, +const describeAdminStatus = ( + copy: AdminCopy, status: TransactionStatus, + followUp: string, ): { heading: string; body: string; hash?: string } | undefined => { - const copy = EMERGENCY_COPY[action] switch (status.status) { case "idle": return undefined @@ -1042,8 +1104,8 @@ const describeEmergencyStatus = ( return { ...describeFailure( status.failure, - emergencyContractErrorReason(copy.refuser), - EMERGENCY_FOLLOW_UP, + adminContractErrorReason(copy.refuser), + followUp, copy.refuser, ), hash: status.hash, @@ -1103,7 +1165,23 @@ const describeStatus = ( case "clear-latest": case "pause-token": case "unpause-token": - return describeEmergencyStatus(props.action, props.status) + return describeAdminStatus( + EMERGENCY_COPY[props.action], + props.status, + EMERGENCY_FOLLOW_UP, + ) + case "set-deposit-cap": + case "set-custodian": + case "set-notice": + case "set-wind-down-delay": + case "set-oracle-config": + case "set-compliance": + case "set-identity-verifier": + return describeAdminStatus( + CONFIGURATION_COPY[props.action](props.value), + props.status, + CONFIGURATION_FOLLOW_UP, + ) } } diff --git a/app/src/hooks/useAdminAuthority.test.ts b/app/src/hooks/useAdminAuthority.test.ts index 6c9d222..65279e4 100644 --- a/app/src/hooks/useAdminAuthority.test.ts +++ b/app/src/hooks/useAdminAuthority.test.ts @@ -72,8 +72,8 @@ describe("toSurfaces", () => { ["share-token compliance", ["compliance"]], ["vault guardian", ["emergency"]], ["oracle guardian", ["emergency"]], - ["share-token admin", ["emergency"]], - ["oracle admin", ["emergency"]], + ["share-token admin", ["emergency", "configuration"]], + ["oracle admin", ["emergency", "configuration"]], ["vault governance", ["emergency", "configuration", "governance"]], ])("grants %s's surfaces to the wallet that holds it", (role, expected) => { const { surfaces, grantedBy, grants } = toSurfaces( @@ -436,8 +436,10 @@ describe("useAdminAuthority", () => { await waitFor(() => expect(result.current.status).toBe("ready")) - expect(result.current.surfaces).toEqual(new Set(["cycle", "emergency"])) - expect(result.current.grantedBy.emergency[0]?.role).toBe( + expect(result.current.surfaces).toEqual( + new Set(["cycle", "emergency", "configuration"]), + ) + expect(result.current.grantedBy.configuration[0]?.role).toBe( "share-token admin", ) }) @@ -448,8 +450,10 @@ describe("useAdminAuthority", () => { await waitFor(() => expect(result.current.status).toBe("ready")) - expect(result.current.surfaces).toEqual(new Set(["cycle", "emergency"])) - expect(result.current.grantedBy.emergency).toEqual([ + expect(result.current.surfaces).toEqual( + new Set(["cycle", "emergency", "configuration"]), + ) + expect(result.current.grantedBy.configuration).toEqual([ { role: "oracle admin", authority: investorAddress, @@ -467,7 +471,9 @@ describe("useAdminAuthority", () => { await waitFor(() => expect(result.current.status).toBe("ready")) expect(accountSignersMock).toHaveBeenCalledWith(multisig) - expect(result.current.surfaces).toEqual(new Set(["emergency"])) + expect(result.current.surfaces).toEqual( + new Set(["emergency", "configuration"]), + ) }) it("reports unreadable when the oracle admin read fails", async () => { diff --git a/app/src/hooks/useAdminAuthority.ts b/app/src/hooks/useAdminAuthority.ts index 734d988..dd8f73e 100644 --- a/app/src/hooks/useAdminAuthority.ts +++ b/app/src/hooks/useAdminAuthority.ts @@ -63,8 +63,8 @@ const ROLE_SURFACES = { "share-token compliance": ["compliance"], "vault guardian": ["emergency"], "oracle guardian": ["emergency"], - "share-token admin": ["emergency"], - "oracle admin": ["emergency"], + "share-token admin": ["emergency", "configuration"], + "oracle admin": ["emergency", "configuration"], "vault governance": ["emergency", "configuration", "governance"], } as const satisfies Record @@ -314,10 +314,12 @@ async function fetchAdminAuthority(wallet: string): Promise { } } +export const adminAuthorityKey = ["admin", "authority"] as const + export function useAdminAuthority(): AdminAuthority { const { address } = useWallet() const { data } = useQuery({ - queryKey: ["admin", "authority", address], + queryKey: [...adminAuthorityKey, address], queryFn: address === undefined ? skipToken : () => fetchAdminAuthority(address), staleTime: 30_000, diff --git a/app/src/hooks/useEmergencyTransactions.test.ts b/app/src/hooks/useAdminTransaction.test.ts similarity index 67% rename from app/src/hooks/useEmergencyTransactions.test.ts rename to app/src/hooks/useAdminTransaction.test.ts index decc684..c3d0bd9 100644 --- a/app/src/hooks/useEmergencyTransactions.test.ts +++ b/app/src/hooks/useAdminTransaction.test.ts @@ -2,6 +2,18 @@ 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 { adminAuthorityKey } from "./useAdminAuthority" +import { configurationReadsKey } from "./useConfigurationState" +import { + useSetCompliance, + useSetCustodian, + useSetDepositCap, + useSetIdentityVerifier, + useSetNotice, + useSetOracleConfig, + useSetWindDownDelay, +} from "./useConfigurationTransactions" +import { cycleReadsKey } from "./useCycleState" import { useClearLatest, usePauseToken, @@ -16,12 +28,25 @@ 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() }, + vault: { + pause: vi.fn(), + unpause: vi.fn(), + set_deposit_cap: vi.fn(), + set_custodian: vi.fn(), + set_notice: vi.fn(), + set_wind_down_delay: vi.fn(), + }, + token: { + pause: vi.fn(), + unpause: vi.fn(), + set_compliance: vi.fn(), + set_identity_verifier: vi.fn(), + }, oracle: { raise_ripcord: vi.fn(), set_ripcord: vi.fn(), clear_latest: vi.fn(), + set_config: vi.fn(), }, })) @@ -31,6 +56,17 @@ vi.mock("../config/clients", () => ({ navOracleWriter: async () => oracle, })) +const CONTRACT = "CCONTRACTADDRESS1234567890" +const VAULT_AUTHORITIES_KEY = ["vault", "authorities"] +const ORACLE_CONFIG = { + freshness_duration: 86_400n, + cooldown_secs: 3_600n, + max_up_bps: 1_000, + max_down_bps: null, + min_answer: 1n, + max_answer: 10n, +} + type Case = { name: string useHook: () => { @@ -108,6 +144,69 @@ const cases: Case[] = [ keys: oracleWriteKeys, refusal: 2000, }, + { + name: "useSetDepositCap", + useHook: useSetDepositCap, + method: vault.set_deposit_cap, + arg: null, + args: { cap: null, caller: investorAddress }, + keys: [cycleReadsKey], + refusal: 6007, + }, + { + name: "useSetCustodian", + useHook: useSetCustodian, + method: vault.set_custodian, + arg: CONTRACT, + args: { custodian: CONTRACT, caller: investorAddress }, + keys: [VAULT_AUTHORITIES_KEY], + refusal: 2000, + }, + { + name: "useSetNotice", + useHook: useSetNotice, + method: vault.set_notice, + arg: 86_400n, + args: { secs: 86_400n, caller: investorAddress }, + keys: [cycleReadsKey], + refusal: 6061, + }, + { + name: "useSetWindDownDelay", + useHook: useSetWindDownDelay, + method: vault.set_wind_down_delay, + arg: 86_400n, + args: { secs: 86_400n, caller: investorAddress }, + keys: [cycleReadsKey], + refusal: 6053, + }, + { + name: "useSetOracleConfig", + useHook: useSetOracleConfig, + method: oracle.set_config, + arg: ORACLE_CONFIG, + args: { config: ORACLE_CONFIG }, + keys: oracleWriteKeys, + refusal: 3001, + }, + { + name: "useSetCompliance", + useHook: useSetCompliance, + method: token.set_compliance, + arg: CONTRACT, + args: { compliance: CONTRACT, operator: investorAddress }, + keys: [configurationReadsKey], + refusal: 2000, + }, + { + name: "useSetIdentityVerifier", + useHook: useSetIdentityVerifier, + method: token.set_identity_verifier, + arg: CONTRACT, + args: { identity_verifier: CONTRACT, operator: investorAddress }, + keys: [configurationReadsKey, adminAuthorityKey], + refusal: 2000, + }, ] const confirmed = () => ({ diff --git a/app/src/hooks/useAdminTransaction.ts b/app/src/hooks/useAdminTransaction.ts new file mode 100644 index 0000000..475b7cd --- /dev/null +++ b/app/src/hooks/useAdminTransaction.ts @@ -0,0 +1,19 @@ +import { type AssembledTransaction } from "@stellar/stellar-sdk/contract" +import { type Signer } from "@stellar-scaffold/app-lib" +import { type QueryKey } from "@tanstack/react-query" +import { + useContractTransaction, + type UseContractTransaction, +} from "./useContractTransaction" + +type NoResult = Record + +const toConfirmed = () => ({}) + +export const adminTransaction = + ( + call: (signer: Signer, arg: TArg) => Promise>, + keys: readonly QueryKey[], + ) => + (): UseContractTransaction => + useContractTransaction(call, toConfirmed, keys) diff --git a/app/src/hooks/useConfigurationState.test.ts b/app/src/hooks/useConfigurationState.test.ts new file mode 100644 index 0000000..9ce29bd --- /dev/null +++ b/app/src/hooks/useConfigurationState.test.ts @@ -0,0 +1,108 @@ +import { waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { renderWithWallet } from "./testSupport" +import { useConfigurationState } from "./useConfigurationState" + +const { vaultMock, tokenMock, useCycleReadsMock, useVaultAuthoritiesMock } = + vi.hoisted(() => ({ + vaultMock: { upgrade_delay: vi.fn() }, + tokenMock: { compliance: vi.fn(), identity_verifier: vi.fn() }, + useCycleReadsMock: vi.fn(), + useVaultAuthoritiesMock: vi.fn(), + })) + +vi.mock("../config/clients", () => ({ + asyncVault: async () => vaultMock, + shareToken: async () => tokenMock, +})) +vi.mock("./useCycleState", async (importOriginal) => ({ + ...(await importOriginal()), + useCycleReads: useCycleReadsMock, +})) +vi.mock("./useVaultAuthorities", () => ({ + useVaultAuthorities: useVaultAuthoritiesMock, +})) + +const CUSTODIAN = "GCUSTODIAN" +const COMPLIANCE = "CCOMPLIANCE" +const VERIFIER = "CVERIFIER" +const LIMITS = { + freshness: 86_400n, + cooldown: 3_600n, + maxUpBps: 1_000, + maxDownBps: null, + min: 1n, + max: 10n, +} + +const cycleReads = { + notice: { kind: "value", value: 3_600n }, + depositCap: { kind: "uncapped" }, + windDown: { delay: 172_800n }, + oracle: { limits: LIMITS }, +} + +describe("useConfigurationState", () => { + beforeEach(() => { + vi.clearAllMocks() + useCycleReadsMock.mockReturnValue(cycleReads) + useVaultAuthoritiesMock.mockReturnValue({ + authorities: { custodian: CUSTODIAN }, + isPending: false, + }) + vaultMock.upgrade_delay.mockResolvedValue({ result: 604_800n }) + tokenMock.compliance.mockResolvedValue({ result: COMPLIANCE }) + tokenMock.identity_verifier.mockResolvedValue({ result: VERIFIER }) + }) + + it("gathers every parameter the configuration surface can change", async () => { + const { result } = renderWithWallet(() => useConfigurationState()) + + await waitFor(() => + expect(result.current.configuration).toEqual({ + status: "ready", + depositCap: { kind: "uncapped" }, + custodian: CUSTODIAN, + notice: 3_600n, + windDownDelay: 172_800n, + upgradeDelay: 604_800n, + oracle: LIMITS, + compliance: COMPLIANCE, + identityVerifier: VERIFIER, + }), + ) + }) + + it("is checking until the cycle reads arrive", () => { + useCycleReadsMock.mockReturnValue(undefined) + const { result } = renderWithWallet(() => useConfigurationState()) + + expect(result.current.configuration).toEqual({ status: "checking" }) + }) + + it("leaves a parameter it could not read unknown, never guessed", async () => { + vaultMock.upgrade_delay.mockRejectedValue(new Error("simulation failed")) + tokenMock.compliance.mockRejectedValue(new Error("simulation failed")) + useCycleReadsMock.mockReturnValue({ + ...cycleReads, + notice: { kind: "unreadable" }, + }) + const { result } = renderWithWallet(() => useConfigurationState()) + + await waitFor(() => + expect(result.current.configuration).toMatchObject({ + status: "ready", + notice: null, + upgradeDelay: null, + compliance: null, + identityVerifier: VERIFIER, + }), + ) + }) + + it("polls only while active", () => { + renderWithWallet(() => useConfigurationState({ active: false })) + + expect(useCycleReadsMock).toHaveBeenCalledWith(false) + }) +}) diff --git a/app/src/hooks/useConfigurationState.ts b/app/src/hooks/useConfigurationState.ts new file mode 100644 index 0000000..4fde92a --- /dev/null +++ b/app/src/hooks/useConfigurationState.ts @@ -0,0 +1,72 @@ +import { type ContractRead, readContract } from "@stellar-scaffold/app-lib" +import { useQuery } from "@tanstack/react-query" +import { asyncVault, shareToken } from "../config/clients" +import { + type DepositCap, + type OracleLimits, + useCycleReads, +} from "./useCycleState" +import { useVaultAuthorities } from "./useVaultAuthorities" + +const STALE_MS = 30_000 +const REFRESH_MS = 15_000 + +export const configurationReadsKey = ["configuration", "reads"] as const + +export type ConfigurationValues = { + depositCap: DepositCap | null + custodian: string | null + notice: bigint | null + windDownDelay: bigint | null + upgradeDelay: bigint | null + oracle: OracleLimits | null + compliance: string | null + identityVerifier: string | null +} + +export type ConfigurationState = + { status: "checking" } | ({ status: "ready" } & ConfigurationValues) + +const valueOf = (read: ContractRead): T | null => + read.kind === "value" ? read.value : null + +async function fetchConfigurationReads() { + const [upgradeDelay, compliance, identityVerifier] = await Promise.all([ + readContract(async () => (await asyncVault()).upgrade_delay()), + readContract(async () => (await shareToken()).compliance()), + readContract(async () => (await shareToken()).identity_verifier()), + ]) + return { + upgradeDelay: valueOf(upgradeDelay), + compliance: valueOf(compliance), + identityVerifier: valueOf(identityVerifier), + } +} + +export function useConfigurationState({ + active = true, +}: { active?: boolean } = {}): { configuration: ConfigurationState } { + const cycle = useCycleReads(active) + const { authorities, isPending } = useVaultAuthorities() + const { data: reads } = useQuery({ + queryKey: configurationReadsKey, + queryFn: fetchConfigurationReads, + staleTime: STALE_MS, + refetchInterval: active ? REFRESH_MS : false, + }) + + if (cycle === undefined || reads === undefined || isPending) + return { configuration: { status: "checking" } } + + return { + configuration: { + status: "ready", + depositCap: cycle.depositCap, + custodian: authorities?.custodian ?? null, + notice: valueOf(cycle.notice), + windDownDelay: cycle.windDown.delay, + oracle: cycle.oracle.limits, + ...reads, + }, + } +} diff --git a/app/src/hooks/useConfigurationTransactions.ts b/app/src/hooks/useConfigurationTransactions.ts new file mode 100644 index 0000000..e511e4d --- /dev/null +++ b/app/src/hooks/useConfigurationTransactions.ts @@ -0,0 +1,72 @@ +import { type NavOracleApi, type Signer } from "@stellar-scaffold/app-lib" +import { + asyncVaultWriter, + navOracleWriter, + shareTokenWriter, +} from "../config/clients" +import { adminAuthorityKey } from "./useAdminAuthority" +import { adminTransaction } from "./useAdminTransaction" +import { configurationReadsKey } from "./useConfigurationState" +import { cycleReadsKey } from "./useCycleState" +import { oracleWriteKeys } from "./useOracleState" + +export type OracleConfig = Parameters[0]["config"] + +const vaultAuthoritiesKey = ["vault", "authorities"] as const + +export const useSetDepositCap = adminTransaction( + (signer: Signer, cap: bigint | null) => + asyncVaultWriter(signer).then((vault) => + vault.set_deposit_cap({ cap, caller: signer.publicKey }), + ), + [cycleReadsKey], +) + +export const useSetCustodian = adminTransaction( + (signer: Signer, custodian: string) => + asyncVaultWriter(signer).then((vault) => + vault.set_custodian({ custodian, caller: signer.publicKey }), + ), + [vaultAuthoritiesKey], +) + +export const useSetNotice = adminTransaction( + (signer: Signer, secs: bigint) => + asyncVaultWriter(signer).then((vault) => + vault.set_notice({ secs, caller: signer.publicKey }), + ), + [cycleReadsKey], +) + +export const useSetWindDownDelay = adminTransaction( + (signer: Signer, secs: bigint) => + asyncVaultWriter(signer).then((vault) => + vault.set_wind_down_delay({ secs, caller: signer.publicKey }), + ), + [cycleReadsKey], +) + +export const useSetOracleConfig = adminTransaction( + (signer: Signer, config: OracleConfig) => + navOracleWriter(signer).then((oracle) => oracle.set_config({ config })), + oracleWriteKeys, +) + +export const useSetCompliance = adminTransaction( + (signer: Signer, compliance: string) => + shareTokenWriter(signer).then((token) => + token.set_compliance({ compliance, operator: signer.publicKey }), + ), + [configurationReadsKey], +) + +export const useSetIdentityVerifier = adminTransaction( + (signer: Signer, identity_verifier: string) => + shareTokenWriter(signer).then((token) => + token.set_identity_verifier({ + identity_verifier, + operator: signer.publicKey, + }), + ), + [configurationReadsKey, adminAuthorityKey], +) diff --git a/app/src/hooks/useCycleState.ts b/app/src/hooks/useCycleState.ts index 677d51a..30a28d0 100644 --- a/app/src/hooks/useCycleState.ts +++ b/app/src/hooks/useCycleState.ts @@ -207,7 +207,7 @@ export function toEpoch( } } -type CycleReads = { +export type CycleReads = { ledgerTime: bigint | null notice: ContractRead freeReserve: Amount | null @@ -262,6 +262,16 @@ async function fetchCustodianBalance( return toAmount(balance) } +export function useCycleReads(active: boolean): CycleReads | undefined { + const { data } = useQuery({ + queryKey: cycleReadsKey, + queryFn: fetchCycleReads, + staleTime: STALE_MS, + refetchInterval: active ? REFRESH_MS : false, + }) + return data +} + export function useCycleState({ active = true }: { active?: boolean } = {}): { cycle: CycleState } { @@ -269,12 +279,7 @@ export function useCycleState({ active = true }: { active?: boolean } = {}): { const { figures, isPending: figuresPending } = useVaultFigures() const { pause } = useVaultPaused() const { authorities, isPending: authoritiesPending } = useVaultAuthorities() - const { data: reads } = useQuery({ - queryKey: cycleReadsKey, - queryFn: fetchCycleReads, - staleTime: STALE_MS, - refetchInterval: active ? REFRESH_MS : false, - }) + const reads = useCycleReads(active) const custodian = authorities?.custodian ?? null const { data: custodianBalance, isPending: balancePending } = useQuery({ queryKey: [...custodianBalanceKey, custodian], diff --git a/app/src/hooks/useEmergencyTransactions.ts b/app/src/hooks/useEmergencyTransactions.ts index c3c7ff0..d78e1a2 100644 --- a/app/src/hooks/useEmergencyTransactions.ts +++ b/app/src/hooks/useEmergencyTransactions.ts @@ -1,31 +1,14 @@ -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 { adminTransaction } from "./useAdminTransaction" 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 }), @@ -47,27 +30,24 @@ const setRipcordCall = (signer: Signer, paused: boolean) => oracle.set_ripcord({ paused, caller: signer.publicKey }), ) -export const usePauseVault = emergencyTransaction(vaultCall("pause"), [ +export const usePauseVault = adminTransaction(vaultCall("pause"), [ vaultPausedKey, ]) -export const useUnpauseVault = emergencyTransaction(vaultCall("unpause"), [ +export const useUnpauseVault = adminTransaction(vaultCall("unpause"), [ vaultPausedKey, ]) -export const usePauseToken = emergencyTransaction(tokenCall("pause"), [ +export const usePauseToken = adminTransaction(tokenCall("pause"), [ tokenPausedKey, ]) -export const useUnpauseToken = emergencyTransaction(tokenCall("unpause"), [ +export const useUnpauseToken = adminTransaction(tokenCall("unpause"), [ tokenPausedKey, ]) -export const useRaiseRipcord = emergencyTransaction( +export const useRaiseRipcord = adminTransaction( oracleCall("raise_ripcord"), oracleWriteKeys, ) -export const useClearLatest = emergencyTransaction( +export const useClearLatest = adminTransaction( oracleCall("clear_latest"), oracleWriteKeys, ) -export const useSetRipcord = emergencyTransaction( - setRipcordCall, - oracleWriteKeys, -) +export const useSetRipcord = adminTransaction(setRipcordCall, oracleWriteKeys) diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx index d1d2936..6b9cd69 100644 --- a/app/src/pages/AdminPanel.test.tsx +++ b/app/src/pages/AdminPanel.test.tsx @@ -61,6 +61,25 @@ vi.mock("../components/admin/EmergencySection", () => ({ ), })) +vi.mock("../components/admin/ConfigurationSection", () => ({ + default: ({ + grants, + contracts, + active, + }: { + grants: Grant[] + contracts: React.ReactNode + active: boolean + }) => ( +
    +

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

    + {contracts} +
    + ), +})) + const connectedAddress = "GCONNECTEDADDRESS1234567890" const emptyGrantedBy = { @@ -256,6 +275,34 @@ describe("AdminPanel", () => { ).toBe("false") }) + it("renders the configuration actions with the configuration grants, active only on its tab", () => { + const guardian = holderOf("vault guardian") + const oracleAdmin = holderOf("oracle admin") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["emergency", "configuration"]), + grantedBy: { + ...emptyGrantedBy, + emergency: [guardian, oracleAdmin], + configuration: [oracleAdmin], + }, + grants: [guardian, oracleAdmin], + }), + ) + renderAt("/admin/configuration") + + expect( + screen.getByText("Configuration surface for oracle admin"), + ).toBeTruthy() + expect( + screen.getByTestId("configuration-surface").getAttribute("data-active"), + ).toBe("true") + expect( + screen.getByTestId("emergency-surface").getAttribute("data-active"), + ).toBe("false") + }) + it("opens the first held surface at /admin", () => { const guardian = holderOf("vault guardian") const governance = holderOf("vault governance") diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index 4a51cbe..bd11320 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -1,6 +1,7 @@ import { shortAddress } from "@stellar-scaffold/app-lib" import React, { useEffect, useRef } from "react" import { Navigate, useParams } from "react-router-dom" +import ConfigurationSection from "../components/admin/ConfigurationSection" import CycleSection from "../components/admin/CycleSection" import Disclosure from "../components/admin/Disclosure" import EmergencySection from "../components/admin/EmergencySection" @@ -86,8 +87,16 @@ const SurfaceBody: React.FC<{ contracts={contracts} /> ) - case "compliance": case "configuration": + return ( + + ) + case "compliance": case "governance": return contracts } diff --git a/app/src/pages/actions.test.ts b/app/src/pages/actions.test.ts index 5f7b9e9..03eb562 100644 --- a/app/src/pages/actions.test.ts +++ b/app/src/pages/actions.test.ts @@ -5,6 +5,8 @@ import { type RailAction, readiness, readyCount, + type Spec, + toAction, unavailableFor, } from "./actions" @@ -106,3 +108,76 @@ describe("unavailableFor", () => { ) }) }) + +describe("toAction", () => { + const spec = ( + overrides: Partial>, + ): Spec<"set-notice"> => ({ + id: "set-notice", + label: "Set the notice", + conditions: [{ label: "Notice ≤ 30d", met: true }], + outcome: null, + ...overrides, + }) + + it("enables a spec the wallet signs alone when no condition is refused", () => { + const action = toAction( + spec({ conditions: [{ label: "Vault is open", met: null }] }), + grant({}), + wallet, + ) + + expect(action.enabled).toBe(true) + expect(action.unavailable).toBeUndefined() + }) + + it("keeps a spec that is not ready closed, even with every condition met", () => { + expect(toAction(spec({ ready: false }), grant({}), wallet).enabled).toBe( + false, + ) + }) + + it("keeps a refused condition closing the action", () => { + const action = toAction( + spec({ conditions: [{ label: "Notice ≤ 30d", met: false }] }), + grant({}), + wallet, + ) + + expect(action.enabled).toBe(false) + }) + + it("carries the fields, the note and the danger tone", () => { + const field = { + key: "notice", + label: "Notice", + placeholder: "7d", + value: "3d", + invalid: false, + } + const action = toAction( + spec({ fields: [field], note: "Heads up.", danger: true }), + grant({}), + wallet, + ) + + expect(action).toMatchObject({ + fields: [field], + note: "Heads up.", + tone: "danger", + }) + }) + + it("explains a grant the wallet cannot sign alone and keeps it closed", () => { + const action = toAction( + spec({}), + grant({ authority: multisig, standing: { needs: 2 } }), + wallet, + ) + + expect(action.enabled).toBe(false) + expect(action.unavailable).toBe( + "Needs 2 signatures; collecting them is not supported yet.", + ) + }) +}) diff --git a/app/src/pages/actions.ts b/app/src/pages/actions.ts index 9909037..66cf941 100644 --- a/app/src/pages/actions.ts +++ b/app/src/pages/actions.ts @@ -1,14 +1,26 @@ import { shortAddress } from "@stellar-scaffold/app-lib" import { type Grant, type Standing } from "../hooks/useAdminAuthority" +export const ROLES = { + vaultGuardian: "vault guardian", + governance: "vault governance", + oracleGuardian: "oracle guardian", + oracleAdmin: "oracle admin", + tokenAdmin: "share-token admin", +} as const + export type Condition = { label: string; met: boolean | null; detail?: string } export type ActionField = { + key?: string label: string placeholder: string value: string invalid: boolean max?: string + text?: boolean + current?: string + next?: string | null } export type RailAction = { @@ -18,6 +30,7 @@ export type RailAction = { outcome: string | null note?: string field?: ActionField + fields?: ActionField[] enabled: boolean unavailable?: string tone?: "danger" @@ -70,3 +83,36 @@ export const unavailableFor = ( return `Signs for ${shortAddress(grant.authority)}; acting on its behalf is not supported yet.` return undefined } + +export type Spec = { + id: Id + label: string + conditions: Condition[] + outcome: string | null + note?: string + danger?: boolean + fields?: ActionField[] + ready?: boolean +} + +export 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 }), + ...(spec.fields === undefined ? {} : { fields: spec.fields }), + enabled: + unavailable === undefined && + spec.ready !== false && + spec.conditions.every((condition) => condition.met !== false), + ...(spec.danger === true ? { tone: "danger" as const } : {}), + ...(unavailable === undefined ? {} : { unavailable }), + } +} diff --git a/app/src/pages/configuration.test.ts b/app/src/pages/configuration.test.ts new file mode 100644 index 0000000..4b0bd69 --- /dev/null +++ b/app/src/pages/configuration.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it } from "vitest" +import { type Grant } from "../hooks/useAdminAuthority" +import { type ConfigurationState } from "../hooks/useConfigurationState" +import { + type ConfigurationAction, + type ConfigurationInputs, + toConfigurationActions, + toConfigurationGroups, +} from "./configuration" + +const WALLET = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H" +const ACCOUNT = "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA" +const CONTRACT = "CABQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGCK3" +const CUSTODIAN = "CACAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAINCW" +const COMPLIANCE = "CACQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQKBIFAUCQLC2U" +const VERIFIER = "CADAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMSST" +const DAY = 86_400n +const PRICE_ONE = 10n ** 18n + +const grantOf = (role: string, overrides: Partial = {}): Grant => ({ + role, + authority: WALLET, + standing: "signs-alone", + ...overrides, +}) + +const governance = grantOf("vault governance") +const oracleAdmin = grantOf("oracle admin") +const tokenAdmin = grantOf("share-token admin") + +const ready = ( + overrides: Partial> = {}, +): ConfigurationState => ({ + status: "ready", + depositCap: { kind: "capped", amount: 1_000_0000000n as never }, + custodian: CUSTODIAN, + notice: DAY, + windDownDelay: 7n * DAY, + upgradeDelay: 3n * DAY, + oracle: { + freshness: DAY, + cooldown: 3_600n, + maxUpBps: 1_000, + maxDownBps: null, + min: PRICE_ONE / 2n, + max: 2n * PRICE_ONE, + }, + compliance: COMPLIANCE, + identityVerifier: VERIFIER, + ...overrides, +}) + +const actionsFor = ( + grants: Grant[], + inputs: ConfigurationInputs = {}, + state: ConfigurationState = ready(), +) => toConfigurationActions(state, grants, WALLET, inputs) + +const find = ( + id: Id, + grants: Grant[], + inputs: ConfigurationInputs = {}, + state?: ConfigurationState, +) => { + const action = actionsFor(grants, inputs, state).find( + (candidate) => candidate.id === id, + ) + if (action === undefined) throw new Error(`no ${id} action`) + return action as Extract +} + +const condition = (action: ConfigurationAction, label: string) => + action.conditions.find((candidate) => candidate.label === label)?.met + +describe("toConfigurationActions", () => { + it("offers nothing without a grant or before the parameters are read", () => { + expect(actionsFor([])).toEqual([]) + expect(actionsFor([governance], {}, { status: "checking" })).toEqual([]) + }) + + it.each<[Grant, ConfigurationAction["id"][]]>([ + [ + governance, + ["set-deposit-cap", "set-custodian", "set-notice", "set-wind-down-delay"], + ], + [oracleAdmin, ["set-oracle-config"]], + [tokenAdmin, ["set-compliance", "set-identity-verifier"]], + ])("offers %o the setters its role guards", (grant, ids) => { + expect(actionsFor([grant]).map((action) => action.id)).toEqual(ids) + }) + + it("prefills every field with the current value and waits for a change", () => { + const notice = find("set-notice", [governance]) + + expect(notice.fields).toEqual([ + { + key: "notice", + label: "Notice", + placeholder: "7d", + value: "1d", + invalid: false, + text: true, + current: "1d", + next: "1d", + }, + ]) + expect(condition(notice, "Differs from current")).toBe(false) + expect(notice.enabled).toBe(false) + }) + + it("enables a notice that parses, changes and stays within both bounds", () => { + const notice = find("set-notice", [governance], { notice: "2d 12h" }) + + expect(notice.enabled).toBe(true) + expect(notice.arg).toBe(216_000n) + expect(notice.next).toBe("2d 12h") + expect(notice.outcome).toBe("Applies to epochs closed from now on.") + }) + + it("names the bound a notice breaks", () => { + const aboveUpgrade = find("set-notice", [governance], { notice: "4d" }) + const aboveCap = find( + "set-notice", + [governance], + { notice: "31d" }, + ready({ upgradeDelay: 60n * DAY }), + ) + + expect(condition(aboveUpgrade, "Notice ≤ upgrade delay 3d")).toBe(false) + expect(condition(aboveUpgrade, "Notice ≤ 30d")).toBe(true) + expect(aboveUpgrade.enabled).toBe(false) + expect(condition(aboveCap, "Notice ≤ 30d")).toBe(false) + }) + + it("holds the notice while the upgrade delay is unknown", () => { + const notice = find( + "set-notice", + [governance], + { notice: "2d" }, + ready({ upgradeDelay: null }), + ) + + expect(condition(notice, "Notice ≤ upgrade delay")).toBeNull() + expect(notice.enabled).toBe(false) + }) + + it("flags a duration it cannot read and holds the action", () => { + const notice = find("set-notice", [governance], { notice: "soon" }) + + expect(notice.fields?.[0]).toMatchObject({ invalid: true, next: null }) + expect(notice.enabled).toBe(false) + }) + + it("caps the wind-down delay at 90 days and says what it leaves alone", () => { + const over = find("set-wind-down-delay", [governance], { + windDownDelay: "91d", + }) + const within = find("set-wind-down-delay", [governance], { + windDownDelay: "90d", + }) + + expect(condition(over, "Wind-down delay ≤ 90d")).toBe(false) + expect(within.enabled).toBe(true) + expect(within.arg).toBe(90n * DAY) + expect(within.outcome).toBe( + "A proposed wind-down keeps its activation time.", + ) + }) + + it("reads an empty deposit cap as no cap", () => { + const cap = find("set-deposit-cap", [governance], { depositCap: "" }) + + expect(cap.enabled).toBe(true) + expect(cap.arg).toBeNull() + expect(cap.next).toBe("No cap") + expect(cap.fields?.[0]).toMatchObject({ + current: "1,000.00", + next: "No cap", + }) + }) + + it("refuses a negative deposit cap and waits for a change when uncapped", () => { + const negative = find("set-deposit-cap", [governance], { depositCap: "-1" }) + const uncapped = find( + "set-deposit-cap", + [governance], + {}, + ready({ depositCap: { kind: "uncapped" } }), + ) + + expect(condition(negative, "Deposit cap ≥ 0")).toBe(false) + expect(negative.enabled).toBe(false) + expect(uncapped.fields?.[0]?.value).toBe("") + expect(uncapped.enabled).toBe(false) + }) + + it("takes an account or a contract as custodian and says where deploys go", () => { + for (const address of [ACCOUNT, CONTRACT]) { + const custodian = find("set-custodian", [governance], { + custodian: address, + }) + expect(custodian.enabled).toBe(true) + expect(custodian.arg).toBe(address) + } + const custodian = find("set-custodian", [governance], { + custodian: ACCOUNT, + }) + expect(custodian.outcome).toBe( + "Treasury deploys to the new address from now on.", + ) + }) + + it("flags an address that is not a valid key", () => { + const custodian = find("set-custodian", [governance], { + custodian: "GNOPE", + }) + + expect(custodian.fields?.[0]?.invalid).toBe(true) + expect(custodian.enabled).toBe(false) + }) + + it("takes only a contract as the compliance contract or identity verifier", () => { + expect( + find("set-compliance", [tokenAdmin], { compliance: ACCOUNT }).enabled, + ).toBe(false) + expect( + find("set-compliance", [tokenAdmin], { compliance: CONTRACT }).arg, + ).toBe(CONTRACT) + expect( + find("set-identity-verifier", [tokenAdmin], { identityVerifier: ACCOUNT }) + .enabled, + ).toBe(false) + expect( + find("set-identity-verifier", [tokenAdmin], { + identityVerifier: CONTRACT, + }).enabled, + ).toBe(true) + }) + + it("prefills every oracle limit and sends them all when one changes", () => { + const config = find("set-oracle-config", [oracleAdmin], { maxRise: "5" }) + + expect(config.fields?.map((field) => [field.label, field.value])).toEqual([ + ["Freshness window", "1d"], + ["Cooldown", "1h"], + ["Largest rise", "5"], + ["Largest fall", ""], + ["Price floor", "0.50"], + ["Price ceiling", "2.00"], + ]) + expect(config.enabled).toBe(true) + expect(config.arg).toEqual({ + freshness_duration: DAY, + cooldown_secs: 3_600n, + max_up_bps: 500, + max_down_bps: null, + min_answer: PRICE_ONE / 2n, + max_answer: 2n * PRICE_ONE, + }) + }) + + it.each<[ConfigurationInputs, string]>([ + [{ freshness: "0" }, "Freshness window > 0"], + [{ maxRise: "0" }, "Largest rise > 0"], + [{ maxRise: "100.01" }, "Largest rise ≤ 100%"], + [{ maxFall: "101%" }, "Largest fall ≤ 100%"], + [{ floor: "0" }, "Price floor > 0"], + [{ ceiling: "0.4" }, "Price ceiling ≥ floor"], + [ + { ceiling: "17014118346046924" }, + "Price ceiling ≤ 17,014,118,346,046,923", + ], + ])("names the oracle limit %o breaks", (inputs, label) => { + const config = find("set-oracle-config", [oracleAdmin], inputs) + + expect(condition(config, label)).toBe(false) + expect(config.enabled).toBe(false) + }) + + it("sets a largest fall in basis points", () => { + const config = find("set-oracle-config", [oracleAdmin], { + maxFall: "12.5%", + }) + + expect(config.arg?.max_down_bps).toBe(1_250) + expect(config.fields?.[3]?.next).toBe("12.5%") + }) + + it("explains why a grant the wallet cannot sign alone leaves an action unavailable", () => { + const multisig = grantOf("oracle admin", { + authority: ACCOUNT, + standing: { needs: 2 }, + }) + const config = find("set-oracle-config", [multisig], { maxRise: "5" }) + + expect(config.enabled).toBe(false) + expect(config.unavailable).toBe( + "Needs 2 signatures; collecting them is not supported yet.", + ) + }) +}) + +describe("toConfigurationGroups", () => { + it("groups the current parameters by contract", () => { + const groups = toConfigurationGroups(ready()) + + expect(groups.map((group) => group.title)).toEqual([ + "Vault", + "Oracle", + "Share token", + ]) + expect(groups[0]?.strips).toEqual([ + [ + { label: "Deposit cap", value: "1,000.00" }, + { label: "Notice", value: "1d" }, + { label: "Wind-down delay", value: "7d" }, + { label: "Upgrade delay", value: "3d" }, + ], + ]) + expect(groups[0]?.addresses).toEqual([ + { label: "Custodian", address: CUSTODIAN }, + ]) + expect( + groups[1]?.strips.map((strip) => strip.map((tile) => tile.value)), + ).toEqual([ + ["1d", "1h", "10%", "No limit"], + ["0.5000", "2.0000"], + ]) + expect(groups[2]?.strips).toEqual([]) + expect(groups[2]?.addresses).toEqual([ + { label: "Compliance contract", address: COMPLIANCE }, + { label: "Identity verifier", address: VERIFIER }, + ]) + }) + + it("shows every figure pending while the parameters are read", () => { + const groups = toConfigurationGroups({ status: "checking" }) + + for (const group of groups) { + for (const tile of group.strips.flat()) expect(tile.pending).toBe(true) + for (const row of group.addresses) expect(row.pending).toBe(true) + } + }) +}) diff --git a/app/src/pages/configuration.ts b/app/src/pages/configuration.ts new file mode 100644 index 0000000..498ae96 --- /dev/null +++ b/app/src/pages/configuration.ts @@ -0,0 +1,543 @@ +import { StrKey } from "@stellar/stellar-sdk" +import { + AMOUNT_DECIMALS, + type Amount, + formatExact, + formatScaled, + parseUnits, + PRICE_DECIMALS, + type Price, + shortAddress, +} from "@stellar-scaffold/app-lib" +import { type AddressRow } from "../components/vault/AddressRows" +import { type Metric } from "../components/vault/MetricsStrip" +import { type Grant } from "../hooks/useAdminAuthority" +import { + type ConfigurationState, + type ConfigurationValues, +} from "../hooks/useConfigurationState" +import { type OracleConfig } from "../hooks/useConfigurationTransactions" +import { type OracleLimits } from "../hooks/useCycleState" +import { + type ActionField, + allMet, + type Condition, + grantFor, + type RailAction, + ROLES, + type Spec, + toAction, +} from "./actions" +import { + depositCapValue, + exactAmount, + formatDuration, + formatPercent, + oracleLimitRows, + parseDuration, +} from "./cycle" + +export type FieldKey = + | "depositCap" + | "custodian" + | "notice" + | "windDownDelay" + | "freshness" + | "cooldown" + | "maxRise" + | "maxFall" + | "floor" + | "ceiling" + | "compliance" + | "identityVerifier" + +export type ConfigurationInputs = Partial> + +type Setter = Id extends string + ? RailAction & { id: Id; arg: Arg | undefined; next: string } + : never + +export type ConfigurationAction = + | Setter<"set-deposit-cap", bigint | null> + | Setter<"set-custodian" | "set-compliance" | "set-identity-verifier", string> + | Setter<"set-notice" | "set-wind-down-delay", bigint> + | Setter<"set-oracle-config", OracleConfig> + +export type ConfigurationGroup = { + title: string + strips: Metric[][] + addresses: AddressRow[] +} + +export const EFFECTS = { + custodian: "Treasury deploys to the new address from now on.", + notice: "Applies to epochs closed from now on.", + windDownDelay: "A proposed wind-down keeps its activation time.", +} as const + +const DAY = 86_400n +const MAX_NOTICE = 30n * DAY +const MAX_WIND_DOWN_DELAY = 90n * DAY +const MAX_BPS = 10_000 +const MAX_CEILING = ((1n << 127n) - 1n) / 10_000n +const PERCENT = /^(\d+)(?:\.(\d{1,2}))?\s*%?$/ + +type Kind = { + parse: (text: string) => T | undefined + show: (value: T) => string + prefill?: (value: T) => string + placeholder: string + text?: boolean +} + +const parseBps = (text: string): number | undefined => { + const match = PERCENT.exec(text.trim()) + if (match === null) return undefined + const [, whole = "", fraction = ""] = match + return Number(whole) * 100 + Number(fraction.padEnd(2, "0")) +} + +const orNone = + (parse: (text: string) => T | undefined) => + (text: string): T | null | undefined => + text.trim() === "" ? null : parse(text) + +const DURATION: Kind = { + parse: (text) => parseDuration(text) ?? undefined, + show: (seconds) => formatDuration(seconds, 4), + placeholder: "7d", + text: true, +} + +const address = (accepts: (text: string) => boolean): Kind => ({ + parse: (text) => (accepts(text.trim()) ? text.trim() : undefined), + show: shortAddress, + prefill: (value) => value, + placeholder: "Address", + text: true, +}) + +const ANY_ADDRESS = address( + (text) => + StrKey.isValidEd25519PublicKey(text) || StrKey.isValidContract(text), +) +const CONTRACT = address((text) => StrKey.isValidContract(text)) + +const CAP: Kind = { + parse: orNone((text) => parseUnits(text, AMOUNT_DECIMALS) ?? undefined), + show: (cap) => (cap === null ? "No cap" : exactAmount(cap as Amount)), + prefill: (cap) => (cap === null ? "" : exactAmount(cap as Amount)), + placeholder: "No cap", +} + +const PRICE: Kind = { + parse: (text) => parseUnits(text, PRICE_DECIMALS) ?? undefined, + show: (price) => formatExact(price as Price, PRICE_DECIMALS), + placeholder: "0.00", +} + +const RISE: Kind = { + parse: parseBps, + show: formatPercent, + prefill: (bps) => String(bps / 100), + placeholder: "10", +} + +const FALL: Kind = { + parse: orNone(parseBps), + show: (bps) => (bps === null ? "No limit" : formatPercent(bps)), + prefill: (bps) => (bps === null ? "" : String(bps / 100)), + placeholder: "No limit", +} + +type Parsed = { arg: T | undefined; field: ActionField } + +function parsed( + key: FieldKey, + label: string, + kind: Kind, + current: T | undefined, + inputs: ConfigurationInputs, +): Parsed { + const prefill = kind.prefill ?? kind.show + const input = inputs[key] ?? (current === undefined ? "" : prefill(current)) + const arg = kind.parse(input) + return { + arg, + field: { + key, + label, + placeholder: kind.placeholder, + value: input, + invalid: input.trim() !== "" && arg === undefined, + ...(kind.text === true ? { text: true } : {}), + current: current === undefined ? "—" : kind.show(current), + next: arg === undefined ? null : kind.show(arg), + }, + } +} + +const known = (value: T | null): T | undefined => value ?? undefined + +const bound = ( + label: string, + value: T | undefined, + holds: (value: T) => boolean, +): Condition => ({ label, met: value === undefined ? null : holds(value) }) + +const differs = (changed: boolean | undefined): Condition => ({ + label: "Differs from current", + met: changed ?? null, +}) + +const changedFrom = (arg: T | undefined, current: T | undefined) => + arg === undefined || current === undefined ? undefined : arg !== current + +function single( + id: Id, + label: string, + field: Parsed, + current: T | undefined, + bounds: Condition[], + outcome: string | null = null, +): Spec & { arg: T | undefined; next: string } { + const conditions = [...bounds, differs(changedFrom(field.arg, current))] + return { + id, + label, + conditions, + outcome, + fields: [field.field], + ready: allMet(conditions), + arg: field.arg, + next: field.field.next ?? "", + } +} + +const capOf = (state: ConfigurationValues): bigint | null | undefined => { + if (state.depositCap === null) return undefined + return state.depositCap.kind === "uncapped" ? null : state.depositCap.amount +} + +const upgradeBound = ( + notice: bigint | undefined, + upgradeDelay: bigint | null, +): Condition => ({ + label: + upgradeDelay === null + ? "Notice ≤ upgrade delay" + : `Notice ≤ upgrade delay ${formatDuration(upgradeDelay)}`, + met: + notice === undefined || upgradeDelay === null + ? null + : notice <= upgradeDelay, +}) + +function vaultSpecs(state: ConfigurationValues, inputs: ConfigurationInputs) { + const cap = capOf(state) + const capField = parsed("depositCap", "Deposit cap", CAP, cap, inputs) + const custodian = known(state.custodian) + const notice = known(state.notice) + const noticeField = parsed("notice", "Notice", DURATION, notice, inputs) + const delay = known(state.windDownDelay) + const delayField = parsed( + "windDownDelay", + "Wind-down delay", + DURATION, + delay, + inputs, + ) + return [ + single("set-deposit-cap", "Set the deposit cap", capField, cap, [ + bound("Deposit cap ≥ 0", capField.arg, (value) => (value ?? 0n) >= 0n), + ]), + single( + "set-custodian", + "Set the custodian", + parsed("custodian", "Custodian", ANY_ADDRESS, custodian, inputs), + custodian, + [], + EFFECTS.custodian, + ), + single( + "set-notice", + "Set the notice", + noticeField, + notice, + [ + bound("Notice ≤ 30d", noticeField.arg, (value) => value <= MAX_NOTICE), + upgradeBound(noticeField.arg, state.upgradeDelay), + ], + EFFECTS.notice, + ), + single( + "set-wind-down-delay", + "Set the wind-down delay", + delayField, + delay, + [ + bound( + "Wind-down delay ≤ 90d", + delayField.arg, + (value) => value <= MAX_WIND_DOWN_DELAY, + ), + ], + EFFECTS.windDownDelay, + ), + ] as const +} + +function tokenSpecs(state: ConfigurationValues, inputs: ConfigurationInputs) { + const compliance = known(state.compliance) + const verifier = known(state.identityVerifier) + return [ + single( + "set-compliance", + "Set the compliance contract", + parsed("compliance", "Compliance contract", CONTRACT, compliance, inputs), + compliance, + [], + ), + single( + "set-identity-verifier", + "Set the identity verifier", + parsed( + "identityVerifier", + "Identity verifier", + CONTRACT, + verifier, + inputs, + ), + verifier, + [], + ), + ] as const +} + +const toConfig = (limits: OracleLimits): OracleConfig => ({ + freshness_duration: limits.freshness, + cooldown_secs: limits.cooldown, + max_up_bps: limits.maxUpBps, + max_down_bps: limits.maxDownBps, + min_answer: limits.min, + max_answer: limits.max, +}) + +const sameConfig = (a: OracleConfig, b: OracleConfig): boolean => + (Object.keys(a) as (keyof OracleConfig)[]).every((key) => a[key] === b[key]) + +type ParsedConfig = { [K in keyof OracleConfig]: Parsed } + +function oracleFields( + current: OracleConfig | undefined, + inputs: ConfigurationInputs, +): ParsedConfig { + const field = ( + configKey: K, + key: FieldKey, + label: string, + kind: Kind, + ) => + parsed( + key, + label, + kind, + current === undefined ? undefined : current[configKey], + inputs, + ) + return { + freshness_duration: field( + "freshness_duration", + "freshness", + "Freshness window", + DURATION, + ), + cooldown_secs: field("cooldown_secs", "cooldown", "Cooldown", DURATION), + max_up_bps: field("max_up_bps", "maxRise", "Largest rise", RISE), + max_down_bps: field("max_down_bps", "maxFall", "Largest fall", FALL), + min_answer: field("min_answer", "floor", "Price floor", PRICE), + max_answer: field("max_answer", "ceiling", "Price ceiling", PRICE), + } +} + +const configOf = ({ + freshness_duration: freshness, + cooldown_secs: cooldown, + max_up_bps: rise, + max_down_bps: fall, + min_answer: floor, + max_answer: ceiling, +}: ParsedConfig): OracleConfig | undefined => + freshness.arg === undefined || + cooldown.arg === undefined || + rise.arg === undefined || + fall.arg === undefined || + floor.arg === undefined || + ceiling.arg === undefined + ? undefined + : { + freshness_duration: freshness.arg, + cooldown_secs: cooldown.arg, + max_up_bps: rise.arg, + max_down_bps: fall.arg, + min_answer: floor.arg, + max_answer: ceiling.arg, + } + +const oracleBounds = (fields: ParsedConfig): Condition[] => { + const floor = fields.min_answer.arg + const ceiling = fields.max_answer.arg + return [ + bound( + "Freshness window > 0", + fields.freshness_duration.arg, + (value) => value > 0n, + ), + bound("Largest rise > 0", fields.max_up_bps.arg, (value) => value > 0), + bound( + "Largest rise ≤ 100%", + fields.max_up_bps.arg, + (value) => value <= MAX_BPS, + ), + bound( + "Largest fall ≤ 100%", + fields.max_down_bps.arg, + (value) => value === null || value <= MAX_BPS, + ), + bound("Price floor > 0", floor, (value) => value > 0n), + { + label: "Price ceiling ≥ floor", + met: + floor === undefined || ceiling === undefined ? null : ceiling >= floor, + }, + bound( + `Price ceiling ≤ ${formatScaled(MAX_CEILING as Price, PRICE_DECIMALS, 0)}`, + ceiling, + (value) => value <= MAX_CEILING, + ), + ] +} + +function oracleSpec( + state: ConfigurationValues, + inputs: ConfigurationInputs, +): Spec<"set-oracle-config"> & { arg: OracleConfig | undefined; next: string } { + const current = state.oracle === null ? undefined : toConfig(state.oracle) + const fields = oracleFields(current, inputs) + const arg = configOf(fields) + const changed = + arg === undefined || current === undefined + ? undefined + : !sameConfig(arg, current) + const conditions = [...oracleBounds(fields), differs(changed)] + return { + id: "set-oracle-config", + label: "Set the oracle limits", + conditions, + outcome: null, + fields: Object.values(fields).map((field) => field.field), + ready: allMet(conditions), + arg, + next: "", + } +} + +export function toConfigurationActions( + state: ConfigurationState, + grants: Grant[], + wallet: string, + inputs: ConfigurationInputs = {}, +): ConfigurationAction[] { + if (state.status !== "ready" || grants.length === 0) return [] + const build = ( + spec: Spec & { arg: T | undefined; next: string }, + grant: Grant, + ) => ({ ...toAction(spec, grant, wallet), arg: spec.arg, next: spec.next }) + + const actions: ConfigurationAction[] = [] + const governance = grantFor(grants, ROLES.governance, wallet) + if (governance !== undefined) { + const [cap, custodian, notice, delay] = vaultSpecs(state, inputs) + actions.push( + build(cap, governance), + build(custodian, governance), + build(notice, governance), + build(delay, governance), + ) + } + const oracleAdmin = grantFor(grants, ROLES.oracleAdmin, wallet) + if (oracleAdmin !== undefined) + actions.push(build(oracleSpec(state, inputs), oracleAdmin)) + const tokenAdmin = grantFor(grants, ROLES.tokenAdmin, wallet) + if (tokenAdmin !== undefined) { + const [compliance, verifier] = tokenSpecs(state, inputs) + actions.push(build(compliance, tokenAdmin), build(verifier, tokenAdmin)) + } + return actions +} + +const tile = (label: string, value: string | null, pending: boolean): Metric => + pending ? { label, value: null, pending } : { label, value } + +const addressRow = ( + label: string, + value: string | null, + pending: boolean, +): AddressRow => + pending ? { label, address: null, pending } : { label, address: value } + +const duration = (seconds: bigint | null): string | null => + seconds === null ? null : formatDuration(seconds) + +export function toConfigurationGroups( + state: ConfigurationState, +): ConfigurationGroup[] { + const pending = state.status !== "ready" + const values = state.status === "ready" ? state : null + const limits = oracleLimitRows(values?.oracle ?? null).map((row) => + tile(row.label, row.value, pending), + ) + return [ + { + title: "Vault", + strips: [ + [ + tile( + "Deposit cap", + depositCapValue(values?.depositCap ?? null), + pending, + ), + tile("Notice", duration(values?.notice ?? null), pending), + tile( + "Wind-down delay", + duration(values?.windDownDelay ?? null), + pending, + ), + tile( + "Upgrade delay", + duration(values?.upgradeDelay ?? null), + pending, + ), + ], + ], + addresses: [addressRow("Custodian", values?.custodian ?? null, pending)], + }, + { + title: "Oracle", + strips: [limits.slice(0, 4), limits.slice(4)], + addresses: [], + }, + { + title: "Share token", + strips: [], + addresses: [ + addressRow("Compliance contract", values?.compliance ?? null, pending), + addressRow( + "Identity verifier", + values?.identityVerifier ?? null, + pending, + ), + ], + }, + ] +} diff --git a/app/src/pages/cycle.test.ts b/app/src/pages/cycle.test.ts index 0656512..997055c 100644 --- a/app/src/pages/cycle.test.ts +++ b/app/src/pages/cycle.test.ts @@ -28,6 +28,7 @@ import { type WindDownAction, formatDuration, formatTimestamp, + parseDuration, toActivityList, toCycleActions, toCycleRows, @@ -431,6 +432,36 @@ describe("formatDuration", () => { ])("formats %s seconds as %s", (seconds, expected) => { expect(formatDuration(seconds)).toBe(expected) }) + + it("keeps every unit when asked for an exact duration", () => { + expect(formatDuration(90_061n, 4)).toBe("1d 1h 1m 1s") + }) +}) + +describe("parseDuration", () => { + it.each<[string, bigint]>([ + ["3600", 3_600n], + [" 0 ", 0n], + ["7d", 604_800n], + ["1h 30m", 5_400n], + ["1d2h", 93_600n], + ["1d 1h 1m 1s", 90_061n], + ["2D", 172_800n], + ])("reads %j as %s seconds", (input, expected) => { + expect(parseDuration(input)).toBe(expected) + }) + + it.each(["", " ", "1.5h", "-60", "1w", "h", "1h h", "1h 1h"])( + "refuses %j", + (input) => { + expect(parseDuration(input)).toBeNull() + }, + ) + + it("reads back every duration it formats exactly", () => { + for (const seconds of [0n, 59n, 3_661n, 2_592_000n, 7_776_000n]) + expect(parseDuration(formatDuration(seconds, 4))).toBe(seconds) + }) }) describe("toEpochList", () => { diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index 534220a..2591c22 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -26,6 +26,7 @@ import { type CycleState, type CycleWindDown, type DepositCap, + type OracleLimits, type WindDownPhase, } from "../hooks/useCycleState" import { @@ -115,17 +116,31 @@ const SECONDS_PER_UNIT: [string, bigint][] = [ ["s", 1n], ] -export function formatDuration(seconds: bigint): string { +export function formatDuration(seconds: bigint, maxParts = 2): string { const parts: string[] = [] let remaining = seconds for (const [unit, size] of SECONDS_PER_UNIT) { const count = remaining / size remaining -= count * size - if (count > 0n && parts.length < 2) parts.push(`${count}${unit}`) + if (count > 0n && parts.length < maxParts) parts.push(`${count}${unit}`) } return parts.length === 0 ? "0s" : parts.join(" ") } +const SHORTHAND = /^(?:(\d+)d)?\s*(?:(\d+)h)?\s*(?:(\d+)m)?\s*(?:(\d+)s)?$/i + +export function parseDuration(input: string): bigint | null { + const text = input.trim() + if (/^\d+$/.test(text)) return BigInt(text) + const counts = SHORTHAND.exec(text)?.slice(1) + if (counts === undefined || counts.every((count) => count === undefined)) + return null + return SECONDS_PER_UNIT.reduce( + (total, [, size], index) => total + BigInt(counts[index] ?? 0) * size, + 0n, + ) +} + const formatClock = (seconds: bigint): string => new Intl.DateTimeFormat("en-US", { hour: "2-digit", @@ -144,13 +159,14 @@ export function formatTimestamp(seconds: bigint): string { const formatAmountValue = (raw: Amount | null): string | null => raw === null ? null : formatScaled(raw, AMOUNT_DECIMALS) -const formatPriceValue = (raw: bigint | null): string | null => +export const formatPriceValue = (raw: bigint | null): string | null => raw === null ? null : formatScaled(raw as Price, PRICE_DECIMALS, 4) export const formatSignedPrice = (raw: Price): string => formatExact(raw, PRICE_DECIMALS, 4) -const formatPercent = (basisPoints: number): string => `${basisPoints / 100}%` +export const formatPercent = (basisPoints: number): string => + `${basisPoints / 100}%` const orNotApplicable = ( value: T | null, @@ -227,6 +243,21 @@ function epochGroup(state: ReadyState): CycleGroup { } } +export const oracleLimitRows = (limits: OracleLimits | null): FigureRow[] => [ + row("Freshness window", limits && formatDuration(limits.freshness)), + row("Cooldown", limits && formatDuration(limits.cooldown)), + row("Largest rise", limits && formatPercent(limits.maxUpBps)), + row( + "Largest fall", + limits && + (limits.maxDownBps === null + ? "No limit" + : formatPercent(limits.maxDownBps)), + ), + row("Price floor", limits && formatPriceValue(limits.min)), + row("Price ceiling", limits && formatPriceValue(limits.max)), +] + function priceGroup(oracle: CycleOracle): CycleGroup { const { limits } = oracle const timestamp = (value: bigint | null): string | null => @@ -255,20 +286,7 @@ function priceGroup(oracle: CycleOracle): CycleGroup { : "Not raised", ), ], - limits: [ - row("Freshness window", limits && formatDuration(limits.freshness)), - row("Cooldown", limits && formatDuration(limits.cooldown)), - row("Largest rise", limits && formatPercent(limits.maxUpBps)), - row( - "Largest fall", - limits && - (limits.maxDownBps === null - ? "No limit" - : formatPercent(limits.maxDownBps)), - ), - row("Price floor", limits && formatPriceValue(limits.min)), - row("Price ceiling", limits && formatPriceValue(limits.max)), - ], + limits: oracleLimitRows(limits), } } @@ -279,7 +297,7 @@ const coverage = (uncovered: Amount | null): string | undefined => { : "Covered" } -const depositCapValue = (cap: DepositCap | null): string | null => { +export const depositCapValue = (cap: DepositCap | null): string | null => { if (cap === null) return null return cap.kind === "uncapped" ? "No cap" diff --git a/app/src/pages/emergency.ts b/app/src/pages/emergency.ts index 626a815..7d9793a 100644 --- a/app/src/pages/emergency.ts +++ b/app/src/pages/emergency.ts @@ -6,6 +6,9 @@ import { type Condition, grantFor, type RailAction, + ROLES, + type Spec, + toAction, unavailableFor, } from "./actions" @@ -26,14 +29,6 @@ type PlainId = 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 = { @@ -59,36 +54,7 @@ const ripcordRaised = (oracle: OracleStatus): Condition => ({ 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] => [ +const vaultSpecs = (vault: PauseState): [Spec, Spec] => [ { id: "pause-vault", label: "Pause the vault", @@ -106,7 +72,9 @@ const vaultSpecs = (vault: PauseState): [Spec, Spec] => [ }, ] -const oracleAdminSpecs = (oracle: OracleStatus): [Spec, Spec] => [ +const oracleAdminSpecs = ( + oracle: OracleStatus, +): [Spec, Spec] => [ { id: "lower-ripcord", label: "Lower the ripcord", @@ -124,7 +92,7 @@ const oracleAdminSpecs = (oracle: OracleStatus): [Spec, Spec] => [ }, ] -const tokenSpecs = (token: PauseState): [Spec, Spec] => [ +const tokenSpecs = (token: PauseState): [Spec, Spec] => [ { id: "pause-token", label: "Pause the share token", From 748c6dfcc7eaa7e1b771419c450c87c2b415d4f7 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 18:14:32 -0300 Subject: [PATCH 02/10] fix(app): retry a submission with a null argument --- app/src/hooks/usePendingTransaction.test.ts | 18 +++++++++++++++--- app/src/hooks/usePendingTransaction.ts | 6 +++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/src/hooks/usePendingTransaction.test.ts b/app/src/hooks/usePendingTransaction.test.ts index ed3afae..75162ec 100644 --- a/app/src/hooks/usePendingTransaction.test.ts +++ b/app/src/hooks/usePendingTransaction.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it, vi } from "vitest" import { type UseContractTransaction } from "./useContractTransaction" import { usePendingTransaction } from "./usePendingTransaction" -const makeTransaction = ( - submit: (epochId: bigint) => boolean, -): UseContractTransaction> => ({ +const makeTransaction = ( + submit: (arg: TArg) => boolean, +): UseContractTransaction> => ({ status: { status: "idle" }, submit, reset: vi.fn(), @@ -57,6 +57,18 @@ describe("usePendingTransaction", () => { expect(submit).toHaveBeenNthCalledWith(2, 5n) }) + it("retries a submission whose argument was null", () => { + const submit = vi.fn().mockReturnValue(true) + const { result } = renderHook(() => + usePendingTransaction(makeTransaction(submit)), + ) + + result.current.submit(null, "No cap") + result.current.retry() + + expect(submit).toHaveBeenNthCalledWith(2, null) + }) + it("does nothing on retry before any submission was accepted", () => { const submit = vi.fn().mockReturnValue(true) const { result } = renderHook(() => diff --git a/app/src/hooks/usePendingTransaction.ts b/app/src/hooks/usePendingTransaction.ts index 4d74586..a93fea0 100644 --- a/app/src/hooks/usePendingTransaction.ts +++ b/app/src/hooks/usePendingTransaction.ts @@ -17,12 +17,12 @@ export function usePendingTransaction( ): UsePendingTransaction { const { status, submit: submitTransaction, reset } = transaction const [amountLabel, setAmountLabel] = useState("") - const pendingArg = useRef(null) + const pendingArg = useRef<{ arg: TArg } | null>(null) const submit = useCallback( (arg: TArg, label: string): boolean => { if (!submitTransaction(arg)) return false - pendingArg.current = arg + pendingArg.current = { arg } setAmountLabel(label) return true }, @@ -31,7 +31,7 @@ export function usePendingTransaction( const retry = useCallback(() => { if (pendingArg.current === null) return - submitTransaction(pendingArg.current) + submitTransaction(pendingArg.current.arg) }, [submitTransaction]) return { status, amountLabel, submit, retry, reset } From e527d52a93fb0e34f7ef9af168c506221cd12c5b Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 18:15:06 -0300 Subject: [PATCH 03/10] fix(app): bound configuration values to contract width --- app/src/pages/configuration.test.ts | 34 +++++++++++++++++++++++++++++ app/src/pages/configuration.ts | 17 +++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/app/src/pages/configuration.test.ts b/app/src/pages/configuration.test.ts index 4b0bd69..bc5a5e5 100644 --- a/app/src/pages/configuration.test.ts +++ b/app/src/pages/configuration.test.ts @@ -194,6 +194,38 @@ describe("toConfigurationActions", () => { expect(uncapped.enabled).toBe(false) }) + it("holds a deposit cap and an oracle duration at their contract width", () => { + const cap = (text: string) => + find("set-deposit-cap", [governance], { depositCap: text }) + const oracle = (inputs: ConfigurationInputs) => + find("set-oracle-config", [oracleAdmin], inputs) + + expect( + condition( + cap("17014118346046923173168730371588.4105727"), + "Deposit cap ≤ 2^127−1", + ), + ).toBe(true) + expect( + condition( + cap("17014118346046923173168730371588.4105728"), + "Deposit cap ≤ 2^127−1", + ), + ).toBe(false) + expect( + condition( + oracle({ freshness: "18446744073709551615" }), + "Freshness window ≤ 2^64−1 s", + ), + ).toBe(true) + expect( + condition( + oracle({ cooldown: "18446744073709551615" }), + "Cooldown ≤ 2^64−1 s", + ), + ).toBe(true) + }) + it("takes an account or a contract as custodian and says where deploys go", () => { for (const address of [ACCOUNT, CONTRACT]) { const custodian = find("set-custodian", [governance], { @@ -261,6 +293,8 @@ describe("toConfigurationActions", () => { it.each<[ConfigurationInputs, string]>([ [{ freshness: "0" }, "Freshness window > 0"], + [{ freshness: "18446744073709551616" }, "Freshness window ≤ 2^64−1 s"], + [{ cooldown: "18446744073709551616" }, "Cooldown ≤ 2^64−1 s"], [{ maxRise: "0" }, "Largest rise > 0"], [{ maxRise: "100.01" }, "Largest rise ≤ 100%"], [{ maxFall: "101%" }, "Largest fall ≤ 100%"], diff --git a/app/src/pages/configuration.ts b/app/src/pages/configuration.ts index 498ae96..0b461ec 100644 --- a/app/src/pages/configuration.ts +++ b/app/src/pages/configuration.ts @@ -79,6 +79,8 @@ const DAY = 86_400n const MAX_NOTICE = 30n * DAY const MAX_WIND_DOWN_DELAY = 90n * DAY const MAX_BPS = 10_000 +const MAX_U64 = (1n << 64n) - 1n +const MAX_I128 = (1n << 127n) - 1n const MAX_CEILING = ((1n << 127n) - 1n) / 10_000n const PERCENT = /^(\d+)(?:\.(\d{1,2}))?\s*%?$/ @@ -250,6 +252,11 @@ function vaultSpecs(state: ConfigurationValues, inputs: ConfigurationInputs) { return [ single("set-deposit-cap", "Set the deposit cap", capField, cap, [ bound("Deposit cap ≥ 0", capField.arg, (value) => (value ?? 0n) >= 0n), + bound( + "Deposit cap ≤ 2^127−1", + capField.arg, + (value) => (value ?? 0n) <= MAX_I128, + ), ]), single( "set-custodian", @@ -393,6 +400,16 @@ const oracleBounds = (fields: ParsedConfig): Condition[] => { fields.freshness_duration.arg, (value) => value > 0n, ), + bound( + "Freshness window ≤ 2^64−1 s", + fields.freshness_duration.arg, + (value) => value <= MAX_U64, + ), + bound( + "Cooldown ≤ 2^64−1 s", + fields.cooldown_secs.arg, + (value) => value <= MAX_U64, + ), bound("Largest rise > 0", fields.max_up_bps.arg, (value) => value > 0), bound( "Largest rise ≤ 100%", From 165f2345568f303411e66ffb6b5800ec42e0646d Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 18:16:04 -0300 Subject: [PATCH 04/10] refactor(app): share the vault authorities key --- app/src/hooks/useAdminTransaction.test.ts | 4 ++-- app/src/hooks/useConfigurationTransactions.ts | 3 +-- app/src/hooks/useVaultAuthorities.ts | 4 +++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/hooks/useAdminTransaction.test.ts b/app/src/hooks/useAdminTransaction.test.ts index c3d0bd9..fbc008d 100644 --- a/app/src/hooks/useAdminTransaction.test.ts +++ b/app/src/hooks/useAdminTransaction.test.ts @@ -25,6 +25,7 @@ import { } from "./useEmergencyTransactions" import { oracleWriteKeys } from "./useOracleState" import { tokenPausedKey } from "./useTokenPaused" +import { vaultAuthoritiesKey } from "./useVaultAuthorities" import { vaultPausedKey } from "./useVaultPaused" const { vault, token, oracle } = vi.hoisted(() => ({ @@ -57,7 +58,6 @@ vi.mock("../config/clients", () => ({ })) const CONTRACT = "CCONTRACTADDRESS1234567890" -const VAULT_AUTHORITIES_KEY = ["vault", "authorities"] const ORACLE_CONFIG = { freshness_duration: 86_400n, cooldown_secs: 3_600n, @@ -159,7 +159,7 @@ const cases: Case[] = [ method: vault.set_custodian, arg: CONTRACT, args: { custodian: CONTRACT, caller: investorAddress }, - keys: [VAULT_AUTHORITIES_KEY], + keys: [vaultAuthoritiesKey], refusal: 2000, }, { diff --git a/app/src/hooks/useConfigurationTransactions.ts b/app/src/hooks/useConfigurationTransactions.ts index e511e4d..4ae2794 100644 --- a/app/src/hooks/useConfigurationTransactions.ts +++ b/app/src/hooks/useConfigurationTransactions.ts @@ -9,11 +9,10 @@ import { adminTransaction } from "./useAdminTransaction" import { configurationReadsKey } from "./useConfigurationState" import { cycleReadsKey } from "./useCycleState" import { oracleWriteKeys } from "./useOracleState" +import { vaultAuthoritiesKey } from "./useVaultAuthorities" export type OracleConfig = Parameters[0]["config"] -const vaultAuthoritiesKey = ["vault", "authorities"] as const - export const useSetDepositCap = adminTransaction( (signer: Signer, cap: bigint | null) => asyncVaultWriter(signer).then((vault) => diff --git a/app/src/hooks/useVaultAuthorities.ts b/app/src/hooks/useVaultAuthorities.ts index b0ede77..e563d8b 100644 --- a/app/src/hooks/useVaultAuthorities.ts +++ b/app/src/hooks/useVaultAuthorities.ts @@ -2,6 +2,8 @@ import { type ContractRead, readContract } from "@stellar-scaffold/app-lib" import { useQuery } from "@tanstack/react-query" import { asyncVault } from "../config/clients" +export const vaultAuthoritiesKey = ["vault", "authorities"] as const + export type AuthorityKey = "governance" | "manager" | "treasury" | "guardian" | "custodian" @@ -34,7 +36,7 @@ export function useVaultAuthorities(): { isPending: boolean } { const { data, isPending } = useQuery({ - queryKey: ["vault", "authorities"], + queryKey: vaultAuthoritiesKey, queryFn: fetchVaultAuthorities, staleTime: 30_000, }) From 0f6809fe54b78b4e6177fd4056bbaa14a27ccf51 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 18:16:38 -0300 Subject: [PATCH 05/10] fix(app): refresh eligibility after a token change --- app/src/hooks/useAdminTransaction.test.ts | 5 +++-- app/src/hooks/useConfigurationTransactions.ts | 5 +++-- app/src/hooks/useIsAllowed.ts | 4 +++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/src/hooks/useAdminTransaction.test.ts b/app/src/hooks/useAdminTransaction.test.ts index fbc008d..7d285d5 100644 --- a/app/src/hooks/useAdminTransaction.test.ts +++ b/app/src/hooks/useAdminTransaction.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { adminAuthorityKey } from "./useAdminAuthority" import { configurationReadsKey } from "./useConfigurationState" +import { isAllowedKey } from "./useIsAllowed" import { useSetCompliance, useSetCustodian, @@ -195,7 +196,7 @@ const cases: Case[] = [ method: token.set_compliance, arg: CONTRACT, args: { compliance: CONTRACT, operator: investorAddress }, - keys: [configurationReadsKey], + keys: [configurationReadsKey, isAllowedKey], refusal: 2000, }, { @@ -204,7 +205,7 @@ const cases: Case[] = [ method: token.set_identity_verifier, arg: CONTRACT, args: { identity_verifier: CONTRACT, operator: investorAddress }, - keys: [configurationReadsKey, adminAuthorityKey], + keys: [configurationReadsKey, adminAuthorityKey, isAllowedKey], refusal: 2000, }, ] diff --git a/app/src/hooks/useConfigurationTransactions.ts b/app/src/hooks/useConfigurationTransactions.ts index 4ae2794..7be2785 100644 --- a/app/src/hooks/useConfigurationTransactions.ts +++ b/app/src/hooks/useConfigurationTransactions.ts @@ -8,6 +8,7 @@ import { adminAuthorityKey } from "./useAdminAuthority" import { adminTransaction } from "./useAdminTransaction" import { configurationReadsKey } from "./useConfigurationState" import { cycleReadsKey } from "./useCycleState" +import { isAllowedKey } from "./useIsAllowed" import { oracleWriteKeys } from "./useOracleState" import { vaultAuthoritiesKey } from "./useVaultAuthorities" @@ -56,7 +57,7 @@ export const useSetCompliance = adminTransaction( shareTokenWriter(signer).then((token) => token.set_compliance({ compliance, operator: signer.publicKey }), ), - [configurationReadsKey], + [configurationReadsKey, isAllowedKey], ) export const useSetIdentityVerifier = adminTransaction( @@ -67,5 +68,5 @@ export const useSetIdentityVerifier = adminTransaction( operator: signer.publicKey, }), ), - [configurationReadsKey, adminAuthorityKey], + [configurationReadsKey, adminAuthorityKey, isAllowedKey], ) diff --git a/app/src/hooks/useIsAllowed.ts b/app/src/hooks/useIsAllowed.ts index 11f40d1..d315ae6 100644 --- a/app/src/hooks/useIsAllowed.ts +++ b/app/src/hooks/useIsAllowed.ts @@ -3,6 +3,8 @@ import { skipToken, useQuery } from "@tanstack/react-query" import { identityVerifier } from "../config/clients" import { useWallet } from "./useWallet" +export const isAllowedKey = ["identity", "is-allowed"] as const + export type Allowance = "checking" | "allowed" | "not-allowed" | "unreadable" export function classifyAllowance(read: ContractRead): Allowance { @@ -20,7 +22,7 @@ async function fetchAllowance(account: string): Promise { export function useIsAllowed(): { allowance: Allowance } { const { address } = useWallet() const { data } = useQuery({ - queryKey: ["identity", "is-allowed", address], + queryKey: [...isAllowedKey, address], queryFn: address === undefined ? skipToken : () => fetchAllowance(address), staleTime: 30_000, }) From 1fae8ee57c0690ba6b7b9c8f6dd94198323e911b Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 18:16:57 -0300 Subject: [PATCH 06/10] refactor(app): type the deposit cap as an Amount --- app/src/pages/configuration.test.ts | 3 ++- app/src/pages/configuration.ts | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/pages/configuration.test.ts b/app/src/pages/configuration.test.ts index bc5a5e5..37659f6 100644 --- a/app/src/pages/configuration.test.ts +++ b/app/src/pages/configuration.test.ts @@ -1,3 +1,4 @@ +import { type Amount } from "@stellar-scaffold/app-lib" import { describe, expect, it } from "vitest" import { type Grant } from "../hooks/useAdminAuthority" import { type ConfigurationState } from "../hooks/useConfigurationState" @@ -32,7 +33,7 @@ const ready = ( overrides: Partial> = {}, ): ConfigurationState => ({ status: "ready", - depositCap: { kind: "capped", amount: 1_000_0000000n as never }, + depositCap: { kind: "capped", amount: 1_000_0000000n as Amount }, custodian: CUSTODIAN, notice: DAY, windDownDelay: 7n * DAY, diff --git a/app/src/pages/configuration.ts b/app/src/pages/configuration.ts index 0b461ec..a9ccfbd 100644 --- a/app/src/pages/configuration.ts +++ b/app/src/pages/configuration.ts @@ -125,10 +125,10 @@ const ANY_ADDRESS = address( ) const CONTRACT = address((text) => StrKey.isValidContract(text)) -const CAP: Kind = { +const CAP: Kind = { parse: orNone((text) => parseUnits(text, AMOUNT_DECIMALS) ?? undefined), - show: (cap) => (cap === null ? "No cap" : exactAmount(cap as Amount)), - prefill: (cap) => (cap === null ? "" : exactAmount(cap as Amount)), + show: (cap) => (cap === null ? "No cap" : exactAmount(cap)), + prefill: (cap) => (cap === null ? "" : exactAmount(cap)), placeholder: "No cap", } @@ -216,7 +216,7 @@ function single( } } -const capOf = (state: ConfigurationValues): bigint | null | undefined => { +const capOf = (state: ConfigurationValues): Amount | null | undefined => { if (state.depositCap === null) return undefined return state.depositCap.kind === "uncapped" ? null : state.depositCap.amount } From df5bfc5b4a3d4980eb822a3fe153514221e5dfb4 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 18:17:31 -0300 Subject: [PATCH 07/10] fix(app): read an unchanged action as no change --- .../components/admin/CycleActionRail.test.tsx | 15 +++++++++++++ app/src/components/admin/CycleActionRail.tsx | 1 + app/src/pages/actions.test.ts | 22 +++++++++++++++++++ app/src/pages/actions.ts | 18 ++++++++++----- app/src/pages/configuration.ts | 1 + 5 files changed, 51 insertions(+), 6 deletions(-) diff --git a/app/src/components/admin/CycleActionRail.test.tsx b/app/src/components/admin/CycleActionRail.test.tsx index fccfe6b..83b776c 100644 --- a/app/src/components/admin/CycleActionRail.test.tsx +++ b/app/src/components/admin/CycleActionRail.test.tsx @@ -126,6 +126,21 @@ const expanded = (label: string) => rowToggle(label).getAttribute("aria-expanded") === "true" describe("CycleActionRail", () => { + it("summarises an action that changes nothing as no change", () => { + renderRail([ + { + ...fulfillAction, + conditions: [ + { label: "Differs from current", met: false, unchanged: true }, + ], + }, + ]) + + expect(rowToggle("Fulfill epoch").textContent).toBe( + "Fulfill epochNo change", + ) + }) + it("heads the rail with how many of its actions are ready", () => { renderRail([closeAction, fulfillAction, attestAction]) diff --git a/app/src/components/admin/CycleActionRail.tsx b/app/src/components/admin/CycleActionRail.tsx index a18756a..589725e 100644 --- a/app/src/components/admin/CycleActionRail.tsx +++ b/app/src/components/admin/CycleActionRail.tsx @@ -21,6 +21,7 @@ const conditionValue = (condition: Condition): string => { const summaryOf = (state: Readiness): string => { if (state.kind === "ready") return "Ready" if (state.kind === "unavailable") return "Unavailable" + if (state.kind === "unchanged") return "No change" return `${state.count} unmet` } diff --git a/app/src/pages/actions.test.ts b/app/src/pages/actions.test.ts index 03eb562..e11c220 100644 --- a/app/src/pages/actions.test.ts +++ b/app/src/pages/actions.test.ts @@ -51,6 +51,28 @@ describe("readiness", () => { expect(readiness(action)).toEqual({ kind: "unmet", count: 2 }) }) + it("reads an action whose only unmet condition is no change as unchanged", () => { + const action = actionWith({ + conditions: [ + { label: "Notice ≤ 30d", met: true }, + { label: "Differs from current", met: false, unchanged: true }, + ], + }) + + expect(readiness(action)).toEqual({ kind: "unchanged" }) + }) + + it("keeps counting when something besides no change is unmet", () => { + const action = actionWith({ + conditions: [ + { label: "Notice ≤ 30d", met: false }, + { label: "Differs from current", met: false, unchanged: 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({ diff --git a/app/src/pages/actions.ts b/app/src/pages/actions.ts index 66cf941..b07a675 100644 --- a/app/src/pages/actions.ts +++ b/app/src/pages/actions.ts @@ -9,7 +9,12 @@ export const ROLES = { tokenAdmin: "share-token admin", } as const -export type Condition = { label: string; met: boolean | null; detail?: string } +export type Condition = { + label: string + met: boolean | null + detail?: string + unchanged?: true +} export type ActionField = { key?: string @@ -39,17 +44,18 @@ export type RailAction = { export type Readiness = | { kind: "ready" } | { kind: "unmet"; count: number } + | { kind: "unchanged" } | { 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, - } + const unmet = action.conditions.filter((condition) => condition.met !== true) + const [only] = unmet + if (unmet.length === 1 && only?.unchanged === true && only.met === false) + return { kind: "unchanged" } + return { kind: "unmet", count: unmet.length } } export const readyCount = (actions: RailAction[]): number => diff --git a/app/src/pages/configuration.ts b/app/src/pages/configuration.ts index a9ccfbd..7884e4e 100644 --- a/app/src/pages/configuration.ts +++ b/app/src/pages/configuration.ts @@ -190,6 +190,7 @@ const bound = ( const differs = (changed: boolean | undefined): Condition => ({ label: "Differs from current", met: changed ?? null, + unchanged: true, }) const changedFrom = (arg: T | undefined, current: T | undefined) => From 651a833cda92d2580b93a596a8318128744f4046 Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 18:18:16 -0300 Subject: [PATCH 08/10] refactor(app): map configuration actions to flows --- .../components/admin/ConfigurationSection.tsx | 50 ++++++------------- 1 file changed, 15 insertions(+), 35 deletions(-) diff --git a/app/src/components/admin/ConfigurationSection.tsx b/app/src/components/admin/ConfigurationSection.tsx index dd0ee7e..b1b53f6 100644 --- a/app/src/components/admin/ConfigurationSection.tsx +++ b/app/src/components/admin/ConfigurationSection.tsx @@ -33,7 +33,7 @@ import layoutStyles from "./SurfaceLayout.module.css" type Flow = Pick< UsePendingTransaction, - "status" | "amountLabel" | "reset" | "retry" + "status" | "amountLabel" | "reset" | "retry" | "submit" > const flow = ( @@ -63,15 +63,18 @@ const ConfigurationSection: React.FC<{ const identityVerifier = usePendingTransaction(useSetIdentityVerifier()) const [inputs, setInputs] = useState({}) - const modalFlows: TransactionModalProps[] = [ - flow("set-deposit-cap", depositCap), - flow("set-custodian", custodian), - flow("set-notice", notice), - flow("set-wind-down-delay", windDownDelay), - flow("set-oracle-config", oracleConfig), - flow("set-compliance", compliance), - flow("set-identity-verifier", identityVerifier), - ] + const flows: Record = { + "set-deposit-cap": depositCap, + "set-custodian": custodian, + "set-notice": notice, + "set-wind-down-delay": windDownDelay, + "set-oracle-config": oracleConfig, + "set-compliance": compliance, + "set-identity-verifier": identityVerifier, + } + const modalFlows = Object.entries(flows).map(([id, transaction]) => + flow(id as ConfigurationModalAction, transaction), + ) const recorded = modalFlows.some( (props) => props.status.status === "confirmed", ) @@ -82,31 +85,8 @@ const ConfigurationSection: React.FC<{ const actions = toConfigurationActions(configuration, grants, wallet, inputs) const run = (action: ConfigurationAction) => { - switch (action.id) { - case "set-deposit-cap": - if (action.arg !== undefined) depositCap.submit(action.arg, action.next) - return - case "set-custodian": - if (action.arg !== undefined) custodian.submit(action.arg, action.next) - return - case "set-notice": - if (action.arg !== undefined) notice.submit(action.arg, action.next) - return - case "set-wind-down-delay": - if (action.arg !== undefined) - windDownDelay.submit(action.arg, action.next) - return - case "set-oracle-config": - if (action.arg !== undefined) - oracleConfig.submit(action.arg, action.next) - return - case "set-compliance": - if (action.arg !== undefined) compliance.submit(action.arg, action.next) - return - case "set-identity-verifier": - if (action.arg !== undefined) - identityVerifier.submit(action.arg, action.next) - } + if (action.arg !== undefined) + flows[action.id].submit(action.arg as never, action.next) } const onInput = (_: ConfigurationAction, value: string, key?: string) => { From 4a05b9ab98764748a101d497a404379226e139fd Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 18:19:48 -0300 Subject: [PATCH 09/10] style(app): order the admin transaction test imports --- app/src/hooks/useAdminTransaction.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/hooks/useAdminTransaction.test.ts b/app/src/hooks/useAdminTransaction.test.ts index 7d285d5..799a6eb 100644 --- a/app/src/hooks/useAdminTransaction.test.ts +++ b/app/src/hooks/useAdminTransaction.test.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { adminAuthorityKey } from "./useAdminAuthority" import { configurationReadsKey } from "./useConfigurationState" -import { isAllowedKey } from "./useIsAllowed" import { useSetCompliance, useSetCustodian, @@ -24,6 +23,7 @@ import { useUnpauseToken, useUnpauseVault, } from "./useEmergencyTransactions" +import { isAllowedKey } from "./useIsAllowed" import { oracleWriteKeys } from "./useOracleState" import { tokenPausedKey } from "./useTokenPaused" import { vaultAuthoritiesKey } from "./useVaultAuthorities" From 286e1d6c9cdc4a925822748f1725a72326a58ddf Mon Sep 17 00:00:00 2001 From: luchobonatti Date: Tue, 13 Oct 2026 18:21:58 -0300 Subject: [PATCH 10/10] fix(app): drop the casts from configuration submit --- .../components/admin/ConfigurationSection.tsx | 42 ++++++++++++------- app/src/pages/configuration.ts | 2 +- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/app/src/components/admin/ConfigurationSection.tsx b/app/src/components/admin/ConfigurationSection.tsx index b1b53f6..395af5f 100644 --- a/app/src/components/admin/ConfigurationSection.tsx +++ b/app/src/components/admin/ConfigurationSection.tsx @@ -63,18 +63,17 @@ const ConfigurationSection: React.FC<{ const identityVerifier = usePendingTransaction(useSetIdentityVerifier()) const [inputs, setInputs] = useState({}) - const flows: Record = { - "set-deposit-cap": depositCap, - "set-custodian": custodian, - "set-notice": notice, - "set-wind-down-delay": windDownDelay, - "set-oracle-config": oracleConfig, - "set-compliance": compliance, - "set-identity-verifier": identityVerifier, - } - const modalFlows = Object.entries(flows).map(([id, transaction]) => - flow(id as ConfigurationModalAction, transaction), - ) + const modalFlows = ( + [ + ["set-deposit-cap", depositCap], + ["set-custodian", custodian], + ["set-notice", notice], + ["set-wind-down-delay", windDownDelay], + ["set-oracle-config", oracleConfig], + ["set-compliance", compliance], + ["set-identity-verifier", identityVerifier], + ] satisfies [ConfigurationModalAction, Flow][] + ).map(([id, transaction]) => flow(id, transaction)) const recorded = modalFlows.some( (props) => props.status.status === "confirmed", ) @@ -85,8 +84,23 @@ const ConfigurationSection: React.FC<{ const actions = toConfigurationActions(configuration, grants, wallet, inputs) const run = (action: ConfigurationAction) => { - if (action.arg !== undefined) - flows[action.id].submit(action.arg as never, action.next) + if (action.arg === undefined) return + switch (action.id) { + case "set-deposit-cap": + return depositCap.submit(action.arg, action.next) + case "set-custodian": + return custodian.submit(action.arg, action.next) + case "set-notice": + return notice.submit(action.arg, action.next) + case "set-wind-down-delay": + return windDownDelay.submit(action.arg, action.next) + case "set-oracle-config": + return oracleConfig.submit(action.arg, action.next) + case "set-compliance": + return compliance.submit(action.arg, action.next) + case "set-identity-verifier": + return identityVerifier.submit(action.arg, action.next) + } } const onInput = (_: ConfigurationAction, value: string, key?: string) => { diff --git a/app/src/pages/configuration.ts b/app/src/pages/configuration.ts index 7884e4e..a75427f 100644 --- a/app/src/pages/configuration.ts +++ b/app/src/pages/configuration.ts @@ -81,7 +81,7 @@ const MAX_WIND_DOWN_DELAY = 90n * DAY const MAX_BPS = 10_000 const MAX_U64 = (1n << 64n) - 1n const MAX_I128 = (1n << 127n) - 1n -const MAX_CEILING = ((1n << 127n) - 1n) / 10_000n +const MAX_CEILING = MAX_I128 / 10_000n const PERCENT = /^(\d+)(?:\.(\d{1,2}))?\s*%?$/ type Kind = {