Skip to content
Merged
214 changes: 214 additions & 0 deletions app/src/components/admin/ConfigurationSection.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>("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(<ConfigurationSection grants={[governance]} wallet={WALLET} />)

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(
<ConfigurationSection grants={[governance]} wallet={WALLET} />,
)
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(<ConfigurationSection grants={[governance]} wallet={WALLET} />)
expect(screen.getByText("New notice: 2d.")).toBeTruthy()
})

it("sends every oracle limit when one changes", () => {
render(<ConfigurationSection grants={[oracleAdmin]} wallet={WALLET} />)
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(
<ConfigurationSection grants={[governance]} wallet={WALLET} />,
)
expand("Set the notice")
const field = () =>
screen.getByRole<HTMLInputElement>("textbox", { name: "Notice" })
fireEvent.change(field(), { target: { value: "2d" } })

noticeMock.status = { status: "confirmed" }
rerender(<ConfigurationSection grants={[governance]} wallet={WALLET} />)

expect(field().value).toBe("1d")
})

it("shows the flow modal only while the surface is active", () => {
noticeMock.status = { status: "preparing" }
const { rerender } = render(
<ConfigurationSection grants={[governance]} wallet={WALLET} active />,
)
expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1)

rerender(
<ConfigurationSection
grants={[governance]}
wallet={WALLET}
active={false}
/>,
)
expect(screen.queryAllByRole("dialog", { hidden: true })).toHaveLength(0)
})

it("reads the parameters with polling only while active", () => {
const { rerender } = render(
<ConfigurationSection
grants={[governance]}
wallet={WALLET}
active={false}
/>,
)
expect(useConfigurationStateMock).toHaveBeenLastCalledWith({
active: false,
})

rerender(
<ConfigurationSection grants={[governance]} wallet={WALLET} active />,
)
expect(useConfigurationStateMock).toHaveBeenLastCalledWith({ active: true })
})
})
162 changes: 162 additions & 0 deletions app/src/components/admin/ConfigurationSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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<never, object>,
"status" | "amountLabel" | "reset" | "retry" | "submit"
>

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<ConfigurationInputs>({})

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",
)
useEffect(() => {
if (recorded) setInputs({})
}, [recorded])

const actions = toConfigurationActions(configuration, grants, wallet, inputs)

const run = (action: ConfigurationAction) => {
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) => {
if (key !== undefined)
setInputs((previous) => ({ ...previous, [key]: value }))
}

const activeModal = modalFlows.find((props) => props.status.status !== "idle")

return (
<>
<SurfaceLayout
side={
<CycleActionRail
scope="configuration"
actions={actions}
pending={configuration.status === "checking"}
unreadable={false}
onRun={run}
onInput={onInput}
/>
}
>
{toConfigurationGroups(configuration).map((group, index) => {
const headingId = `configuration-group-${index}`
return (
<section
key={group.title}
className={layoutStyles.group}
aria-labelledby={headingId}
>
<h3
id={headingId}
className={`${typeStyles.body} ${layoutStyles.groupTitle}`}
>
{group.title}
</h3>
{group.strips.map((strip) => (
<MetricsStrip
key={strip[0]?.label ?? group.title}
metrics={strip}
/>
))}
{group.addresses.length > 0 && (
<AddressRows rows={group.addresses} />
)}
</section>
)
})}
{contracts}
</SurfaceLayout>
{active && activeModal !== undefined && (
<TransactionModal {...activeModal} />
)}
</>
)
}

export default ConfigurationSection
Loading
Loading