diff --git a/app-lib/readContract.test.ts b/app-lib/readContract.test.ts index 8152228..86bd59e 100644 --- a/app-lib/readContract.test.ts +++ b/app-lib/readContract.test.ts @@ -1,4 +1,4 @@ -import { nativeToScVal, xdr } from "@stellar/stellar-sdk" +import { Address, nativeToScVal, xdr } from "@stellar/stellar-sdk" import type { Api } from "@stellar/stellar-sdk/rpc" import { beforeEach, describe, expect, it, vi } from "vitest" import type { DepositRequest, EpochInfo, RedeemRequest } from "./contracts" @@ -8,6 +8,7 @@ import { readEpochs, readEvents, readLedgerTime, + readPendingAdmins, } from "./readContract" const { @@ -645,3 +646,169 @@ describe("readEvents", () => { expect(read).toEqual({ kind: "unreadable" }) }) }) + +const ORACLE_ID = "CABQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGCK3" +const PENDING = "GDPO5SQDJY4E2XEE47MAOT2M2F3GR4H2AYYKSMIOT2YPOHAMC36VKWIA" + +const pendingAdminKey = (contractId: string): xdr.LedgerKey => + xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: Address.fromString(contractId).toScAddress(), + key: xdr.ScVal.scvVec([xdr.ScVal.scvSymbol("PendingAdmin")]), + durability: xdr.ContractDataDurability.temporary(), + }), + ) + +const pendingEntry = ( + contractId: string, + liveUntilLedger: number, + ttl = liveUntilLedger, +): Api.LedgerEntryResult => + entryFor( + pendingAdminKey(contractId).toXDR("base64"), + structScVal({ + address: Address.fromString(PENDING).toScVal(), + live_until_ledger: xdr.ScVal.scvU32(liveUntilLedger), + }), + ttl, + ) + +const archivalKey = xdr.LedgerKey.configSetting( + new xdr.LedgerKeyConfigSetting({ + configSettingId: xdr.ConfigSettingId.configSettingStateArchival(), + }), +) + +const archivalEntry = (maxEntryTtl: number): Api.LedgerEntryResult => ({ + key: archivalKey, + val: xdr.LedgerEntryData.configSetting( + xdr.ConfigSettingEntry.configSettingStateArchival( + new xdr.StateArchivalSettings({ + maxEntryTtl, + minTemporaryTtl: 16, + minPersistentTtl: 4096, + persistentRentRateDenominator: xdr.Int64.fromString("1"), + tempRentRateDenominator: xdr.Int64.fromString("1"), + maxEntriesToArchive: 0, + liveSorobanStateSizeWindowSampleSize: 0, + liveSorobanStateSizeWindowSamplePeriod: 0, + evictionScanSize: 0, + startingEvictionScanLevel: 0, + }), + ), + ), +}) + +describe("readPendingAdmins", () => { + beforeEach(() => { + getLedgerEntriesMock.mockReset() + getLatestLedgerMock.mockReset() + getLatestLedgerMock.mockResolvedValue({ + sequence: 1_000, + closeTime: "1700000000", + }) + }) + + it("asks for each contract's temporary PendingAdmin entry and the archival limits", async () => { + getLedgerEntriesMock.mockResolvedValue({ entries: [] }) + + await readPendingAdmins([CONTRACT_ID, ORACLE_ID]) + + const sent = (getLedgerEntriesMock.mock.calls[0] as xdr.LedgerKey[]).map( + (key) => key.toXDR("base64"), + ) + expect(sent).toEqual([ + pendingAdminKey(CONTRACT_ID).toXDR("base64"), + pendingAdminKey(ORACLE_ID).toXDR("base64"), + archivalKey.toXDR("base64"), + ]) + }) + + it("decodes a live transfer with the ledger it was read at and the furthest allowed expiry", async () => { + getLedgerEntriesMock.mockResolvedValue({ + entries: [pendingEntry(CONTRACT_ID, 1_500), archivalEntry(100_000)], + }) + + const read = await readPendingAdmins([CONTRACT_ID, ORACLE_ID]) + + expect(read).toEqual({ + kind: "value", + value: { + ledger: { + sequence: 1_000, + closeTime: 1_700_000_000n, + maxLiveUntil: 100_999, + }, + pending: new Map([ + [ + CONTRACT_ID, + { address: PENDING, liveUntilLedger: 1_500, expired: false }, + ], + [ORACLE_ID, null], + ]), + }, + }) + }) + + it("reads a transfer past its live_until_ledger as expired while the entry lingers", async () => { + getLedgerEntriesMock.mockResolvedValue({ + entries: [pendingEntry(CONTRACT_ID, 900, 1_200)], + }) + + const read = await readPendingAdmins([CONTRACT_ID]) + + expect( + read.kind === "value" && read.value.pending.get(CONTRACT_ID), + ).toEqual({ address: PENDING, liveUntilLedger: 900, expired: true }) + expect(read.kind === "value" && read.value.ledger.maxLiveUntil).toBeNull() + }) + + it("reads an entry whose TTL lapsed as no transfer", async () => { + getLedgerEntriesMock.mockResolvedValue({ + entries: [pendingEntry(CONTRACT_ID, 900)], + }) + + const read = await readPendingAdmins([CONTRACT_ID]) + + expect(read.kind === "value" && read.value.pending.get(CONTRACT_ID)).toBe( + null, + ) + }) + + it.each([ + ["a value that is not a transfer", xdr.ScVal.scvU32(7)], + [ + "a transfer without an address", + structScVal({ live_until_ledger: xdr.ScVal.scvU32(1_500) }), + ], + [ + "a transfer with a non-numeric expiry", + structScVal({ + address: Address.fromString(PENDING).toScVal(), + live_until_ledger: xdr.ScVal.scvString("later"), + }), + ], + ])("is unreadable when the entry holds %s", async (_, val) => { + getLedgerEntriesMock.mockResolvedValue({ + entries: [ + entryFor(pendingAdminKey(CONTRACT_ID).toXDR("base64"), val, 1_500), + ], + }) + + expect(await readPendingAdmins([CONTRACT_ID])).toEqual({ + kind: "unreadable", + }) + }) + + it.each([ + ["the entries", () => getLedgerEntriesMock.mockRejectedValue(new Error())], + ["the ledger", () => getLatestLedgerMock.mockRejectedValue(new Error())], + ])("is unreadable when %s cannot be read", async (_, fail) => { + getLedgerEntriesMock.mockResolvedValue({ entries: [] }) + fail() + + expect(await readPendingAdmins([CONTRACT_ID])).toEqual({ + kind: "unreadable", + }) + }) +}) diff --git a/app-lib/readContract.ts b/app-lib/readContract.ts index 39362e4..456ae92 100644 --- a/app-lib/readContract.ts +++ b/app-lib/readContract.ts @@ -71,13 +71,10 @@ const userRequestDataKey = ( const contractDataLedgerKey = ( contract: xdr.ScAddress, key: xdr.ScVal, + durability = xdr.ContractDataDurability.persistent(), ): xdr.LedgerKey => xdr.LedgerKey.contractData( - new xdr.LedgerKeyContractData({ - contract, - key, - durability: xdr.ContractDataDurability.persistent(), - }), + new xdr.LedgerKeyContractData({ contract, key, durability }), ) type EpochKeySet = { @@ -274,6 +271,115 @@ export async function readLedgerTime(): Promise { } } +export type PendingTransfer = { + address: string + liveUntilLedger: number + expired: boolean +} + +export type LedgerWindow = { + sequence: number + closeTime: bigint + maxLiveUntil: number | null +} + +export type PendingAdmins = { + ledger: LedgerWindow + pending: Map +} + +const pendingAdminKey = (contractId: string): xdr.LedgerKey => + contractDataLedgerKey( + Address.fromString(contractId).toScAddress(), + xdr.ScVal.scvVec([xdr.ScVal.scvSymbol("PendingAdmin")]), + xdr.ContractDataDurability.temporary(), + ) + +const archivalSettingsKey = (): xdr.LedgerKey => + xdr.LedgerKey.configSetting( + new xdr.LedgerKeyConfigSetting({ + configSettingId: xdr.ConfigSettingId.configSettingStateArchival(), + }), + ) + +const isPendingTransfer = ( + value: unknown, +): value is { address: string; live_until_ledger: number } => + typeof value === "object" && + value !== null && + "address" in value && + typeof value.address === "string" && + "live_until_ledger" in value && + typeof value.live_until_ledger === "number" + +const decodePendingTransfer = + (sequence: number) => + (val: xdr.ScVal): PendingTransfer => { + const native: unknown = scValToNative(val) + if (!isPendingTransfer(native)) throw new Error("malformed pending admin") + return { + address: native.address, + liveUntilLedger: native.live_until_ledger, + expired: sequence > native.live_until_ledger, + } + } + +const maxLiveUntil = ( + settings: Api.LedgerEntryResult | undefined, + sequence: number, +): number | null => + settings === undefined + ? null + : sequence + + settings.val.configSetting().stateArchivalSettings().maxEntryTtl() - + 1 + +export async function readPendingAdmins( + contractIds: string[], +): Promise> { + const keyed = contractIds.map((contractId) => ({ + contractId, + key: pendingAdminKey(contractId), + })) + const archival = archivalSettingsKey() + try { + const [latest, fetched] = await Promise.all([ + server.getLatestLedger(), + fetchChunk([...keyed.map(({ key }) => key), archival]), + ]) + if (!fetched.ok) return { kind: "unreadable" } + const { sequence } = latest + const pending = new Map() + for (const { contractId, key } of keyed) { + const read = decodeLedgerEntry( + fetched, + key, + decodePendingTransfer(sequence), + sequence, + ) + if (read.kind !== "value" && read.kind !== "archived") + return { kind: "unreadable" } + pending.set(contractId, read.kind === "value" ? read.value : null) + } + return { + kind: "value", + value: { + ledger: { + sequence, + closeTime: BigInt(latest.closeTime), + maxLiveUntil: maxLiveUntil( + fetched.byKey.get(archival.toXDR("base64")), + sequence, + ), + }, + pending, + }, + } + } catch { + return { kind: "unreadable" } + } +} + export type EventSelection = { contractId: string; names: string[] } export type ChainEvent = { diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index 6cf9ce1..bd60677 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -42,6 +42,21 @@ const disconnectedAuthority: AdminAuthority = { grants: [], signersUnknown: false, addresses: { oracle: null, shareToken: null, identityVerifier: null }, + holders: { + "vault governance": [], + "vault manager": [], + "vault treasury": [], + "vault guardian": [], + "oracle attester": [], + "oracle guardian": [], + "oracle admin": [], + "share-token compliance": [], + "share-token manager": [], + "share-token admin": [], + "vault pending admin": [], + "oracle pending admin": [], + "share-token pending admin": [], + }, } const renderAt = (path: string) => { diff --git a/app/src/components/admin/CycleActionRail.module.css b/app/src/components/admin/CycleActionRail.module.css index ac0ee34..fdce97c 100644 --- a/app/src/components/admin/CycleActionRail.module.css +++ b/app/src/components/admin/CycleActionRail.module.css @@ -119,16 +119,59 @@ color: var(--vault-text-muted); } -.met, -.unmet { - text-align: right; - white-space: nowrap; +.check { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.pass, +.fail, +.pending { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 1em; + height: 1em; + align-self: center; +} + +.pass { + color: var(--vault-ink); } -.unmet { +.fail { + color: var(--vault-danger); +} + +.pending { color: var(--vault-text-muted); } +.icon { + width: 100%; + height: 100%; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.dot { + width: 0.45em; + height: 0.45em; + border-radius: 50%; + background: currentColor; +} + +.detail { + text-align: right; + white-space: nowrap; +} + .note { margin: 0; color: var(--vault-text-muted); @@ -257,3 +300,12 @@ overflow: hidden; clip: rect(0 0 0 0); } + +.confirm { + display: flex; + align-items: baseline; + gap: 10px; + color: var(--vault-ink); + cursor: pointer; + overflow-wrap: anywhere; +} diff --git a/app/src/components/admin/CycleActionRail.test.tsx b/app/src/components/admin/CycleActionRail.test.tsx index 83b776c..e1ce2b2 100644 --- a/app/src/components/admin/CycleActionRail.test.tsx +++ b/app/src/components/admin/CycleActionRail.test.tsx @@ -1,6 +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 RailAction, type RailField } from "../../pages/actions" import { type CycleAction } from "../../pages/cycle" import CycleActionRail from "./CycleActionRail" @@ -158,15 +158,15 @@ describe("CycleActionRail", () => { .map((toggle) => toggle.textContent), ).toEqual([ "Close epochUnavailable", - "Fulfill epoch1 unmet", - "Attest price1 unmet", - "Deploy to custodian1 unmet", + "Fulfill epochBlocked", + "Attest priceBlocked", + "Deploy to custodianBlocked", ]) for (const [name, description] of [ ["Close epoch", "Unavailable"], - ["Fulfill epoch", "1 unmet"], - ["Attest price", "1 unmet"], - ["Deploy to custodian", "1 unmet"], + ["Fulfill epoch", "Blocked"], + ["Attest price", "Blocked"], + ["Deploy to custodian", "Blocked"], ] as const) expect( screen.getAllByRole("button", { name, description })[0], @@ -301,18 +301,17 @@ describe("CycleActionRail", () => { expect(screen.getByText("Could not read the emergency state.")).toBeTruthy() }) - it("lists each condition as met or not met", () => { + it("lists each condition as pass or fail, in words for screen readers", () => { renderRail([fulfillAction]) expand("Fulfill epoch") const list = screen.getByRole("list", { name: "Fulfill epoch conditions" }) - const [awaiting, notice] = within(list).getAllByRole("listitem") expect( - within(awaiting!).getByText("Sealed epoch awaiting a price"), - ).toBeTruthy() - expect(within(awaiting!).getByText("Met")).toBeTruthy() - expect(within(notice!).getByText("Notice elapsed")).toBeTruthy() - expect(within(notice!).getByText("Not met")).toBeTruthy() + within(list) + .getAllByRole("listitem") + .map((item) => item.textContent), + ).toEqual(["Pass: Sealed epoch awaiting a price", "Fail: Notice elapsed"]) + expect(within(list).queryByText(/Met|Not met/)).toBeNull() }) it("states the resulting state before the button and runs only an enabled action", () => { @@ -374,16 +373,15 @@ describe("CycleActionRail", () => { expect(screen.queryByRole("textbox")).toBeNull() }) - it("leaves a condition open with a dash while it cannot be judged yet", () => { + it("marks a condition pending while it cannot be judged yet", () => { renderRail([attestAction]) expand("Attest price") const band = screen.getByText("Within the band").closest("li")! - expect(within(band).getByText("—")).toBeTruthy() - expect(within(band).queryByText("Not met")).toBeNull() + expect(band.textContent).toBe("Pending: Within the band") }) - it("shows a condition's detail in place of Not met when it has one", () => { + it("keeps a failing condition's detail next to its label", () => { renderRail([ { ...attestAction, @@ -395,17 +393,16 @@ describe("CycleActionRail", () => { expand("Attest price") const band = screen.getByText("Within the band").closest("li")! + expect(band.textContent).toBe("Fail: Within the band0.5000 – 2.0000") expect(within(band).getByText("0.5000 – 2.0000")).toBeTruthy() - expect(within(band).queryByText("Not met")).toBeNull() }) - it("shows a condition's detail in place of Met when it has one", () => { + it("keeps a passing condition's detail next to its label", () => { renderRail([deployAction]) expand("Deploy to custodian") const custodian = screen.getByText("Custodian set").closest("li")! - expect(within(custodian).getByText("CCUS...7890")).toBeTruthy() - expect(within(custodian).queryByText("Met")).toBeNull() + expect(custodian.textContent).toBe("Pass: Custodian setCCUS...7890") }) it("offers to fill the field with its max, reporting the exact figure", () => { @@ -521,3 +518,93 @@ describe("CycleActionRail with keyed fields", () => { ).toBe("decimal") }) }) + +describe("CycleActionRail choice fields", () => { + const grantRole = (checked = false): RailAction => ({ + id: "grant-vault-role", + label: "Grant a vault role", + conditions: [{ label: "Confirmed", met: checked }], + outcome: null, + fields: [ + { + kind: "select", + key: "role", + label: "Role", + value: "treasury", + options: [ + { value: "manager", label: "Manager" }, + { value: "treasury", label: "Treasury" }, + ], + }, + { + kind: "confirm", + key: "confirm", + label: "Merges two vault roles on one account.", + value: "treasury:GABC", + checked, + }, + ], + enabled: checked, + }) + + const renderChoices = (action: RailAction) => { + const onInput = vi.fn() + render( + , + ) + const collapsed = screen.queryByRole("button", { + name: "Grant a vault role", + expanded: false, + }) + if (collapsed !== null) fireEvent.click(collapsed) + return onInput + } + + it("offers the options of a select and reports the chosen one with its key", () => { + const action = grantRole() + const onInput = renderChoices(action) + const select = screen.getByRole("combobox", { + name: "Role", + }) + + expect(select.value).toBe("treasury") + expect( + within(select) + .getAllByRole("option") + .map((option) => option.textContent), + ).toEqual(["Manager", "Treasury"]) + fireEvent.change(select, { target: { value: "manager" } }) + expect(onInput).toHaveBeenCalledWith(action, "manager", "role") + }) + + it("shows the label of a select", () => { + renderChoices(grantRole()) + + expect(screen.getByText("Role").className).not.toContain("srOnly") + }) + + it.each([ + [false, "treasury:GABC"], + [true, ""], + ])( + "toggles a confirmation checked %s by reporting %o", + (checked, reported) => { + const action = grantRole(checked) + const onInput = renderChoices(action) + const check = screen.getByRole("checkbox", { + name: "Merges two vault roles on one account.", + }) + + expect(check.checked).toBe(checked) + fireEvent.click(check) + expect(onInput).toHaveBeenCalledWith(action, reported, "confirm") + }, + ) +}) diff --git a/app/src/components/admin/CycleActionRail.tsx b/app/src/components/admin/CycleActionRail.tsx index 589725e..eaaf51a 100644 --- a/app/src/components/admin/CycleActionRail.tsx +++ b/app/src/components/admin/CycleActionRail.tsx @@ -2,10 +2,13 @@ import React, { useState } from "react" import { type ActionField as ActionFieldSpec, type Condition, + type ConfirmField, type RailAction, + type RailField, type Readiness, readiness, readyCount, + type SelectField, } from "../../pages/actions" import typeStyles from "../../styles/type.module.css" import Skeleton from "../Skeleton" @@ -13,19 +16,111 @@ import styles from "./CycleActionRail.module.css" type OnInput = (action: A, value: string, key?: string) => void -const conditionValue = (condition: Condition): string => { - if (condition.met === null) return "—" - return condition.detail ?? (condition.met ? "Met" : "Not met") +type Status = "pass" | "fail" | "pending" + +const statusOf = ({ met }: Condition): Status => { + if (met === null) return "pending" + return met ? "pass" : "fail" +} + +const STATUS_PREFIX: Record = { + pass: "Pass: ", + fail: "Fail: ", + pending: "Pending: ", +} + +const STATUS_CLASS: Record = { + pass: styles.pass, + fail: styles.fail, + pending: styles.pending, +} + +const StatusIcon: React.FC<{ status: Status }> = ({ status }) => + status === "pending" ? ( + + ) : ( + + + + ) + +const ConditionRow: React.FC<{ condition: Condition }> = ({ condition }) => { + const status = statusOf(condition) + return ( +
  • + + + + {STATUS_PREFIX[status]} + {condition.label} + + + {condition.detail !== undefined && ( + + {condition.detail} + + )} +
  • + ) } 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` + return "Blocked" } -function ActionField
    ({ +type Report = (value: string) => void + +const SelectInput: React.FC<{ + id: string + field: SelectField + report: Report +}> = ({ id, field, report }) => ( +
    + +
    + +
    +
    +) + +const ConfirmInput: React.FC<{ + id: string + field: ConfirmField + report: Report +}> = ({ id, field, report }) => ( + +) + +function RailInput
    >({ id, action, field, @@ -33,13 +128,32 @@ function ActionField({ }: { id: string action: A - field: ActionFieldSpec + field: RailField onInput: OnInput | undefined }) { const report = (value: string) => field.key === undefined ? onInput?.(action, value) : onInput?.(action, value, field.key) + switch (field.kind) { + case "select": + return + case "confirm": + return + case undefined: + return + } +} + +function TextInput({ + id, + field, + report, +}: { + id: string + field: ActionFieldSpec + report: Report +}) { const label = ( ({ +function ActionBody>({ fieldId, action, onRun, @@ -115,7 +229,7 @@ function ActionBody({ return ( <> {fieldsOf(action).map((field) => ( - ({ className={styles.conditions} > {action.conditions.map((condition) => ( -
  • - - {condition.label} - - - {conditionValue(condition)} - -
  • + ))} {action.note !== undefined && ( @@ -167,7 +270,7 @@ function ActionBody
    ({ ) } -function CycleActionRail({ +function CycleActionRail>({ scope, actions, pending, diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx index 1621a09..670bfe2 100644 --- a/app/src/components/admin/CycleSection.test.tsx +++ b/app/src/components/admin/CycleSection.test.tsx @@ -296,14 +296,14 @@ describe("CycleSection", () => { expect(button("Deploy to custodian").disabled).toBe(true) const within_ = screen.getByText("Within the free reserve").closest("li")! - expect(within(within_).getByText("—")).toBeTruthy() + expect(within_.textContent).toBe("Pending: Within the free reserve") fireEvent.change(fieldOf("Amount to deploy"), { target: { value: "250" }, }) expect(button("Deploy to custodian").disabled).toBe(false) - expect(within(within_).getByText("Met")).toBeTruthy() + expect(within_.textContent).toBe("Pass: Within the free reserve") expect( screen.getByText( "Sends 250.00 to the custodian; free reserve becomes 650.00.", @@ -515,12 +515,12 @@ describe("CycleSection", () => { ) expect(button("Attest price").disabled).toBe(true) const band = screen.getByText("Within the band").closest("li")! - expect(within(band).getByText("—")).toBeTruthy() + expect(band.textContent).toMatch(/^Pending: Within the band/) fireEvent.change(field, { target: { value: "1.04" } }) expect(button("Attest price").disabled).toBe(false) - expect(within(band).getByText("Met")).toBeTruthy() + expect(band.textContent).toMatch(/^Pass: Within the band/) expect( screen.getByText("Records 1.0400 as the share price, valid for 1d."), ).toBeTruthy() @@ -615,7 +615,7 @@ describe("CycleSection", () => { expect(button("Fulfill epoch").disabled).toBe(true) const notice = screen.getByText("Notice elapsed").closest("li")! - expect(within(notice).getByText("Not met")).toBeTruthy() + expect(notice.textContent).toMatch(/^Fail: Notice elapsed/) }) it("tells a manager signing through a multisig that collecting signatures is not supported yet", () => { diff --git a/app/src/components/admin/GovernanceSection.test.tsx b/app/src/components/admin/GovernanceSection.test.tsx new file mode 100644 index 0000000..0b993de --- /dev/null +++ b/app/src/components/admin/GovernanceSection.test.tsx @@ -0,0 +1,392 @@ +import { type PendingTransfer } from "@stellar-scaffold/app-lib" +import { fireEvent, render, screen, within } from "@testing-library/react" +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest" +import { + type AdminAddresses, + type Grant, + type RoleHolders, +} from "../../hooks/useAdminAuthority" +import { type TransactionStatus } from "../../hooks/useContractTransaction" +import { + type Handovers, + type PendingUpgrade, + type Upgrades, +} from "../../hooks/useGovernanceState" +import GovernanceSection from "./GovernanceSection" + +const WALLET = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H" +const ACCOUNT = "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA" +const VAULT = "CABQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGCK3" + +const { txs, useGovernanceStateMock } = vi.hoisted(() => { + const tx = (): { + status: TransactionStatus + submit: Mock<() => boolean> + reset: Mock + } => ({ + status: { status: "idle" }, + submit: vi.fn(() => true), + reset: vi.fn(), + }) + return { + txs: { + useGrantVaultRole: tx(), + useRevokeVaultRole: tx(), + useGrantOracleRole: tx(), + useRevokeOracleRole: tx(), + useGrantTokenRole: tx(), + useRevokeTokenRole: tx(), + useHandOverVaultAdmin: tx(), + useCancelVaultHandover: tx(), + useAcceptVaultAdmin: tx(), + useHandOverOracleAdmin: tx(), + useCancelOracleHandover: tx(), + useAcceptOracleAdmin: tx(), + useHandOverTokenAdmin: tx(), + useCancelTokenHandover: tx(), + useAcceptTokenAdmin: tx(), + useProposeCodeUpgrade: tx(), + useProposeUpgradeDelay: tx(), + useApplyUpgrade: tx(), + useCancelUpgrade: tx(), + useProposeWindDown: tx(), + useCancelWindDown: tx(), + }, + useGovernanceStateMock: vi.fn(), + } +}) + +vi.mock("../../hooks/useGovernanceTransactions", () => + Object.fromEntries( + Object.entries(txs).map(([name, transaction]) => [name, () => transaction]), + ), +) +vi.mock("../../hooks/useGovernanceState", async (importOriginal) => ({ + ...(await importOriginal()), + useGovernanceState: useGovernanceStateMock, +})) +vi.mock("../../config/contracts", () => ({ + vaultContractId: "CABQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGCK3", +})) + +const ADDRESSES: AdminAddresses = { + oracle: "CORACLE", + shareToken: "CTOKEN", + identityVerifier: null, +} + +const grantOf = (role: string): Grant => ({ + role, + authority: WALLET, + standing: "signs-alone", +}) + +const governance = grantOf("vault governance") +const tokenAdmin = grantOf("share-token admin") + +const HOLDERS: RoleHolders = { + "vault governance": [WALLET], + "vault manager": [], + "vault treasury": [], + "vault guardian": [], + "oracle attester": [ACCOUNT], + "oracle guardian": [], + "oracle admin": [WALLET], + "share-token compliance": [], + "share-token manager": [VAULT], + "share-token admin": [WALLET], + "vault pending admin": [], + "oracle pending admin": [], + "share-token pending admin": [], +} + +const handovers = (pending: PendingTransfer | null = null): Handovers => ({ + status: "ready", + ledger: { sequence: 1_000, closeTime: 1_700_000_000n, maxLiveUntil: null }, + pending: { vault: null, oracle: pending, token: null }, +}) + +const upgrades = (proposal: PendingUpgrade | null = null): Upgrades => ({ + status: "ready", + proposal, + delay: 7n * 86_400n, + notice: 86_400n, + windDown: { + phase: "proposed", + activeAt: 1_700_100_000n, + round: 0, + delay: 86_400n, + owed: null, + supply: null, + }, + now: 1_800_000_000n, + paused: "open", +}) + +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"))! + +const renderSection = (grants: Grant[], active = true) => + render( + , + ) + +describe("GovernanceSection", () => { + beforeEach(() => { + vi.clearAllMocks() + useGovernanceStateMock.mockReturnValue({ + handovers: handovers(), + upgrades: upgrades(), + }) + for (const mock of Object.values(txs)) { + mock.status = { status: "idle" } + mock.submit = vi.fn(() => true) + mock.reset = vi.fn() + } + }) + + it("shows the holders of every role grouped by contract", () => { + renderSection([governance]) + + const oracle = screen.getByRole("region", { name: "Oracle" }) + expect(within(oracle).getByText("Attester")).toBeTruthy() + expect(within(oracle).getByText("GABA...EJXA")).toBeTruthy() + const token = screen.getByRole("region", { name: "Share token" }) + expect(within(token).getByText("CABQ...GCK3")).toBeTruthy() + }) + + it("says when the pending admins could not be read", () => { + useGovernanceStateMock.mockReturnValue({ + handovers: { status: "unreadable" }, + upgrades: upgrades(), + }) + renderSection([]) + + expect( + screen.getByText("Could not read the governance state."), + ).toBeTruthy() + }) + + it("keeps the rail checking while the pending admins load", () => { + useGovernanceStateMock.mockReturnValue({ + handovers: { status: "checking" }, + upgrades: upgrades(), + }) + renderSection([]) + + expect( + screen.queryByText("Could not read the governance state."), + ).toBeNull() + expect(screen.queryByText("No actions available.")).toBeNull() + }) + + it("offers role changes only on the contracts the wallet administers", () => { + renderSection([tokenAdmin]) + + expect( + screen.getAllByRole("button", { name: "Grant a share-token role" }), + ).toBeTruthy() + expect( + screen.queryAllByRole("button", { name: "Grant a vault role" }), + ).toEqual([]) + }) + + it("grants the chosen role to the typed account", () => { + renderSection([governance]) + + expand("Grant a vault role") + fireEvent.change(screen.getByRole("combobox", { name: "Role" }), { + target: { value: "guardian" }, + }) + fireEvent.change(screen.getByRole("textbox", { name: "Account" }), { + target: { value: ACCOUNT }, + }) + fireEvent.click(submit("Grant a vault role")) + + expect(txs.useGrantVaultRole.submit).toHaveBeenCalledWith({ + role: "guardian", + account: ACCOUNT, + }) + }) + + it("holds a dangerous revoke until its confirmation is checked", () => { + renderSection([tokenAdmin]) + + expand("Revoke a share-token role") + fireEvent.change(screen.getByRole("combobox", { name: "Role" }), { + target: { value: "manager" }, + }) + fireEvent.change(screen.getByRole("textbox", { name: "Account" }), { + target: { value: VAULT }, + }) + + expect(submit("Revoke a share-token role").disabled).toBe(true) + fireEvent.click( + screen.getByRole("checkbox", { + name: "Stops the vault minting and burning shares.", + }), + ) + expect(submit("Revoke a share-token role").disabled).toBe(false) + fireEvent.click(submit("Revoke a share-token role")) + expect(txs.useRevokeTokenRole.submit).toHaveBeenCalledWith({ + role: "manager", + account: VAULT, + }) + }) + + it("asks again for a confirmation when a typed value comes back", () => { + renderSection([governance]) + + expand("Grant a vault role") + fireEvent.change(screen.getByRole("combobox", { name: "Role" }), { + target: { value: "guardian" }, + }) + const typeAccount = (value: string) => + fireEvent.change(screen.getByRole("textbox", { name: "Account" }), { + target: { value }, + }) + const merge = () => + screen.getByRole("checkbox", { + name: "Merges two vault roles on one account.", + }) + typeAccount(WALLET) + fireEvent.click(merge()) + expect(merge().checked).toBe(true) + + typeAccount(ACCOUNT) + typeAccount(WALLET) + + expect(merge().checked).toBe(false) + expect(submit("Grant a vault role").disabled).toBe(true) + }) + + it("asks again for a handover confirmation when the expiry comes back", () => { + renderSection([governance]) + + expand("Hand over vault admin") + fireEvent.change(screen.getByRole("textbox", { name: "New admin" }), { + target: { value: ACCOUNT }, + }) + const typeExpiry = (value: string) => + fireEvent.change(screen.getByRole("textbox", { name: "Expiry" }), { + target: { value }, + }) + const hands = () => + screen.getByRole("checkbox", { + name: "Hands control of the vault to GABA...EJXA.", + }) + fireEvent.click(hands()) + typeExpiry("2d") + typeExpiry("7d") + + expect(hands().checked).toBe(false) + }) + + it("shows the running role change only while the tab is active", () => { + txs.useGrantOracleRole.status = { status: "awaiting-signature" } + const { rerender } = renderSection([grantOf("oracle admin")]) + + expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1) + rerender( + , + ) + expect(screen.queryAllByRole("dialog", { hidden: true })).toHaveLength(0) + }) + + it("hands the admin over for the typed expiry once confirmed", () => { + renderSection([governance]) + + expand("Hand over vault admin") + fireEvent.change(screen.getByRole("textbox", { name: "New admin" }), { + target: { value: ACCOUNT }, + }) + fireEvent.change(screen.getByRole("textbox", { name: "Expiry" }), { + target: { value: "2d" }, + }) + fireEvent.click( + screen.getByRole("checkbox", { + name: "Hands control of the vault to GABA...EJXA.", + }), + ) + fireEvent.click(submit("Hand over vault admin")) + + expect(txs.useHandOverVaultAdmin.submit).toHaveBeenCalledWith({ + newAdmin: ACCOUNT, + liveUntilLedger: 1_000 + 2 * 17_280, + }) + }) + + it("lets the pending wallet accept and the admin cancel", () => { + useGovernanceStateMock.mockReturnValue({ + handovers: handovers({ + address: WALLET, + liveUntilLedger: 5_000, + expired: false, + }), + upgrades: upgrades(), + }) + renderSection([grantOf("oracle admin"), grantOf("oracle pending admin")]) + + expand("Accept oracle admin") + fireEvent.click(submit("Accept oracle admin")) + expand("Cancel the oracle handover") + fireEvent.click(submit("Cancel the oracle handover")) + + expect(txs.useAcceptOracleAdmin.submit).toHaveBeenCalledWith(undefined) + expect(txs.useCancelOracleHandover.submit).toHaveBeenCalledWith({ + newAdmin: WALLET, + liveUntilLedger: 0, + }) + expect(useGovernanceStateMock).toHaveBeenCalledWith({ + contracts: { vault: VAULT, oracle: "CORACLE", token: "CTOKEN" }, + active: true, + }) + }) + + it("applies a confirmed code upgrade and cancels the wind-down", () => { + const hash = "ab".repeat(32) + useGovernanceStateMock.mockReturnValue({ + handovers: handovers(), + upgrades: upgrades({ kind: "code", hash, eta: 1_700_000_000n }), + }) + renderSection([governance]) + + expand("Apply the upgrade") + fireEvent.click( + screen.getByRole("checkbox", { + name: `Replaces the vault's code with ${hash}.`, + }), + ) + fireEvent.click(submit("Apply the upgrade")) + expand("Cancel the wind-down") + fireEvent.click(submit("Cancel the wind-down")) + + expect(txs.useApplyUpgrade.submit).toHaveBeenCalledWith(undefined) + expect(txs.useCancelWindDown.submit).toHaveBeenCalledWith(undefined) + expect(screen.getByText("Pending upgrade")).toBeTruthy() + expect(screen.getByTitle(`Code ${hash}`).textContent).toBe( + "Code abab...abab", + ) + }) +}) diff --git a/app/src/components/admin/GovernanceSection.tsx b/app/src/components/admin/GovernanceSection.tsx new file mode 100644 index 0000000..4f7effd --- /dev/null +++ b/app/src/components/admin/GovernanceSection.tsx @@ -0,0 +1,219 @@ +import React, { useEffect, useState } from "react" +import { vaultContractId } from "../../config/contracts" +import { + type AdminAddresses, + type Grant, + type RoleHolders, +} from "../../hooks/useAdminAuthority" +import { useGovernanceState } from "../../hooks/useGovernanceState" +import { + useApplyUpgrade, + useCancelUpgrade, + useCancelWindDown, + useProposeCodeUpgrade, + useProposeUpgradeDelay, + useProposeWindDown, + useAcceptOracleAdmin, + useAcceptTokenAdmin, + useAcceptVaultAdmin, + useCancelOracleHandover, + useCancelTokenHandover, + useCancelVaultHandover, + useGrantOracleRole, + useGrantTokenRole, + useGrantVaultRole, + useHandOverOracleAdmin, + useHandOverTokenAdmin, + useHandOverVaultAdmin, + useRevokeOracleRole, + useRevokeTokenRole, + useRevokeVaultRole, +} from "../../hooks/useGovernanceTransactions" +import { + usePendingTransaction, + type UsePendingTransaction, +} from "../../hooks/usePendingTransaction" +import { + confirmKey, + GOVERNANCE_ACTION_IDS, + type GovernanceAction, + type GovernanceInputs, + toGovernanceActions, + toGovernanceGroups, +} from "../../pages/governance" +import typeStyles from "../../styles/type.module.css" +import MetricsStrip from "../vault/MetricsStrip" +import TransactionModal, { + type GovernanceModalAction, + 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" | "submit" +> + +const flow = ( + action: GovernanceModalAction, + transaction: Flow, +): TransactionModalProps => ({ + action, + status: transaction.status, + value: transaction.amountLabel, + onClose: transaction.reset, + onRetry: transaction.retry, +}) + +function useGovernanceFlows() { + return { + "grant-vault-role": usePendingTransaction(useGrantVaultRole()), + "revoke-vault-role": usePendingTransaction(useRevokeVaultRole()), + "hand-over-vault-admin": usePendingTransaction(useHandOverVaultAdmin()), + "cancel-vault-handover": usePendingTransaction(useCancelVaultHandover()), + "accept-vault-admin": usePendingTransaction(useAcceptVaultAdmin()), + "grant-oracle-role": usePendingTransaction(useGrantOracleRole()), + "revoke-oracle-role": usePendingTransaction(useRevokeOracleRole()), + "hand-over-oracle-admin": usePendingTransaction(useHandOverOracleAdmin()), + "cancel-oracle-handover": usePendingTransaction(useCancelOracleHandover()), + "accept-oracle-admin": usePendingTransaction(useAcceptOracleAdmin()), + "grant-token-role": usePendingTransaction(useGrantTokenRole()), + "revoke-token-role": usePendingTransaction(useRevokeTokenRole()), + "hand-over-token-admin": usePendingTransaction(useHandOverTokenAdmin()), + "cancel-token-handover": usePendingTransaction(useCancelTokenHandover()), + "accept-token-admin": usePendingTransaction(useAcceptTokenAdmin()), + "propose-code-upgrade": usePendingTransaction(useProposeCodeUpgrade()), + "propose-upgrade-delay": usePendingTransaction(useProposeUpgradeDelay()), + "apply-upgrade": usePendingTransaction(useApplyUpgrade()), + "cancel-upgrade": usePendingTransaction(useCancelUpgrade()), + "propose-wind-down": usePendingTransaction(useProposeWindDown()), + "cancel-wind-down": usePendingTransaction(useCancelWindDown()), + } satisfies Record +} + +type Flows = ReturnType + +const submitArg = ( + { submit }: { submit: (arg: Arg, label: string) => boolean }, + { arg, next }: { arg: Arg | undefined; next: string }, +) => arg !== undefined && submit(arg, next) + +function submit(flows: Flows, action: GovernanceAction) { + switch (action.id) { + case "grant-vault-role": + case "revoke-vault-role": + case "grant-oracle-role": + case "revoke-oracle-role": + case "grant-token-role": + case "revoke-token-role": + return submitArg(flows[action.id], action) + case "hand-over-vault-admin": + case "cancel-vault-handover": + case "hand-over-oracle-admin": + case "cancel-oracle-handover": + case "hand-over-token-admin": + case "cancel-token-handover": + return submitArg(flows[action.id], action) + case "propose-code-upgrade": + return submitArg(flows[action.id], action) + case "propose-upgrade-delay": + return submitArg(flows[action.id], action) + case "accept-vault-admin": + case "accept-oracle-admin": + case "accept-token-admin": + case "apply-upgrade": + case "cancel-upgrade": + case "propose-wind-down": + case "cancel-wind-down": + return flows[action.id].submit(undefined, action.next) + } +} + +const GovernanceSection: React.FC<{ + grants: Grant[] + wallet: string + holders: RoleHolders + addresses: AdminAddresses + contracts?: React.ReactNode + active?: boolean +}> = ({ grants, wallet, holders, addresses, contracts, active = true }) => { + const { handovers, upgrades } = useGovernanceState({ + contracts: + addresses.oracle === null || addresses.shareToken === null + ? undefined + : { + vault: vaultContractId, + oracle: addresses.oracle, + token: addresses.shareToken, + }, + active, + }) + const flows = useGovernanceFlows() + const [inputs, setInputs] = useState({}) + + const modalFlows = GOVERNANCE_ACTION_IDS.map((id) => flow(id, flows[id])) + const recorded = modalFlows.some( + (props) => props.status.status === "confirmed", + ) + useEffect(() => { + if (recorded) setInputs({}) + }, [recorded]) + + const state = { holders, vault: vaultContractId, handovers, upgrades } + const actions = toGovernanceActions(state, grants, wallet, inputs) + + const onInput = (action: GovernanceAction, value: string, key?: string) => { + if (key !== undefined) + setInputs((previous) => ({ + ...previous, + [confirmKey(action.id)]: "", + [key]: value, + })) + } + + const activeModal = modalFlows.find((props) => props.status.status !== "idle") + + return ( + <> + submit(flows, action)} + onInput={onInput} + /> + } + > + {toGovernanceGroups(state).map((group, index) => { + const headingId = `governance-group-${index}` + return ( +
    +

    + {group.title} +

    + +
    + ) + })} + {contracts} +
    + {active && activeModal !== undefined && ( + + )} + + ) +} + +export default GovernanceSection diff --git a/app/src/components/vault/MetricsStrip.test.tsx b/app/src/components/vault/MetricsStrip.test.tsx index a569c95..a638e3e 100644 --- a/app/src/components/vault/MetricsStrip.test.tsx +++ b/app/src/components/vault/MetricsStrip.test.tsx @@ -31,6 +31,24 @@ describe("MetricsStrip", () => { } }) + it("exposes the full text behind a shortened value", () => { + render( + , + ) + + expect(screen.getByText("Code abab").getAttribute("title")).toBe( + "Code abab...", + ) + }) + it("renders an em dash for a value that could not be read", () => { const unread = metrics.map((metric) => ({ ...metric, value: null })) diff --git a/app/src/components/vault/MetricsStrip.tsx b/app/src/components/vault/MetricsStrip.tsx index a13ca85..b59576b 100644 --- a/app/src/components/vault/MetricsStrip.tsx +++ b/app/src/components/vault/MetricsStrip.tsx @@ -6,6 +6,7 @@ import styles from "./MetricsStrip.module.css" export type Metric = { label: string value: string | null + title?: string note?: string pending?: boolean } @@ -26,7 +27,10 @@ const MetricsStrip: React.FC = ({ metrics }) => ( className={`${typeStyles.metric} ${styles.valueSkeleton}`} /> ) : ( - + {metric.value ?? "—"} )} diff --git a/app/src/components/vault/TransactionModal.module.css b/app/src/components/vault/TransactionModal.module.css index b7afd6c..c875c4c 100644 --- a/app/src/components/vault/TransactionModal.module.css +++ b/app/src/components/vault/TransactionModal.module.css @@ -128,6 +128,7 @@ .body { color: var(--vault-text-body); + overflow-wrap: anywhere; } .hash { diff --git a/app/src/components/vault/TransactionModal.test.tsx b/app/src/components/vault/TransactionModal.test.tsx index 9569341..2ef55dd 100644 --- a/app/src/components/vault/TransactionModal.test.tsx +++ b/app/src/components/vault/TransactionModal.test.tsx @@ -20,6 +20,7 @@ import { type RequestRedeemStatus } from "../../hooks/useRequestRedeem" import TransactionModal, { type ConfigurationModalAction, type EmergencyModalAction, + type GovernanceModalAction, } from "./TransactionModal" const { explorerTransactionMock } = vi.hoisted(() => ({ @@ -2221,3 +2222,196 @@ describe("TransactionModal, configuration actions", () => { ).toBeTruthy() }) }) + +const renderGovernanceModal = ( + action: GovernanceModalAction, + status: TransactionStatus, + value = "Manager to GABC...WXYZ", +) => + render( + , + ) + +describe("TransactionModal, governance actions", () => { + it.each<[GovernanceModalAction, string, string, string, string]>([ + [ + "grant-vault-role", + "Manager to GABC...WXYZ", + "Signing grants Manager to GABC...WXYZ.", + "Role granted", + "Granted Manager to GABC...WXYZ.", + ], + [ + "revoke-token-role", + "Manager from CVAU...LTID", + "Signing revokes Manager from CVAU...LTID.", + "Role revoked", + "Revoked Manager from CVAU...LTID.", + ], + ])( + "names the role change of %s", + (action, value, signing, done, doneBody) => { + const { unmount } = renderGovernanceModal( + action, + { status: "awaiting-signature" }, + value, + ) + expect(screen.getByText(signing)).toBeTruthy() + unmount() + + renderGovernanceModal(action, { status: "confirmed" }, value) + expect(screen.getByRole("heading", { name: done })).toBeTruthy() + expect(screen.getByText(doneBody)).toBeTruthy() + }, + ) + + it.each<[GovernanceModalAction, number, string, RegExp]>([ + ["grant-vault-role", 2000, "vault", /does not hold the role/], + ["revoke-oracle-role", 2002, "oracle", /holders changed/], + ["revoke-token-role", 2007, "share token", /does not hold this role/], + [ + "grant-oracle-role", + 2010, + "oracle", + /The oracle already has the maximum number of roles/, + ], + ["cancel-vault-handover", 2200, "vault", /no pending handover/], + ["hand-over-oracle-admin", 2201, "oracle", /expiry is outside/], + ["cancel-token-handover", 2202, "share token", /different address/], + ["accept-vault-admin", 2203, "vault", /expired before it was accepted/], + ["apply-upgrade", 1000, "vault", /The vault is paused/], + ["propose-wind-down", 6046, "vault", /wind-down is already active/], + ["cancel-wind-down", 6047, "vault", /No wind-down is proposed/], + ["propose-wind-down", 6048, "vault", /wind-down is already proposed/], + ["propose-code-upgrade", 6055, "vault", /upgrade is already pending/], + ["cancel-upgrade", 6056, "vault", /No upgrade is pending/], + ["apply-upgrade", 6057, "vault", /upgrade delay has not passed/], + ["propose-upgrade-delay", 6058, "vault", /cannot be under 7 days/], + ["propose-upgrade-delay", 6059, "vault", /cannot be under the notice/], + ["propose-upgrade-delay", 6060, "vault", /cannot exceed 90 days/], + ])( + "names the reason %s was refused with code %i", + (action, code, refuser, sentence) => { + renderGovernanceModal(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.each<[GovernanceModalAction, string, string, string, string]>([ + [ + "hand-over-vault-admin", + "GABC...WXYZ becomes admin once it accepts before 21 Nov, 22:13 UTC.", + "Signing proposes the handover.", + "Handover proposed", + "GABC...WXYZ becomes admin once it accepts before 21 Nov, 22:13 UTC.", + ], + [ + "cancel-oracle-handover", + "GABC...WXYZ", + "Signing withdraws the offer to GABC...WXYZ.", + "Handover cancelled", + "GABC...WXYZ can no longer accept.", + ], + [ + "accept-token-admin", + "share token", + "Signing makes this wallet the share token admin.", + "Admin accepted", + "This wallet is now the share token admin.", + ], + ])( + "names the handover step of %s", + (action, value, signing, done, doneBody) => { + const { unmount } = renderGovernanceModal( + action, + { status: "awaiting-signature" }, + value, + ) + expect(screen.getByText(signing)).toBeTruthy() + unmount() + + renderGovernanceModal(action, { status: "confirmed" }, value) + expect(screen.getByRole("heading", { name: done })).toBeTruthy() + expect(screen.getByText(doneBody)).toBeTruthy() + }, + ) + + it.each<[GovernanceModalAction, string, string, string, string]>([ + [ + "propose-code-upgrade", + "Code abab...abab", + "Signing queues Code abab...abab.", + "Upgrade proposed", + "Code abab...abab can apply once the upgrade delay passes.", + ], + [ + "apply-upgrade", + "Delay 14d", + "Signing applies Delay 14d.", + "Upgrade applied", + "Delay 14d is in effect.", + ], + [ + "cancel-upgrade", + "Code abab...abab", + "Signing withdraws Code abab...abab.", + "Upgrade cancelled", + "Code abab...abab is withdrawn.", + ], + [ + "propose-wind-down", + "Activates 21 Nov, 22:13 UTC.", + "Signing starts the wind-down countdown.", + "Wind-down proposed", + "Activates 21 Nov, 22:13 UTC.", + ], + [ + "cancel-wind-down", + "", + "Signing withdraws the wind-down.", + "Wind-down cancelled", + "The vault stays open.", + ], + ])( + "names the vault proposal step of %s", + (action, value, signing, done, doneBody) => { + const { unmount } = renderGovernanceModal( + action, + { status: "awaiting-signature" }, + value, + ) + expect(screen.getByText(signing)).toBeTruthy() + unmount() + + renderGovernanceModal(action, { status: "confirmed" }, value) + expect(screen.getByRole("heading", { name: done })).toBeTruthy() + expect(screen.getByText(doneBody)).toBeTruthy() + }, + ) + + it("sends an unknown outcome to the Governance tab", () => { + renderGovernanceModal("grant-token-role", { + status: "failed", + failure: { kind: "unknown" }, + }) + + expect( + screen.getByText(/check the Governance tab before trying again/), + ).toBeTruthy() + }) +}) diff --git a/app/src/components/vault/TransactionModal.tsx b/app/src/components/vault/TransactionModal.tsx index d7137e7..6b0ccbd 100644 --- a/app/src/components/vault/TransactionModal.tsx +++ b/app/src/components/vault/TransactionModal.tsx @@ -25,6 +25,7 @@ import { type FundStatus } from "../../hooks/useFund" import { type RequestDepositStatus } from "../../hooks/useRequestDeposit" import { type RequestRedeemStatus } from "../../hooks/useRequestRedeem" import { EFFECTS } from "../../pages/configuration" +import { type GovernanceActionId } from "../../pages/governance" import typeStyles from "../../styles/type.module.css" import Close from "../icons/Close" import ExternalLink from "../icons/ExternalLink" @@ -48,6 +49,8 @@ export type ConfigurationModalAction = | "set-compliance" | "set-identity-verifier" +export type GovernanceModalAction = GovernanceActionId + export type TransactionModalProps = | { action: EmergencyModalAction @@ -56,7 +59,7 @@ export type TransactionModalProps = onRetry: () => void } | { - action: ConfigurationModalAction + action: ConfigurationModalAction | GovernanceModalAction status: TransactionStatus value: string onClose: () => void @@ -385,6 +388,7 @@ 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 GOVERNANCE_FOLLOW_UP = "check the Governance tab before trying again" const describeFailure = ( failure: TransactionFailure, @@ -955,6 +959,7 @@ const describeFinalizeRoundStatus = ( type AdminCopy = { refuser: "vault" | "share token" | "oracle" + reasons?: Partial> verb: string signing: string done: string @@ -1050,6 +1055,122 @@ const CONFIGURATION_COPY: Record< "set-identity-verifier": setterCopy("share token", "Identity verifier"), } +const grantCopy = + (refuser: AdminCopy["refuser"]) => + (value: string): AdminCopy => ({ + refuser, + verb: "grant a role", + signing: `Signing grants ${value}.`, + done: "Role granted", + doneBody: `Granted ${value}.`, + }) + +const revokeCopy = + (refuser: AdminCopy["refuser"]) => + (value: string): AdminCopy => ({ + refuser, + verb: "revoke a role", + signing: `Signing revokes ${value}.`, + done: "Role revoked", + doneBody: `Revoked ${value}.`, + }) + +const handOverCopy = + (refuser: AdminCopy["refuser"]) => + (value: string): AdminCopy => ({ + refuser, + verb: "hand over the admin", + signing: "Signing proposes the handover.", + done: "Handover proposed", + doneBody: value, + }) + +const cancelHandoverCopy = + (refuser: AdminCopy["refuser"]) => + (value: string): AdminCopy => ({ + refuser, + verb: "cancel the handover", + signing: `Signing withdraws the offer to ${value}.`, + done: "Handover cancelled", + doneBody: `${value} can no longer accept.`, + }) + +const acceptAdminCopy = (refuser: AdminCopy["refuser"]) => (): AdminCopy => ({ + refuser, + verb: "accept the admin role", + signing: `Signing makes this wallet the ${refuser} admin.`, + done: "Admin accepted", + doneBody: `This wallet is now the ${refuser} admin.`, +}) + +const proposalCopy = (value: string): AdminCopy => ({ + refuser: "vault", + verb: "propose an upgrade", + signing: `Signing queues ${value}.`, + done: "Upgrade proposed", + doneBody: `${value} can apply once the upgrade delay passes.`, +}) + +const applyUpgradeCopy = (value: string): AdminCopy => ({ + refuser: "vault", + reasons: { 1000: "The vault is paused; unpause it first." }, + verb: "apply the upgrade", + signing: `Signing applies ${value}.`, + done: "Upgrade applied", + doneBody: `${value} is in effect.`, +}) + +const cancelUpgradeCopy = (value: string): AdminCopy => ({ + refuser: "vault", + verb: "cancel the upgrade", + signing: `Signing withdraws ${value}.`, + done: "Upgrade cancelled", + doneBody: `${value} is withdrawn.`, +}) + +const proposeWindDownCopy = (value: string): AdminCopy => ({ + refuser: "vault", + verb: "propose a wind-down", + signing: "Signing starts the wind-down countdown.", + done: "Wind-down proposed", + doneBody: value, +}) + +const cancelWindDownCopy = (): AdminCopy => ({ + refuser: "vault", + verb: "cancel the wind-down", + signing: "Signing withdraws the wind-down.", + done: "Wind-down cancelled", + doneBody: "The vault stays open.", +}) + +const GOVERNANCE_COPY: Record< + GovernanceModalAction, + (value: string) => AdminCopy +> = { + "grant-vault-role": grantCopy("vault"), + "revoke-vault-role": revokeCopy("vault"), + "grant-oracle-role": grantCopy("oracle"), + "revoke-oracle-role": revokeCopy("oracle"), + "grant-token-role": grantCopy("share token"), + "revoke-token-role": revokeCopy("share token"), + "hand-over-vault-admin": handOverCopy("vault"), + "cancel-vault-handover": cancelHandoverCopy("vault"), + "accept-vault-admin": acceptAdminCopy("vault"), + "hand-over-oracle-admin": handOverCopy("oracle"), + "cancel-oracle-handover": cancelHandoverCopy("oracle"), + "accept-oracle-admin": acceptAdminCopy("oracle"), + "hand-over-token-admin": handOverCopy("share token"), + "cancel-token-handover": cancelHandoverCopy("share token"), + "accept-token-admin": acceptAdminCopy("share token"), + "propose-code-upgrade": proposalCopy, + "propose-upgrade-delay": proposalCopy, + "apply-upgrade": applyUpgradeCopy, + "cancel-upgrade": cancelUpgradeCopy, + "propose-wind-down": proposeWindDownCopy, + "cancel-wind-down": cancelWindDownCopy, +} + const adminContractErrorReason = (refuser: AdminCopy["refuser"]) => (code: number): string => { @@ -1060,6 +1181,38 @@ const adminContractErrorReason = 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 2002: + return "The role's holders changed before this was recorded." + case 2007: + return "The account does not hold this role." + case 2010: + return `The ${refuser} already has the maximum number of roles.` + case 2200: + return "There is no pending handover." + case 2201: + return "The expiry is outside what the network allows." + case 2202: + return "The pending handover is to a different address." + case 2203: + return "The handover expired before it was accepted." + case 6046: + return "The wind-down is already active." + case 6047: + return "No wind-down is proposed." + case 6048: + return "A wind-down is already proposed." + case 6055: + return "An upgrade is already pending." + case 6056: + return "No upgrade is pending." + case 6057: + return "The upgrade delay has not passed yet." + case 6058: + return "The upgrade delay cannot be under 7 days." + case 6059: + return "The upgrade delay cannot be under the notice." + case 6060: + return "The upgrade delay cannot exceed 90 days." case 3001: return "One of the oracle limits is out of range." case 3008: @@ -1104,7 +1257,9 @@ const describeAdminStatus = ( return { ...describeFailure( status.failure, - adminContractErrorReason(copy.refuser), + (code) => + copy.reasons?.[code] ?? + adminContractErrorReason(copy.refuser)(code), followUp, copy.refuser, ), @@ -1182,6 +1337,32 @@ const describeStatus = ( props.status, CONFIGURATION_FOLLOW_UP, ) + case "grant-vault-role": + case "revoke-vault-role": + case "grant-oracle-role": + case "revoke-oracle-role": + case "grant-token-role": + case "revoke-token-role": + case "hand-over-vault-admin": + case "cancel-vault-handover": + case "accept-vault-admin": + case "hand-over-oracle-admin": + case "cancel-oracle-handover": + case "accept-oracle-admin": + case "hand-over-token-admin": + case "cancel-token-handover": + case "accept-token-admin": + case "propose-code-upgrade": + case "propose-upgrade-delay": + case "apply-upgrade": + case "cancel-upgrade": + case "propose-wind-down": + case "cancel-wind-down": + return describeAdminStatus( + GOVERNANCE_COPY[props.action](props.value), + props.status, + GOVERNANCE_FOLLOW_UP, + ) } } diff --git a/app/src/hooks/pendingAdmins.ts b/app/src/hooks/pendingAdmins.ts new file mode 100644 index 0000000..d33475b --- /dev/null +++ b/app/src/hooks/pendingAdmins.ts @@ -0,0 +1,32 @@ +import { readPendingAdmins } from "@stellar-scaffold/app-lib" +import { queryOptions, skipToken } from "@tanstack/react-query" + +export const governanceReadsKey = ["governance", "reads"] as const + +export type GovernedContract = "vault" | "oracle" | "token" + +export const GOVERNED_CONTRACTS: GovernedContract[] = [ + "vault", + "oracle", + "token", +] + +export const pendingAdminsQuery = ( + contracts: Record | undefined, +) => + queryOptions({ + queryKey: [...governanceReadsKey, "pendingAdmins", contracts] as const, + queryFn: + contracts === undefined + ? skipToken + : async () => { + const read = await readPendingAdmins( + GOVERNED_CONTRACTS.map((contract) => contracts[contract]), + ) + if (read.kind !== "value") { + throw new Error("The pending admins could not be read.") + } + return read.value + }, + staleTime: 30_000, + }) diff --git a/app/src/hooks/useAdminAuthority.test.ts b/app/src/hooks/useAdminAuthority.test.ts index 65279e4..dbb24c0 100644 --- a/app/src/hooks/useAdminAuthority.test.ts +++ b/app/src/hooks/useAdminAuthority.test.ts @@ -1,9 +1,12 @@ import type * as AppLib from "@stellar-scaffold/app-lib" import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { vaultContractId } from "../config/contracts" +import { pendingAdminsQuery } from "./pendingAdmins" import { renderWithWallet, investorAddress } from "./testSupport" import { type AdminSurface, + adminAuthorityKey, type RoleHolders, toSurfaces, useAdminAuthority, @@ -23,7 +26,11 @@ const noHolders: RoleHolders = { "oracle guardian": [], "oracle admin": [], "share-token compliance": [], + "share-token manager": [], "share-token admin": [], + "vault pending admin": [], + "oracle pending admin": [], + "share-token pending admin": [], } const holders = (overrides: Partial): RoleHolders => ({ @@ -72,9 +79,12 @@ describe("toSurfaces", () => { ["share-token compliance", ["compliance"]], ["vault guardian", ["emergency"]], ["oracle guardian", ["emergency"]], - ["share-token admin", ["emergency", "configuration"]], - ["oracle admin", ["emergency", "configuration"]], + ["share-token admin", ["emergency", "configuration", "governance"]], + ["oracle admin", ["emergency", "configuration", "governance"]], ["vault governance", ["emergency", "configuration", "governance"]], + ["vault pending admin", ["governance"]], + ["oracle pending admin", ["governance"]], + ["share-token pending admin", ["governance"]], ])("grants %s's surfaces to the wallet that holds it", (role, expected) => { const { surfaces, grantedBy, grants } = toSurfaces( investorAddress, @@ -288,12 +298,12 @@ const { connectNavOracleMock, connectShareTokenMock, accountSignersMock, + readPendingAdminsMock, } = vi.hoisted(() => ({ vaultMock: { governance: vi.fn(), - manager: vi.fn(), - treasury: vi.fn(), - guardian: vi.fn(), + get_role_member_count: vi.fn(), + get_role_member: vi.fn(), oracle: vi.fn(), share_token: vi.fn(), }, @@ -311,6 +321,7 @@ const { connectNavOracleMock: vi.fn(), connectShareTokenMock: vi.fn(), accountSignersMock: vi.fn(), + readPendingAdminsMock: vi.fn(), })) vi.mock("../config/clients", () => ({ @@ -324,9 +335,25 @@ vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => { connectNavOracle: connectNavOracleMock, connectShareToken: connectShareTokenMock, accountSigners: accountSignersMock, + readPendingAdmins: readPendingAdminsMock, } }) +const pendingAdmins = ( + byContract: Record, +) => ({ + kind: "value", + value: { + ledger: { sequence: 1_000, closeTime: 0n, maxLiveUntil: null }, + pending: new Map( + Object.entries(byContract).map(([contract, transfer]) => [ + contract, + { ...transfer, liveUntilLedger: 2_000 }, + ]), + ), + }, +}) + const members = ( client: { get_role_member: ReturnType }, addresses: string[], @@ -336,6 +363,19 @@ const members = ( ) } +const vaultRoles = (byRole: Record) => { + vaultMock.get_role_member_count.mockImplementation( + async ({ role }: { role: string }) => ({ + result: byRole[role]?.length ?? 0, + }), + ) + vaultMock.get_role_member.mockImplementation( + async ({ role, index }: { role: string; index: number }) => ({ + result: byRole[role]?.[index], + }), + ) +} + describe("useAdminAuthority", () => { const derivedOracleAddress = "CDERIVEDORACLEADDRESS" const derivedShareTokenAddress = "CDERIVEDSHARETOKENADDRESS" @@ -343,9 +383,7 @@ describe("useAdminAuthority", () => { beforeEach(() => { vi.clearAllMocks() vaultMock.governance.mockResolvedValue({ result: null }) - vaultMock.manager.mockResolvedValue({ result: investorAddress }) - vaultMock.treasury.mockResolvedValue({ result: null }) - vaultMock.guardian.mockResolvedValue({ result: null }) + vaultRoles({ manager: [investorAddress] }) vaultMock.oracle.mockResolvedValue({ result: derivedOracleAddress }) vaultMock.share_token.mockResolvedValue({ result: derivedShareTokenAddress, @@ -360,6 +398,7 @@ describe("useAdminAuthority", () => { result: "CVERIFIERADDRESS", }) accountSignersMock.mockResolvedValue(singleKey) + readPendingAdminsMock.mockResolvedValue(pendingAdmins({})) }) it("reports the held surfaces and the derived addresses once every read resolves", async () => { @@ -430,6 +469,107 @@ describe("useAdminAuthority", () => { expect(result.current.surfaces).toEqual(new Set(["cycle", "compliance"])) }) + it("enumerates every holder of a vault role, not only the first", async () => { + vaultRoles({ manager: [otherAddress, investorAddress], treasury: [] }) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + + expect(vaultMock.get_role_member).toHaveBeenCalledWith({ + role: "manager", + index: 1, + }) + expect(result.current.holders["vault manager"]).toEqual([ + otherAddress, + investorAddress, + ]) + expect(result.current.surfaces).toEqual(new Set(["cycle"])) + }) + + it("lists the share-token manager among the holders without granting it", async () => { + tokenMock.get_role_member_count.mockImplementation( + async ({ role }: { role: string }) => ({ + result: role === "manager" ? 1 : 0, + }), + ) + members(tokenMock, [investorAddress]) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + + expect(result.current.holders["share-token manager"]).toEqual([ + investorAddress, + ]) + expect(result.current.grants.map((grant) => grant.role)).toEqual([ + "vault manager", + ]) + }) + + it("lets the pending admin of a live handover reach the governance surface", async () => { + vaultRoles({}) + readPendingAdminsMock.mockResolvedValue( + pendingAdmins({ + [derivedOracleAddress]: { address: investorAddress, expired: false }, + [derivedShareTokenAddress]: { address: investorAddress, expired: true }, + }), + ) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + + expect(readPendingAdminsMock).toHaveBeenCalledWith([ + vaultContractId, + derivedOracleAddress, + derivedShareTokenAddress, + ]) + expect(result.current.surfaces).toEqual(new Set(["governance"])) + expect(result.current.grants.map((grant) => grant.role)).toEqual([ + "oracle pending admin", + ]) + }) + + it("files the pending admins read where the governance state looks for it", async () => { + const { result, queryClient } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + + expect( + queryClient.getQueryData( + pendingAdminsQuery({ + vault: vaultContractId, + oracle: derivedOracleAddress, + token: derivedShareTokenAddress, + }).queryKey, + ), + ).toEqual(pendingAdmins({}).value) + expect(readPendingAdminsMock).toHaveBeenCalledTimes(1) + }) + + it("keeps the role surfaces when the pending admins cannot be read, and restores them on a later read", async () => { + vaultRoles({ manager: [investorAddress] }) + readPendingAdminsMock.mockResolvedValueOnce({ kind: "unreadable" }) + const { result, queryClient } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + expect(result.current.grants.map((grant) => grant.role)).toEqual([ + "vault manager", + ]) + + readPendingAdminsMock.mockResolvedValue( + pendingAdmins({ + [derivedOracleAddress]: { address: investorAddress, expired: false }, + }), + ) + await queryClient.invalidateQueries({ queryKey: adminAuthorityKey }) + + await waitFor(() => + expect(result.current.grants.map((grant) => grant.role)).toEqual([ + "vault manager", + "oracle pending admin", + ]), + ) + }) + it("reads the share-token admin from get_admin", async () => { tokenMock.get_admin.mockResolvedValue({ result: investorAddress }) const { result } = renderWithWallet(useAdminAuthority) @@ -437,7 +577,7 @@ describe("useAdminAuthority", () => { await waitFor(() => expect(result.current.status).toBe("ready")) expect(result.current.surfaces).toEqual( - new Set(["cycle", "emergency", "configuration"]), + new Set(["cycle", "emergency", "configuration", "governance"]), ) expect(result.current.grantedBy.configuration[0]?.role).toBe( "share-token admin", @@ -451,7 +591,7 @@ describe("useAdminAuthority", () => { await waitFor(() => expect(result.current.status).toBe("ready")) expect(result.current.surfaces).toEqual( - new Set(["cycle", "emergency", "configuration"]), + new Set(["cycle", "emergency", "configuration", "governance"]), ) expect(result.current.grantedBy.configuration).toEqual([ { @@ -463,7 +603,7 @@ describe("useAdminAuthority", () => { }) it("looks up the signers of a multisig oracle admin", async () => { - vaultMock.manager.mockResolvedValue({ result: null }) + vaultRoles({}) oracleMock.get_admin.mockResolvedValue({ result: multisig }) accountSignersMock.mockResolvedValue(oneSignatureOf(1)) const { result } = renderWithWallet(useAdminAuthority) @@ -472,7 +612,7 @@ describe("useAdminAuthority", () => { expect(accountSignersMock).toHaveBeenCalledWith(multisig) expect(result.current.surfaces).toEqual( - new Set(["emergency", "configuration"]), + new Set(["emergency", "configuration", "governance"]), ) }) @@ -486,9 +626,8 @@ describe("useAdminAuthority", () => { }) it("grants a signer of a multisig authority and looks each account up once", async () => { - vaultMock.manager.mockResolvedValue({ result: null }) + vaultRoles({ guardian: [multisig] }) vaultMock.governance.mockResolvedValue({ result: multisig }) - vaultMock.guardian.mockResolvedValue({ result: multisig }) accountSignersMock.mockResolvedValue(oneSignatureOf(2)) const { result } = renderWithWallet(useAdminAuthority) @@ -511,7 +650,7 @@ describe("useAdminAuthority", () => { }) it("reports the holder key's standing under a 2-of-2 authority", async () => { - vaultMock.manager.mockResolvedValue({ result: null }) + vaultRoles({}) vaultMock.governance.mockResolvedValue({ result: investorAddress }) accountSignersMock.mockResolvedValue(oneSignatureOf(2)) const { result } = renderWithWallet(useAdminAuthority) @@ -545,7 +684,7 @@ describe("useAdminAuthority", () => { }) it("never asks Horizon about a contract holder", async () => { - vaultMock.guardian.mockResolvedValue({ result: contractHolder }) + vaultRoles({ manager: [investorAddress], guardian: [contractHolder] }) const { result } = renderWithWallet(useAdminAuthority) await waitFor(() => expect(result.current.status).toBe("ready")) @@ -567,7 +706,9 @@ describe("useAdminAuthority", () => { }) it("reports unreadable when a role-determining read fails, without guessing a surface", async () => { - vaultMock.guardian.mockRejectedValue(new Error("simulation failed")) + vaultMock.get_role_member_count.mockRejectedValue( + new Error("simulation failed"), + ) const { result } = renderWithWallet(useAdminAuthority) await waitFor(() => expect(result.current.status).toBe("unreadable")) diff --git a/app/src/hooks/useAdminAuthority.ts b/app/src/hooks/useAdminAuthority.ts index dd8f73e..e84a2c7 100644 --- a/app/src/hooks/useAdminAuthority.ts +++ b/app/src/hooks/useAdminAuthority.ts @@ -4,10 +4,18 @@ import { connectNavOracle, connectShareToken, type ContractRead, + type PendingAdmins, readContract, } from "@stellar-scaffold/app-lib" -import { skipToken, useQuery } from "@tanstack/react-query" +import { + type QueryClient, + skipToken, + useQuery, + useQueryClient, +} from "@tanstack/react-query" import { asyncVault } from "../config/clients" +import { vaultContractId } from "../config/contracts" +import { pendingAdminsQuery } from "./pendingAdmins" import { useWallet } from "./useWallet" export type AdminSurface = @@ -54,6 +62,7 @@ export interface AdminAuthority { grants: Grant[] signersUnknown: boolean addresses: AdminAddresses + holders: RoleHolders } const ROLE_SURFACES = { @@ -63,9 +72,13 @@ const ROLE_SURFACES = { "share-token compliance": ["compliance"], "vault guardian": ["emergency"], "oracle guardian": ["emergency"], - "share-token admin": ["emergency", "configuration"], - "oracle admin": ["emergency", "configuration"], + "share-token manager": [], + "share-token admin": ["emergency", "configuration", "governance"], + "oracle admin": ["emergency", "configuration", "governance"], "vault governance": ["emergency", "configuration", "governance"], + "vault pending admin": ["governance"], + "oracle pending admin": ["governance"], + "share-token pending admin": ["governance"], } as const satisfies Record type RoleLabel = keyof typeof ROLE_SURFACES @@ -74,6 +87,22 @@ export type RoleHolders = Record const ROLE_LABELS = Object.keys(ROLE_SURFACES) as RoleLabel[] +const emptyHolders = (): RoleHolders => ({ + "vault manager": [], + "vault treasury": [], + "oracle attester": [], + "share-token compliance": [], + "vault guardian": [], + "oracle guardian": [], + "share-token manager": [], + "share-token admin": [], + "oracle admin": [], + "vault governance": [], + "vault pending admin": [], + "oracle pending admin": [], + "share-token pending admin": [], +}) + const emptyGrantedBy = (): Record => ({ cycle: [], compliance: [], @@ -98,6 +127,7 @@ const emptyAuthority = ( grants: [], signersUnknown: false, addresses, + holders: emptyHolders(), }) const toGrants = (grantedBy: Record): Grant[] => { @@ -204,6 +234,16 @@ const asHolders = (read: ContractRead): string[] => const asMembers = (read: ContractRead): string[] => read.kind === "value" ? read.value : [] +const pendingAdminOf = ( + pendingAdmins: PendingAdmins | undefined, + contract: string, +): string[] => { + const transfer = pendingAdmins?.pending.get(contract) + return transfer === undefined || transfer === null || transfer.expired + ? [] + : [transfer.address] +} + async function readSigners( holders: RoleHolders, ): Promise<{ byAccount: Record; unknown: boolean }> { @@ -229,7 +269,10 @@ async function readSigners( return { byAccount, unknown } } -async function fetchAdminAuthority(wallet: string): Promise { +async function fetchAdminAuthority( + wallet: string, + queryClient: QueryClient, +): Promise { const [ governance, manager, @@ -239,9 +282,9 @@ async function fetchAdminAuthority(wallet: string): Promise { shareTokenAddress, ] = await Promise.all([ readContract(async () => (await asyncVault()).governance()), - readContract(async () => (await asyncVault()).manager()), - readContract(async () => (await asyncVault()).treasury()), - readContract(async () => (await asyncVault()).guardian()), + readRoleMembers(asyncVault(), "manager"), + readRoleMembers(asyncVault(), "treasury"), + readRoleMembers(asyncVault(), "guardian"), readContract(async () => (await asyncVault()).oracle()), readContract(async () => (await asyncVault()).share_token()), ]) @@ -262,15 +305,27 @@ async function fetchAdminAuthority(wallet: string): Promise { oracleGuardians, oracleAdmin, tokenCompliance, + tokenManager, tokenAdmin, identityVerifier, + pendingAdmins, ] = await Promise.all([ readRoleMembers(oraclePromise, "attester"), readRoleMembers(oraclePromise, "guardian"), readContract(async () => (await oraclePromise).get_admin()), readRoleMembers(tokenPromise, "compliance"), + readRoleMembers(tokenPromise, "manager"), readContract(async () => (await tokenPromise).get_admin()), readContract(async () => (await tokenPromise).identity_verifier()), + queryClient + .fetchQuery( + pendingAdminsQuery({ + vault: vaultContractId, + oracle: oracleAddress.value, + token: shareTokenAddress.value, + }), + ) + .catch(() => undefined), ]) const addresses: AdminAddresses = { @@ -288,6 +343,7 @@ async function fetchAdminAuthority(wallet: string): Promise { oracleGuardians, oracleAdmin, tokenCompliance, + tokenManager, tokenAdmin, ] if (roleReads.some(isUnreadable)) @@ -295,14 +351,21 @@ async function fetchAdminAuthority(wallet: string): Promise { const holders: RoleHolders = { "vault governance": asHolders(governance), - "vault manager": asHolders(manager), - "vault treasury": asHolders(treasury), - "vault guardian": asHolders(guardian), + "vault manager": asMembers(manager), + "vault treasury": asMembers(treasury), + "vault guardian": asMembers(guardian), "oracle attester": asMembers(oracleAttesters), "oracle guardian": asMembers(oracleGuardians), "oracle admin": asHolders(oracleAdmin), "share-token compliance": asMembers(tokenCompliance), + "share-token manager": asMembers(tokenManager), "share-token admin": asHolders(tokenAdmin), + "vault pending admin": pendingAdminOf(pendingAdmins, vaultContractId), + "oracle pending admin": pendingAdminOf(pendingAdmins, oracleAddress.value), + "share-token pending admin": pendingAdminOf( + pendingAdmins, + shareTokenAddress.value, + ), } const { byAccount, unknown } = await readSigners(holders) @@ -311,6 +374,7 @@ async function fetchAdminAuthority(wallet: string): Promise { ...toSurfaces(wallet, holders, byAccount), signersUnknown: unknown, addresses, + holders, } } @@ -318,10 +382,13 @@ export const adminAuthorityKey = ["admin", "authority"] as const export function useAdminAuthority(): AdminAuthority { const { address } = useWallet() + const queryClient = useQueryClient() const { data } = useQuery({ queryKey: [...adminAuthorityKey, address], queryFn: - address === undefined ? skipToken : () => fetchAdminAuthority(address), + address === undefined + ? skipToken + : () => fetchAdminAuthority(address, queryClient), staleTime: 30_000, refetchOnWindowFocus: false, }) diff --git a/app/src/hooks/useAdminTransaction.test.ts b/app/src/hooks/useAdminTransaction.test.ts index 799a6eb..919833e 100644 --- a/app/src/hooks/useAdminTransaction.test.ts +++ b/app/src/hooks/useAdminTransaction.test.ts @@ -1,6 +1,8 @@ +import { Buffer } from "buffer" import { AssembledTransaction } from "@stellar/stellar-sdk/contract" import { act, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" +import { governanceReadsKey } from "./pendingAdmins" import { deferred, investorAddress, renderWithWallet } from "./testSupport" import { adminAuthorityKey } from "./useAdminAuthority" import { configurationReadsKey } from "./useConfigurationState" @@ -23,6 +25,30 @@ import { useUnpauseToken, useUnpauseVault, } from "./useEmergencyTransactions" +import { upgradeProposalKey } from "./useGovernanceState" +import { + useAcceptOracleAdmin, + useAcceptTokenAdmin, + useAcceptVaultAdmin, + useApplyUpgrade, + useCancelOracleHandover, + useCancelTokenHandover, + useCancelUpgrade, + useCancelVaultHandover, + useCancelWindDown, + useGrantOracleRole, + useGrantTokenRole, + useGrantVaultRole, + useHandOverOracleAdmin, + useHandOverTokenAdmin, + useHandOverVaultAdmin, + useProposeCodeUpgrade, + useProposeUpgradeDelay, + useProposeWindDown, + useRevokeOracleRole, + useRevokeTokenRole, + useRevokeVaultRole, +} from "./useGovernanceTransactions" import { isAllowedKey } from "./useIsAllowed" import { oracleWriteKeys } from "./useOracleState" import { tokenPausedKey } from "./useTokenPaused" @@ -37,18 +63,36 @@ const { vault, token, oracle } = vi.hoisted(() => ({ set_custodian: vi.fn(), set_notice: vi.fn(), set_wind_down_delay: vi.fn(), + grant_role: vi.fn(), + revoke_role: vi.fn(), + transfer_admin_role: vi.fn(), + accept_admin_transfer: vi.fn(), + propose_upgrade: vi.fn(), + propose_upgrade_delay: vi.fn(), + apply_upgrade: vi.fn(), + cancel_upgrade: vi.fn(), + propose_wind_down: vi.fn(), + cancel_wind_down_proposal: vi.fn(), }, token: { pause: vi.fn(), unpause: vi.fn(), set_compliance: vi.fn(), set_identity_verifier: vi.fn(), + grant_role: vi.fn(), + revoke_role: vi.fn(), + transfer_admin_role: vi.fn(), + accept_admin_transfer: vi.fn(), }, oracle: { raise_ripcord: vi.fn(), set_ripcord: vi.fn(), clear_latest: vi.fn(), set_config: vi.fn(), + grant_role: vi.fn(), + revoke_role: vi.fn(), + transfer_admin_role: vi.fn(), + accept_admin_transfer: vi.fn(), }, })) @@ -76,7 +120,7 @@ type Case = { } method: ReturnType arg: unknown - args: object + args?: object keys: readonly (readonly string[])[] refusal: number } @@ -210,6 +254,171 @@ const cases: Case[] = [ }, ] +const ROLE_CHANGE = { role: "guardian", account: CONTRACT } +const OFFER = { newAdmin: CONTRACT, liveUntilLedger: 1_234 } +const WASM_HASH = "ab".repeat(32) + +type Hook = Case["useHook"] + +const governedContracts: { + name: string + client: typeof vault | typeof token | typeof oracle + hooks: Record<"grant" | "revoke" | "handOver" | "cancel" | "accept", Hook> + roleKeys: Case["keys"] + handoverKeys: Case["keys"] +}[] = [ + { + name: "Vault", + client: vault, + hooks: { + grant: useGrantVaultRole, + revoke: useRevokeVaultRole, + handOver: useHandOverVaultAdmin, + cancel: useCancelVaultHandover, + accept: useAcceptVaultAdmin, + }, + roleKeys: [adminAuthorityKey, vaultAuthoritiesKey], + handoverKeys: [governanceReadsKey, adminAuthorityKey, vaultAuthoritiesKey], + }, + { + name: "Oracle", + client: oracle, + hooks: { + grant: useGrantOracleRole, + revoke: useRevokeOracleRole, + handOver: useHandOverOracleAdmin, + cancel: useCancelOracleHandover, + accept: useAcceptOracleAdmin, + }, + roleKeys: [adminAuthorityKey], + handoverKeys: [governanceReadsKey, adminAuthorityKey], + }, + { + name: "Token", + client: token, + hooks: { + grant: useGrantTokenRole, + revoke: useRevokeTokenRole, + handOver: useHandOverTokenAdmin, + cancel: useCancelTokenHandover, + accept: useAcceptTokenAdmin, + }, + roleKeys: [adminAuthorityKey], + handoverKeys: [governanceReadsKey, adminAuthorityKey], + }, +] + +const roleArgs = { + account: CONTRACT, + role: "guardian", + caller: investorAddress, +} + +const governanceCases: Case[] = [ + ...governedContracts.flatMap( + ({ name, client, hooks, roleKeys, handoverKeys }): Case[] => [ + { + name: `useGrant${name}Role`, + useHook: hooks.grant, + method: client.grant_role, + arg: ROLE_CHANGE, + args: roleArgs, + keys: roleKeys, + refusal: 2000, + }, + { + name: `useRevoke${name}Role`, + useHook: hooks.revoke, + method: client.revoke_role, + arg: ROLE_CHANGE, + args: roleArgs, + keys: roleKeys, + refusal: 2007, + }, + { + name: `useHandOver${name}Admin`, + useHook: hooks.handOver, + method: client.transfer_admin_role, + arg: OFFER, + args: { new_admin: CONTRACT, live_until_ledger: 1_234 }, + keys: handoverKeys, + refusal: 2201, + }, + { + name: `useCancel${name}Handover`, + useHook: hooks.cancel, + method: client.transfer_admin_role, + arg: { ...OFFER, liveUntilLedger: 0 }, + args: { new_admin: CONTRACT, live_until_ledger: 0 }, + keys: handoverKeys, + refusal: 2200, + }, + { + name: `useAccept${name}Admin`, + useHook: hooks.accept, + method: client.accept_admin_transfer, + arg: undefined, + keys: handoverKeys, + refusal: 2202, + }, + ], + ), + { + name: "useProposeCodeUpgrade", + useHook: useProposeCodeUpgrade, + method: vault.propose_upgrade, + arg: WASM_HASH, + args: { wasm_hash: Buffer.alloc(32, 0xab), caller: investorAddress }, + keys: [upgradeProposalKey, configurationReadsKey], + refusal: 6055, + }, + { + name: "useProposeUpgradeDelay", + useHook: useProposeUpgradeDelay, + method: vault.propose_upgrade_delay, + arg: 604_800n, + args: { secs: 604_800n, caller: investorAddress }, + keys: [upgradeProposalKey, configurationReadsKey], + refusal: 6058, + }, + { + name: "useApplyUpgrade", + useHook: useApplyUpgrade, + method: vault.apply_upgrade, + arg: undefined, + args: { caller: investorAddress }, + keys: [upgradeProposalKey, configurationReadsKey], + refusal: 6057, + }, + { + name: "useCancelUpgrade", + useHook: useCancelUpgrade, + method: vault.cancel_upgrade, + arg: undefined, + args: { caller: investorAddress }, + keys: [upgradeProposalKey, configurationReadsKey], + refusal: 6056, + }, + { + name: "useProposeWindDown", + useHook: useProposeWindDown, + method: vault.propose_wind_down, + arg: undefined, + args: { caller: investorAddress }, + keys: [cycleReadsKey], + refusal: 6046, + }, + { + name: "useCancelWindDown", + useHook: useCancelWindDown, + method: vault.cancel_wind_down_proposal, + arg: undefined, + args: { caller: investorAddress }, + keys: [cycleReadsKey], + refusal: 6047, + }, +] + const confirmed = () => ({ simulation: undefined, signAndSend: vi.fn().mockResolvedValue({ @@ -218,7 +427,7 @@ const confirmed = () => ({ }), }) -describe.each(cases)("$name", (testCase) => { +describe.each([...cases, ...governanceCases])("$name", (testCase) => { const { useHook, method, arg, args, keys, refusal } = testCase beforeEach(() => { @@ -251,7 +460,7 @@ describe.each(cases)("$name", (testCase) => { await waitFor(() => expect(result.current.status).toEqual({ status: "confirmed" }), ) - expect(method).toHaveBeenCalledWith(args) + expect(method.mock.calls).toEqual([args === undefined ? [] : [args]]) }) it("refuses before asking for a signature and refreshes the state it read", async () => { diff --git a/app/src/hooks/useGovernanceState.test.ts b/app/src/hooks/useGovernanceState.test.ts new file mode 100644 index 0000000..2550246 --- /dev/null +++ b/app/src/hooks/useGovernanceState.test.ts @@ -0,0 +1,190 @@ +import { Buffer } from "buffer" +import type * as AppLib from "@stellar-scaffold/app-lib" +import { waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { governanceReadsKey } from "./pendingAdmins" +import { renderWithWallet } from "./testSupport" +import { useGovernanceState } from "./useGovernanceState" + +const { + readPendingAdminsMock, + vaultMock, + useCycleReadsMock, + useConfigurationStateMock, + useVaultPausedMock, +} = vi.hoisted(() => ({ + readPendingAdminsMock: vi.fn(), + vaultMock: { upgrade_proposal: vi.fn() }, + useCycleReadsMock: vi.fn(), + useConfigurationStateMock: vi.fn(), + useVaultPausedMock: vi.fn(), +})) + +vi.mock("../config/clients", () => ({ asyncVault: async () => vaultMock })) +vi.mock("./useCycleState", async (importOriginal) => ({ + ...(await importOriginal()), + useCycleReads: useCycleReadsMock, +})) +vi.mock("./useConfigurationState", async (importOriginal) => ({ + ...(await importOriginal()), + useConfigurationState: useConfigurationStateMock, +})) +vi.mock("./useVaultPaused", async (importOriginal) => ({ + ...(await importOriginal()), + useVaultPaused: useVaultPausedMock, +})) + +vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => ({ + ...(await importOriginal()), + readPendingAdmins: readPendingAdminsMock, +})) + +const VAULT = "CVAULT" +const ORACLE = "CORACLE" +const TOKEN = "CTOKEN" +const LEDGER = { + sequence: 1_000, + closeTime: 1_700_000_000n, + maxLiveUntil: null, +} +const TRANSFER = { address: "GNEXT", liveUntilLedger: 2_000, expired: false } + +const DAY = 86_400n +const WIND_DOWN = { + phase: "none", + activeAt: null, + round: null, + delay: DAY, + owed: null, + supply: null, +} + +const render = () => + renderWithWallet(() => + useGovernanceState({ + contracts: { vault: VAULT, oracle: ORACLE, token: TOKEN }, + }), + ) + +describe("useGovernanceState", () => { + beforeEach(() => { + vi.clearAllMocks() + readPendingAdminsMock.mockResolvedValue({ kind: "unreadable" }) + vaultMock.upgrade_proposal.mockResolvedValue({ result: null }) + useCycleReadsMock.mockReturnValue({ + ledgerTime: 1_800_000_000n, + windDown: WIND_DOWN, + }) + useConfigurationStateMock.mockReturnValue({ + configuration: { + status: "ready", + upgradeDelay: 7n * DAY, + notice: DAY, + }, + }) + useVaultPausedMock.mockReturnValue({ pause: "open" }) + }) + + it("files each contract's pending transfer under its own name", async () => { + readPendingAdminsMock.mockResolvedValue({ + kind: "value", + value: { + ledger: LEDGER, + pending: new Map([ + [VAULT, null], + [ORACLE, TRANSFER], + [TOKEN, null], + ]), + }, + }) + const { result } = render() + + await waitFor(() => expect(result.current.handovers.status).toBe("ready")) + expect(readPendingAdminsMock).toHaveBeenCalledWith([VAULT, ORACLE, TOKEN]) + expect(result.current.handovers).toEqual({ + status: "ready", + ledger: LEDGER, + pending: { vault: null, oracle: TRANSFER, token: null }, + }) + }) + + it("keeps checking, without reading, until the contract addresses are known", async () => { + const { result } = renderWithWallet(() => + useGovernanceState({ contracts: undefined }), + ) + + await waitFor(() => expect(result.current.upgrades.status).toBe("ready")) + expect(result.current.handovers).toEqual({ status: "checking" }) + expect(readPendingAdminsMock).not.toHaveBeenCalled() + }) + + it("reports unreadable when the ledger cannot be read", async () => { + readPendingAdminsMock.mockResolvedValue({ kind: "unreadable" }) + const { result } = render() + + await waitFor(() => + expect(result.current.handovers.status).toBe("unreadable"), + ) + }) + + it("restores the handovers once a later read succeeds", async () => { + const { result, queryClient } = render() + await waitFor(() => + expect(result.current.handovers.status).toBe("unreadable"), + ) + + readPendingAdminsMock.mockResolvedValue({ + kind: "value", + value: { ledger: LEDGER, pending: new Map([[ORACLE, TRANSFER]]) }, + }) + await queryClient.refetchQueries({ queryKey: governanceReadsKey }) + + await waitFor(() => expect(result.current.handovers.status).toBe("ready")) + }) + + it("gathers the upgrade delay, notice, wind-down, ledger time and pause", async () => { + const { result } = render() + + await waitFor(() => expect(result.current.upgrades.status).toBe("ready")) + expect(result.current.upgrades).toEqual({ + status: "ready", + proposal: null, + delay: 7n * DAY, + notice: DAY, + windDown: WIND_DOWN, + now: 1_800_000_000n, + paused: "open", + }) + }) + + it.each([ + [ + { action: { tag: "Wasm", values: [Buffer.alloc(32, 0xab)] }, eta: 5n }, + { kind: "code", hash: "ab".repeat(32), eta: 5n }, + ], + [ + { action: { tag: "Delay", values: [14n * DAY] }, eta: 5n }, + { kind: "delay", secs: 14n * DAY, eta: 5n }, + ], + ])("decodes a pending upgrade proposal", async (raw, proposal) => { + vaultMock.upgrade_proposal.mockResolvedValue({ result: raw }) + const { result } = render() + + await waitFor(() => expect(result.current.upgrades.status).toBe("ready")) + expect(result.current.upgrades).toMatchObject({ proposal }) + }) + + it("keeps an unreadable proposal unknown and waits for the configuration", async () => { + vaultMock.upgrade_proposal.mockRejectedValue(new Error("down")) + const { result, rerender } = render() + + await waitFor(() => expect(result.current.upgrades.status).toBe("ready")) + expect(result.current.upgrades).toMatchObject({ proposal: undefined }) + + useConfigurationStateMock.mockReturnValue({ + configuration: { status: "checking" }, + }) + rerender() + expect(result.current.upgrades).toEqual({ status: "checking" }) + }) +}) diff --git a/app/src/hooks/useGovernanceState.ts b/app/src/hooks/useGovernanceState.ts new file mode 100644 index 0000000..8b673c2 --- /dev/null +++ b/app/src/hooks/useGovernanceState.ts @@ -0,0 +1,122 @@ +import { + type LedgerWindow, + type PendingAdmins, + type PendingTransfer, + readContract, +} from "@stellar-scaffold/app-lib" +import { useQuery } from "@tanstack/react-query" +import { asyncVault } from "../config/clients" +import { type GovernedContract, pendingAdminsQuery } from "./pendingAdmins" +import { useConfigurationState } from "./useConfigurationState" +import { type CycleWindDown, useCycleReads } from "./useCycleState" +import { type PauseState, useVaultPaused } from "./useVaultPaused" + +const STALE_MS = 30_000 +const REFRESH_MS = 15_000 + +export const upgradeProposalKey = ["governance", "upgradeProposal"] as const + +export type Handovers = + | { status: "checking" } + | { status: "unreadable" } + | { + status: "ready" + ledger: LedgerWindow + pending: Record + } + +export type PendingUpgrade = + | { kind: "code"; hash: string; eta: bigint } + | { kind: "delay"; secs: bigint; eta: bigint } + +export type Upgrades = + | { status: "checking" } + | { + status: "ready" + proposal: PendingUpgrade | null | undefined + delay: bigint | null + notice: bigint | null + windDown: CycleWindDown + now: bigint | null + paused: PauseState + } + +type ProposalRead = { proposal: PendingUpgrade | null | undefined } + +async function fetchUpgradeProposal(): Promise { + const read = await readContract(async () => + (await asyncVault()).upgrade_proposal(), + ) + if (read.kind !== "value") return { proposal: undefined } + if (read.value === null) return { proposal: null } + const { action, eta } = read.value + return { + proposal: + action.tag === "Wasm" + ? { kind: "code", hash: action.values[0].toString("hex"), eta } + : { kind: "delay", secs: action.values[0], eta }, + } +} + +function useUpgrades(active: boolean): Upgrades { + const cycle = useCycleReads(active) + const { configuration } = useConfigurationState({ active }) + const { pause } = useVaultPaused({ poll: active }) + const { data } = useQuery({ + queryKey: upgradeProposalKey, + queryFn: fetchUpgradeProposal, + staleTime: STALE_MS, + refetchInterval: active ? REFRESH_MS : false, + }) + if ( + cycle === undefined || + data === undefined || + configuration.status !== "ready" + ) + return { status: "checking" } + return { + status: "ready", + proposal: data.proposal, + delay: configuration.upgradeDelay, + notice: configuration.notice, + windDown: cycle.windDown, + now: cycle.ledgerTime, + paused: pause, + } +} + +function toHandovers( + read: PendingAdmins, + contracts: Record, +): Handovers { + const pendingOf = (contract: GovernedContract) => + read.pending.get(contracts[contract]) ?? null + return { + status: "ready", + ledger: read.ledger, + pending: { + vault: pendingOf("vault"), + oracle: pendingOf("oracle"), + token: pendingOf("token"), + }, + } +} + +export function useGovernanceState({ + contracts, + active = true, +}: { + contracts: Record | undefined + active?: boolean +}): { handovers: Handovers; upgrades: Upgrades } { + const { data, isError } = useQuery({ + ...pendingAdminsQuery(contracts), + refetchInterval: active ? REFRESH_MS : false, + }) + const upgrades = useUpgrades(active) + let handovers: Handovers = { status: "checking" } + if (data !== undefined && contracts !== undefined) + handovers = toHandovers(data, contracts) + else if (isError) handovers = { status: "unreadable" } + return { handovers, upgrades } +} diff --git a/app/src/hooks/useGovernanceTransactions.ts b/app/src/hooks/useGovernanceTransactions.ts new file mode 100644 index 0000000..0c09b47 --- /dev/null +++ b/app/src/hooks/useGovernanceTransactions.ts @@ -0,0 +1,166 @@ +import { Buffer } from "buffer" +import { type AsyncVaultApi, type Signer } from "@stellar-scaffold/app-lib" +import { type QueryKey } from "@tanstack/react-query" +import { + asyncVaultWriter, + navOracleWriter, + shareTokenWriter, +} from "../config/clients" +import { governanceReadsKey } from "./pendingAdmins" +import { adminAuthorityKey } from "./useAdminAuthority" +import { adminTransaction } from "./useAdminTransaction" +import { configurationReadsKey } from "./useConfigurationState" +import { cycleReadsKey } from "./useCycleState" +import { upgradeProposalKey } from "./useGovernanceState" +import { vaultAuthoritiesKey } from "./useVaultAuthorities" + +export type RoleChange = { role: string; account: string } + +type RoleClient = Pick + +const roleChange = ( + connect: (signer: Signer) => Promise, + method: keyof RoleClient, + keys: readonly QueryKey[], +) => + adminTransaction( + (signer: Signer, { role, account }: RoleChange) => + connect(signer).then((client) => + client[method]({ account, role, caller: signer.publicKey }), + ), + keys, + ) + +const VAULT_KEYS = [adminAuthorityKey, vaultAuthoritiesKey] + +export const useGrantVaultRole = roleChange( + asyncVaultWriter, + "grant_role", + VAULT_KEYS, +) +export const useRevokeVaultRole = roleChange( + asyncVaultWriter, + "revoke_role", + VAULT_KEYS, +) +export const useGrantOracleRole = roleChange(navOracleWriter, "grant_role", [ + adminAuthorityKey, +]) +export const useRevokeOracleRole = roleChange(navOracleWriter, "revoke_role", [ + adminAuthorityKey, +]) +export const useGrantTokenRole = roleChange(shareTokenWriter, "grant_role", [ + adminAuthorityKey, +]) +export const useRevokeTokenRole = roleChange(shareTokenWriter, "revoke_role", [ + adminAuthorityKey, +]) + +export type AdminOffer = { newAdmin: string; liveUntilLedger: number } + +type AdminClient = Pick< + AsyncVaultApi, + "transfer_admin_role" | "accept_admin_transfer" +> + +type Connect = (signer: Signer) => Promise + +const offerAdmin = (connect: Connect, keys: readonly QueryKey[]) => + adminTransaction( + (signer: Signer, { newAdmin, liveUntilLedger }: AdminOffer) => + connect(signer).then((client) => + client.transfer_admin_role({ + new_admin: newAdmin, + live_until_ledger: liveUntilLedger, + }), + ), + keys, + ) + +const acceptAdmin = (connect: Connect, keys: readonly QueryKey[]) => + adminTransaction( + (signer: Signer) => + connect(signer).then((client) => client.accept_admin_transfer()), + keys, + ) + +const VAULT_HANDOVER_KEYS = [governanceReadsKey, ...VAULT_KEYS] +const HANDOVER_KEYS = [governanceReadsKey, adminAuthorityKey] + +export const useHandOverVaultAdmin = offerAdmin( + asyncVaultWriter, + VAULT_HANDOVER_KEYS, +) +export const useCancelVaultHandover = offerAdmin( + asyncVaultWriter, + VAULT_HANDOVER_KEYS, +) +export const useAcceptVaultAdmin = acceptAdmin( + asyncVaultWriter, + VAULT_HANDOVER_KEYS, +) +export const useHandOverOracleAdmin = offerAdmin(navOracleWriter, HANDOVER_KEYS) +export const useCancelOracleHandover = offerAdmin( + navOracleWriter, + HANDOVER_KEYS, +) +export const useAcceptOracleAdmin = acceptAdmin(navOracleWriter, HANDOVER_KEYS) +export const useHandOverTokenAdmin = offerAdmin(shareTokenWriter, HANDOVER_KEYS) +export const useCancelTokenHandover = offerAdmin( + shareTokenWriter, + HANDOVER_KEYS, +) +export const useAcceptTokenAdmin = acceptAdmin(shareTokenWriter, HANDOVER_KEYS) + +const UPGRADE_KEYS = [upgradeProposalKey, configurationReadsKey] + +export const useProposeCodeUpgrade = adminTransaction( + (signer: Signer, hash: string) => + asyncVaultWriter(signer).then((vault) => + vault.propose_upgrade({ + wasm_hash: Buffer.from(hash, "hex"), + caller: signer.publicKey, + }), + ), + UPGRADE_KEYS, +) + +export const useProposeUpgradeDelay = adminTransaction( + (signer: Signer, secs: bigint) => + asyncVaultWriter(signer).then((vault) => + vault.propose_upgrade_delay({ secs, caller: signer.publicKey }), + ), + UPGRADE_KEYS, +) + +export const useApplyUpgrade = adminTransaction( + (signer: Signer) => + asyncVaultWriter(signer).then((vault) => + vault.apply_upgrade({ caller: signer.publicKey }), + ), + UPGRADE_KEYS, +) + +export const useCancelUpgrade = adminTransaction( + (signer: Signer) => + asyncVaultWriter(signer).then((vault) => + vault.cancel_upgrade({ caller: signer.publicKey }), + ), + UPGRADE_KEYS, +) + +export const useProposeWindDown = adminTransaction( + (signer: Signer) => + asyncVaultWriter(signer).then((vault) => + vault.propose_wind_down({ caller: signer.publicKey }), + ), + [cycleReadsKey], +) + +export const useCancelWindDown = adminTransaction( + (signer: Signer) => + asyncVaultWriter(signer).then((vault) => + vault.cancel_wind_down_proposal({ caller: signer.publicKey }), + ), + [cycleReadsKey], +) diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx index 6b9cd69..4a7a704 100644 --- a/app/src/pages/AdminPanel.test.tsx +++ b/app/src/pages/AdminPanel.test.tsx @@ -9,7 +9,11 @@ import type React from "react" import { MemoryRouter, Route, Routes } from "react-router-dom" import { beforeEach, describe, expect, it, vi } from "vitest" import type * as UseAdminAuthority from "../hooks/useAdminAuthority" -import { type AdminAuthority, type Grant } from "../hooks/useAdminAuthority" +import { + type AdminAuthority, + type Grant, + type RoleHolders, +} from "../hooks/useAdminAuthority" import AdminPanel from "./AdminPanel" const { useAdminAuthorityMock, useWalletMock } = vi.hoisted(() => ({ @@ -80,6 +84,28 @@ vi.mock("../components/admin/ConfigurationSection", () => ({ ), })) +vi.mock("../components/admin/GovernanceSection", () => ({ + default: ({ + grants, + holders, + contracts, + active, + }: { + grants: Grant[] + holders: RoleHolders + contracts: React.ReactNode + active: boolean + }) => ( +
    +

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

    +

    Managers {holders["vault manager"].join(", ")}

    + {contracts} +
    + ), +})) + const connectedAddress = "GCONNECTEDADDRESS1234567890" const emptyGrantedBy = { @@ -98,6 +124,22 @@ const holderOf = (role: string): Grant => ({ const noAddresses = { oracle: null, shareToken: null, identityVerifier: null } +const noHolders: RoleHolders = { + "vault governance": [], + "vault manager": [], + "vault treasury": [], + "vault guardian": [], + "oracle attester": [], + "oracle guardian": [], + "oracle admin": [], + "share-token compliance": [], + "share-token manager": [], + "share-token admin": [], + "vault pending admin": [], + "oracle pending admin": [], + "share-token pending admin": [], +} + const authority = (overrides: Partial): AdminAuthority => ({ status: "disconnected", surfaces: new Set(), @@ -105,6 +147,7 @@ const authority = (overrides: Partial): AdminAuthority => ({ grants: [], signersUnknown: false, addresses: noAddresses, + holders: noHolders, ...overrides, }) @@ -303,6 +346,38 @@ describe("AdminPanel", () => { ).toBe("false") }) + it("renders the governance actions with the governance grants and every holder, active only on its tab", () => { + const governance = holderOf("vault governance") + const tokenAdmin = holderOf("share-token admin") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["configuration", "governance"]), + grantedBy: { + ...emptyGrantedBy, + configuration: [governance, tokenAdmin], + governance: [governance, tokenAdmin], + }, + grants: [governance, tokenAdmin], + holders: { ...noHolders, "vault manager": ["GMANAGER", "GSECOND"] }, + }), + ) + renderAt("/admin/governance") + + expect( + screen.getByText( + "Governance surface for vault governance, share-token admin", + ), + ).toBeTruthy() + expect(screen.getByText("Managers GMANAGER, GSECOND")).toBeTruthy() + expect( + screen.getByTestId("governance-surface").getAttribute("data-active"), + ).toBe("true") + expect( + screen.getByTestId("configuration-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 bd11320..86239ca 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -5,6 +5,7 @@ import ConfigurationSection from "../components/admin/ConfigurationSection" import CycleSection from "../components/admin/CycleSection" import Disclosure from "../components/admin/Disclosure" import EmergencySection from "../components/admin/EmergencySection" +import GovernanceSection from "../components/admin/GovernanceSection" import SurfaceTabs from "../components/admin/SurfaceTabs" import Skeleton from "../components/Skeleton" import AddressRows, { type AddressRow } from "../components/vault/AddressRows" @@ -13,6 +14,7 @@ import { type AdminAddresses, type AdminSurface, type Grant, + type RoleHolders, type Standing, SURFACE_LABELS, SURFACE_ORDER, @@ -66,7 +68,8 @@ const SurfaceBody: React.FC<{ grantedBy: Record wallet: string addresses: AdminAddresses -}> = ({ surface, active, grantedBy, wallet, addresses }) => { + holders: RoleHolders +}> = ({ surface, active, grantedBy, wallet, addresses, holders }) => { const contracts = switch (surface) { case "cycle": @@ -96,8 +99,18 @@ const SurfaceBody: React.FC<{ contracts={contracts} /> ) - case "compliance": case "governance": + return ( + + ) + case "compliance": return contracts } } @@ -107,7 +120,8 @@ const HeldSurfaces: React.FC<{ grantedBy: Record wallet: string addresses: AdminAddresses -}> = ({ held, grantedBy, wallet, addresses }) => { + holders: RoleHolders +}> = ({ held, grantedBy, wallet, addresses, holders }) => { const { surface } = useParams() const active = held.find((candidate) => candidate === surface) const headingRef = useRef(null) @@ -151,6 +165,7 @@ const HeldSurfaces: React.FC<{ grantedBy={grantedBy} wallet={wallet} addresses={addresses} + holders={holders} /> ))} @@ -159,8 +174,15 @@ const HeldSurfaces: React.FC<{ } const AdminPanel: React.FC = () => { - const { status, surfaces, grantedBy, grants, signersUnknown, addresses } = - useAdminAuthority() + const { + status, + surfaces, + grantedBy, + grants, + signersUnknown, + addresses, + holders, + } = useAdminAuthority() const { address } = useWallet() const held = SURFACE_ORDER.filter((surface) => surfaces.has(surface)) @@ -213,6 +235,7 @@ const AdminPanel: React.FC = () => { grantedBy={grantedBy} wallet={address ?? ""} addresses={addresses} + holders={holders} /> )} diff --git a/app/src/pages/actions.test.ts b/app/src/pages/actions.test.ts index e11c220..3102f97 100644 --- a/app/src/pages/actions.test.ts +++ b/app/src/pages/actions.test.ts @@ -39,7 +39,7 @@ describe("readiness", () => { expect(readiness(actionWith({ enabled: true }))).toEqual({ kind: "ready" }) }) - it("counts the conditions not met, an undecided one included", () => { + it("reads an action with a failing or undecided condition as blocked", () => { const action = actionWith({ conditions: [ { label: "Amount above zero", met: false }, @@ -48,7 +48,15 @@ describe("readiness", () => { ], }) - expect(readiness(action)).toEqual({ kind: "unmet", count: 2 }) + expect(readiness(action)).toEqual({ kind: "blocked" }) + }) + + it("reads a closed action as blocked even with every condition passing", () => { + const action = actionWith({ + conditions: [{ label: "Expiry ≥ 1 day", met: true }], + }) + + expect(readiness(action)).toEqual({ kind: "blocked" }) }) it("reads an action whose only unmet condition is no change as unchanged", () => { @@ -62,7 +70,7 @@ describe("readiness", () => { expect(readiness(action)).toEqual({ kind: "unchanged" }) }) - it("keeps counting when something besides no change is unmet", () => { + it("reads an action blocked when something besides no change fails", () => { const action = actionWith({ conditions: [ { label: "Notice ≤ 30d", met: false }, @@ -70,7 +78,7 @@ describe("readiness", () => { ], }) - expect(readiness(action)).toEqual({ kind: "unmet", count: 2 }) + expect(readiness(action)).toEqual({ kind: "blocked" }) }) it("reads an action the wallet cannot sign as unavailable, keeping the reason", () => { diff --git a/app/src/pages/actions.ts b/app/src/pages/actions.ts index b07a675..ea5d531 100644 --- a/app/src/pages/actions.ts +++ b/app/src/pages/actions.ts @@ -17,6 +17,7 @@ export type Condition = { } export type ActionField = { + kind?: undefined key?: string label: string placeholder: string @@ -28,14 +29,32 @@ export type ActionField = { next?: string | null } -export type RailAction = { +export type SelectField = { + kind: "select" + key: string + label: string + value: string + options: { value: string; label: string }[] +} + +export type ConfirmField = { + kind: "confirm" + key: string + label: string + value: string + checked: boolean +} + +export type RailField = ActionField | SelectField | ConfirmField + +export type RailAction = { id: string label: string conditions: Condition[] outcome: string | null note?: string field?: ActionField - fields?: ActionField[] + fields?: F[] enabled: boolean unavailable?: string tone?: "danger" @@ -43,11 +62,11 @@ export type RailAction = { export type Readiness = | { kind: "ready" } - | { kind: "unmet"; count: number } + | { kind: "blocked" } | { kind: "unchanged" } | { kind: "unavailable"; reason: string } -export const readiness = (action: RailAction): Readiness => { +export const readiness = (action: RailAction): Readiness => { if (action.enabled) return { kind: "ready" } if (action.unavailable !== undefined) return { kind: "unavailable", reason: action.unavailable } @@ -55,10 +74,10 @@ export const readiness = (action: RailAction): Readiness => { const [only] = unmet if (unmet.length === 1 && only?.unchanged === true && only.met === false) return { kind: "unchanged" } - return { kind: "unmet", count: unmet.length } + return { kind: "blocked" } } -export const readyCount = (actions: RailAction[]): number => +export const readyCount = (actions: RailAction[]): number => actions.filter((action) => action.enabled).length export const allMet = (conditions: Condition[]): boolean => @@ -90,22 +109,22 @@ export const unavailableFor = ( return undefined } -export type Spec = { +export type Spec = { id: Id label: string conditions: Condition[] outcome: string | null note?: string danger?: boolean - fields?: ActionField[] + fields?: F[] ready?: boolean } -export const toAction = ( - spec: Spec, +export const toAction = ( + spec: Spec, grant: Grant, wallet: string, -): RailAction & { id: Id } => { +): RailAction & { id: Id } => { const unavailable = unavailableFor(grant, wallet) return { id: spec.id, diff --git a/app/src/pages/configuration.ts b/app/src/pages/configuration.ts index a75427f..f4ac60f 100644 --- a/app/src/pages/configuration.ts +++ b/app/src/pages/configuration.ts @@ -29,6 +29,7 @@ import { toAction, } from "./actions" import { + DAY, depositCapValue, exactAmount, formatDuration, @@ -75,7 +76,6 @@ export const EFFECTS = { 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 diff --git a/app/src/pages/cycle.ts b/app/src/pages/cycle.ts index 2591c22..eb1a489 100644 --- a/app/src/pages/cycle.ts +++ b/app/src/pages/cycle.ts @@ -109,8 +109,10 @@ const WIND_DOWN_LABELS = { active: "Active", } as const +export const DAY = 86_400n + const SECONDS_PER_UNIT: [string, bigint][] = [ - ["d", 86_400n], + ["d", DAY], ["h", 3_600n], ["m", 60n], ["s", 1n], diff --git a/app/src/pages/governance.test.ts b/app/src/pages/governance.test.ts new file mode 100644 index 0000000..32912cc --- /dev/null +++ b/app/src/pages/governance.test.ts @@ -0,0 +1,825 @@ +import { type PendingTransfer } from "@stellar-scaffold/app-lib" +import { describe, expect, it } from "vitest" +import { type Metric } from "../components/vault/MetricsStrip" +import { type GovernedContract } from "../hooks/pendingAdmins" +import { type Grant, type RoleHolders } from "../hooks/useAdminAuthority" +import { type Handovers, type Upgrades } from "../hooks/useGovernanceState" +import { type RailField, readiness } from "./actions" +import { formatDuration, formatTimestamp } from "./cycle" +import { + type GovernanceAction, + type GovernanceInputs, + type GovernanceState, + toGovernanceActions, + toGovernanceGroups, +} from "./governance" + +const WALLET = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H" +const ACCOUNT = "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA" +const VAULT = "CABQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYDAMBQGCK3" +const MANAGER = "GACAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAJJHP" + +const grantOf = (role: string): Grant => ({ + role, + authority: WALLET, + standing: "signs-alone", +}) + +const governance = grantOf("vault governance") +const oracleAdmin = grantOf("oracle admin") +const tokenAdmin = grantOf("share-token admin") + +const holders = (overrides: Partial = {}): RoleHolders => ({ + "vault governance": [WALLET], + "vault manager": [MANAGER], + "vault treasury": [], + "vault guardian": [], + "oracle attester": [], + "oracle guardian": [], + "oracle admin": [WALLET], + "share-token compliance": [], + "share-token manager": [VAULT], + "share-token admin": [WALLET], + "vault pending admin": [], + "oracle pending admin": [], + "share-token pending admin": [], + ...overrides, +}) + +const SEQUENCE = 1_000 +const CLOSE_TIME = 1_700_000_000n +const DAY_IN_LEDGERS = 17_280 +const DAY = 86_400n + +type ReadyUpgrades = Extract + +const upgrades = (overrides: Partial = {}): Upgrades => ({ + status: "ready", + proposal: null, + delay: 7n * DAY, + notice: DAY, + windDown: { + phase: "none", + activeAt: null, + round: null, + delay: 3n * DAY, + owed: null, + supply: null, + }, + now: CLOSE_TIME, + paused: "open", + ...overrides, +}) + +const state = ( + overrides: Partial = {}, + pending: Partial> = {}, + maxLiveUntil: number | null = SEQUENCE + 30 * DAY_IN_LEDGERS, +): GovernanceState => ({ + holders: holders(overrides), + vault: VAULT, + handovers: { + status: "ready", + ledger: { sequence: SEQUENCE, closeTime: CLOSE_TIME, maxLiveUntil }, + pending: { + vault: pending.vault ?? null, + oracle: pending.oracle ?? null, + token: pending.token ?? null, + }, + }, + upgrades: upgrades(), +}) + +const find = ( + id: Id, + grants: Grant[], + inputs: GovernanceInputs = {}, + current: GovernanceState = state(), +) => { + const action = toGovernanceActions(current, grants, WALLET, inputs).find( + (candidate): candidate is Extract => + candidate.id === id, + ) + if (action === undefined) throw new Error(`no ${id} action`) + return action +} + +const fieldOf = (action: GovernanceAction, kind: RailField["kind"]) => + action.fields?.find((field) => field.kind === kind) + +const condition = (action: GovernanceAction, label: string) => + action.conditions.find((candidate) => candidate.label === label)?.met + +describe("toGovernanceActions", () => { + it("offers nothing without an admin grant", () => { + expect(toGovernanceActions(state(), [], WALLET, {})).toEqual([]) + }) + + it.each<[Grant, GovernanceAction["id"][]]>([ + [ + governance, + [ + "grant-vault-role", + "revoke-vault-role", + "hand-over-vault-admin", + "propose-code-upgrade", + "propose-upgrade-delay", + "propose-wind-down", + ], + ], + [ + oracleAdmin, + ["grant-oracle-role", "revoke-oracle-role", "hand-over-oracle-admin"], + ], + [ + tokenAdmin, + ["grant-token-role", "revoke-token-role", "hand-over-token-admin"], + ], + ])( + "offers %o role changes and the handover on its own contract", + (grant, ids) => { + expect( + toGovernanceActions(state(), [grant], WALLET, {}).map( + (action) => action.id, + ), + ).toEqual(ids) + }, + ) + + it("lets the admin pick any role of its contract", () => { + const select = fieldOf(find("grant-oracle-role", [oracleAdmin]), "select") + + expect(select).toEqual({ + kind: "select", + key: "grant-oracle-role-role", + label: "Role", + value: "attester", + options: [ + { value: "attester", label: "Attester" }, + { value: "guardian", label: "Guardian" }, + ], + }) + }) + + it("waits for a valid account before enabling", () => { + const empty = find("grant-oracle-role", [oracleAdmin]) + const invalid = find("grant-oracle-role", [oracleAdmin], { + "grant-oracle-role-account": "not an address", + }) + + expect(empty.enabled).toBe(false) + expect(empty.outcome).toBeNull() + expect(fieldOf(invalid, undefined)).toMatchObject({ invalid: true }) + expect(invalid.enabled).toBe(false) + }) + + it("grants a role the account does not hold and shows the resulting holders", () => { + const grant = find("grant-oracle-role", [oracleAdmin], { + "grant-oracle-role-role": "guardian", + "grant-oracle-role-account": ` ${ACCOUNT} `, + }) + + expect(grant.enabled).toBe(true) + expect(grant.arg).toEqual({ role: "guardian", account: ACCOUNT }) + expect(grant.next).toBe("Guardian to GABA...EJXA") + expect(grant.outcome).toBe("Guardian holders: GABA...EJXA") + }) + + it("refuses to grant a role the account already holds", () => { + const grant = find("grant-vault-role", [governance], { + "grant-vault-role-account": MANAGER, + }) + + expect(condition(grant, "Account does not hold the role")).toBe(false) + expect(grant.enabled).toBe(false) + }) + + it("revokes only from a holder and shows who keeps the role", () => { + const second = holders({ "oracle attester": [MANAGER, ACCOUNT] }) + const revoke = find( + "revoke-oracle-role", + [oracleAdmin], + { "revoke-oracle-role-account": ACCOUNT }, + { ...state(), holders: second }, + ) + const notHeld = find("revoke-oracle-role", [oracleAdmin], { + "revoke-oracle-role-account": ACCOUNT, + }) + + expect(revoke.enabled).toBe(true) + expect(revoke.next).toBe("Attester from GABA...EJXA") + expect(revoke.outcome).toBe("Attester holders: GACA...JJHP") + expect(condition(notHeld, "Account holds the role")).toBe(false) + expect(notHeld.enabled).toBe(false) + }) + + it("says when revoking leaves a role without holders", () => { + const revoke = find("revoke-vault-role", [governance], { + "revoke-vault-role-account": MANAGER, + }) + + expect(revoke.outcome).toBe("Manager holders: none") + expect(revoke.enabled).toBe(true) + }) + + it("needs a confirmation to revoke the vault's share-token manager role", () => { + const inputs: GovernanceInputs = { + "revoke-token-role-role": "manager", + "revoke-token-role-account": VAULT, + } + const unconfirmed = find("revoke-token-role", [tokenAdmin], inputs) + const confirm = fieldOf(unconfirmed, "confirm") + + expect(confirm).toMatchObject({ + kind: "confirm", + label: "Stops the vault minting and burning shares.", + checked: false, + }) + expect(unconfirmed.tone).toBe("danger") + expect(unconfirmed.enabled).toBe(false) + + const confirmed = find("revoke-token-role", [tokenAdmin], { + ...inputs, + [confirm?.key ?? ""]: confirm?.value, + }) + expect(fieldOf(confirmed, "confirm")).toMatchObject({ checked: true }) + expect(confirmed.enabled).toBe(true) + }) + + it("needs a confirmation to put a second vault role on one account", () => { + const grant = find("grant-vault-role", [governance], { + "grant-vault-role-role": "guardian", + "grant-vault-role-account": MANAGER, + }) + const toGovernance = find("grant-vault-role", [governance], { + "grant-vault-role-role": "treasury", + "grant-vault-role-account": WALLET, + }) + const separate = find("grant-vault-role", [governance], { + "grant-vault-role-role": "guardian", + "grant-vault-role-account": ACCOUNT, + }) + + expect(fieldOf(grant, "confirm")?.label).toBe( + "Merges two vault roles on one account.", + ) + expect(grant.enabled).toBe(false) + expect(fieldOf(toGovernance, "confirm")).toBeDefined() + expect(fieldOf(separate, "confirm")).toBeUndefined() + expect(separate.enabled).toBe(true) + }) + + it("drops a confirmation once the role or account changes", () => { + const inputs: GovernanceInputs = { + "grant-vault-role-role": "guardian", + "grant-vault-role-account": MANAGER, + } + const confirm = fieldOf( + find("grant-vault-role", [governance], inputs), + "confirm", + ) + const moved = find("grant-vault-role", [governance], { + ...inputs, + "grant-vault-role-role": "treasury", + [confirm?.key ?? ""]: confirm?.value, + }) + + expect(fieldOf(moved, "confirm")).toMatchObject({ checked: false }) + expect(moved.enabled).toBe(false) + }) + + it("defers to the grant standing like every admin action", () => { + const shared: Grant = { ...governance, standing: { needs: 2 } } + const grant = find("grant-vault-role", [shared], { + "grant-vault-role-role": "guardian", + "grant-vault-role-account": ACCOUNT, + }) + + expect(grant.enabled).toBe(false) + expect(grant.unavailable).toMatch(/Needs 2 signatures/) + }) +}) + +describe("admin handover", () => { + const offer = { + "hand-over-vault-admin-account": ACCOUNT, + "hand-over-vault-admin-expiry": "7d", + } + const transfer = (expired = false): PendingTransfer => ({ + address: WALLET, + liveUntilLedger: SEQUENCE + DAY_IN_LEDGERS, + expired, + }) + + it("offers the admin to an account for seven days by default, once confirmed", () => { + const draft = find("hand-over-vault-admin", [governance], { + "hand-over-vault-admin-account": ACCOUNT, + }) + const confirm = fieldOf(draft, "confirm") + + expect( + draft.fields?.find( + (field) => field.key === "hand-over-vault-admin-expiry", + ), + ).toMatchObject({ value: "7d" }) + expect(confirm?.label).toBe("Hands control of the vault to GABA...EJXA.") + expect(draft.tone).toBe("danger") + expect(draft.enabled).toBe(false) + + const confirmed = find("hand-over-vault-admin", [governance], { + ...offer, + [confirm?.key ?? ""]: confirm?.value, + }) + expect(confirmed.enabled).toBe(true) + expect(confirmed.arg).toEqual({ + newAdmin: ACCOUNT, + liveUntilLedger: SEQUENCE + 7 * DAY_IN_LEDGERS, + }) + expect(confirmed.outcome).toBe( + `GABA...EJXA becomes admin once it accepts before ${formatTimestamp( + CLOSE_TIME + 7n * DAY, + )}.`, + ) + }) + + it("names each contract's handover and holds an empty one as blocked", () => { + const current = state( + {}, + { token: { address: ACCOUNT, liveUntilLedger: 5_000, expired: false } }, + ) + const labels = toGovernanceActions( + current, + [governance, oracleAdmin, tokenAdmin], + WALLET, + {}, + ).map((action) => action.label) + const empty = find("hand-over-vault-admin", [governance]) + + expect(labels).toEqual( + expect.arrayContaining([ + "Hand over vault admin", + "Hand over oracle admin", + "Hand over share-token admin", + "Cancel the share-token handover", + ]), + ) + expect(condition(empty, "New admin entered")).toBeNull() + expect(readiness(empty)).toEqual({ kind: "blocked" }) + expect( + readiness( + find("hand-over-vault-admin", [governance], { + "hand-over-vault-admin-account": ACCOUNT, + }), + ), + ).toEqual({ kind: "blocked" }) + }) + + it.each<[string, number | null, string, boolean | null]>([ + ["59s", null, "Expiry ≥ 1m", false], + ["1m", null, "Expiry ≥ 1m", true], + ["1.5d", null, "Expiry ≥ 1m", null], + ["60", null, "Expiry has a unit, e.g. 7d", false], + ["7d", null, "Expiry has a unit, e.g. 7d", true], + ["31d", SEQUENCE + 30 * DAY_IN_LEDGERS, "Expiry ≤ 30d", false], + ["30d", SEQUENCE + 30 * DAY_IN_LEDGERS, "Expiry ≤ 30d", true], + ["30d 5s", SEQUENCE + 30 * DAY_IN_LEDGERS, "Expiry ≤ 30d", false], + ["1.5d", SEQUENCE + 30 * DAY_IN_LEDGERS, "Expiry ≤ 30d", null], + ])( + "bounds an expiry of %s by the network limit %s", + (expiry, max, label, met) => { + const draft = find( + "hand-over-vault-admin", + [governance], + { ...offer, "hand-over-vault-admin-expiry": expiry }, + state({}, {}, max), + ) + + expect(condition(draft, label)).toBe(met) + }, + ) + + it("drops a confirmation given for another expiry", () => { + const draft = find("hand-over-vault-admin", [governance], offer) + const confirm = fieldOf(draft, "confirm") + const longer = find("hand-over-vault-admin", [governance], { + ...offer, + "hand-over-vault-admin-expiry": "8d", + [confirm?.key ?? ""]: confirm?.value, + }) + + expect(fieldOf(longer, "confirm")).toMatchObject({ checked: false }) + expect(longer.enabled).toBe(false) + }) + + it("offers any expiry the contract accepts, in any duration unit", () => { + const confirmed = (expiry: string) => { + const inputs = { ...offer, "hand-over-vault-admin-expiry": expiry } + const confirm = fieldOf( + find("hand-over-vault-admin", [governance], inputs), + "confirm", + ) + return find("hand-over-vault-admin", [governance], { + ...inputs, + [confirm?.key ?? ""]: confirm?.value, + }) + } + + expect(confirmed("36h").arg).toEqual({ + newAdmin: ACCOUNT, + liveUntilLedger: SEQUENCE + 36 * 720, + }) + expect(confirmed("1m")).toMatchObject({ + enabled: true, + arg: { liveUntilLedger: SEQUENCE + 12 }, + }) + expect(confirmed("60")).toMatchObject({ enabled: false, arg: undefined }) + }) + + it("refuses an expiry beyond the largest ledger number when the network limit is unknown", () => { + const draft = find( + "hand-over-vault-admin", + [governance], + { ...offer, "hand-over-vault-admin-expiry": "99999999d" }, + state({}, {}, null), + ) + + expect(condition(draft, "Expiry within the network limit")).toBe(false) + expect(draft.arg).toBeUndefined() + expect(draft.enabled).toBe(false) + }) + + it("checks the largest ledger number alone when the network limit is unknown", () => { + const draft = find( + "hand-over-vault-admin", + [governance], + { ...offer, "hand-over-vault-admin-expiry": "400d" }, + state({}, {}, null), + ) + + expect(draft.conditions.map(({ label }) => label)).toEqual([ + "New admin entered", + "Expiry has a unit, e.g. 7d", + "Expiry ≥ 1m", + "Expiry within the network limit", + "Confirmed", + ]) + expect(condition(draft, "Expiry within the network limit")).toBe(true) + }) + + it("cancels a pending handover by re-offering it with no expiry", () => { + const current = state({}, { oracle: { ...transfer(), address: ACCOUNT } }) + const cancel = find("cancel-oracle-handover", [oracleAdmin], {}, current) + + expect(cancel.enabled).toBe(true) + expect(cancel.arg).toEqual({ newAdmin: ACCOUNT, liveUntilLedger: 0 }) + expect(cancel.outcome).toBe("GABA...EJXA can no longer accept.") + expect( + toGovernanceActions(state(), [oracleAdmin], WALLET, {}).map( + (action) => action.id, + ), + ).not.toContain("cancel-oracle-handover") + }) + + it("lets only the pending wallet accept, while the offer is open", () => { + const pendingGrant = grantOf("oracle pending admin") + const accept = find( + "accept-oracle-admin", + [pendingGrant], + {}, + state({}, { oracle: transfer() }), + ) + + expect(accept.enabled).toBe(true) + expect(accept.outcome).toBe("GAAQ...DZ7H becomes the admin of the oracle.") + expect( + toGovernanceActions( + state({}, { oracle: transfer(true) }), + [pendingGrant], + WALLET, + {}, + ), + ).toEqual([]) + expect( + toGovernanceActions( + state({}, { oracle: { ...transfer(), address: ACCOUNT } }), + [pendingGrant], + WALLET, + {}, + ), + ).toEqual([]) + }) +}) + +describe("upgrades and wind-down", () => { + const HASH = "ab".repeat(32) + const withUpgrades = (overrides: Partial) => ({ + ...state(), + upgrades: upgrades(overrides), + }) + const codeProposal = { + kind: "code" as const, + hash: HASH, + eta: CLOSE_TIME + DAY, + } + + it("proposes code by its 64-character hash while nothing is pending", () => { + const short = find("propose-code-upgrade", [governance], { + "propose-code-upgrade-hash": "abc", + }) + const valid = find("propose-code-upgrade", [governance], { + "propose-code-upgrade-hash": ` ${HASH.toUpperCase()} `, + }) + const blocked = find( + "propose-code-upgrade", + [governance], + { "propose-code-upgrade-hash": HASH }, + withUpgrades({ proposal: codeProposal }), + ) + + expect(condition(short, "Hash of 64 hex characters")).toBe(false) + expect(valid.enabled).toBe(true) + expect(valid.arg).toBe(HASH) + expect(valid.next).toBe(`Code ${HASH}`) + expect(valid.outcome).toBe( + `Can apply after ${formatTimestamp(CLOSE_TIME + 7n * DAY)}.`, + ) + expect(condition(blocked, "No pending upgrade")).toBe(false) + expect(blocked.enabled).toBe(false) + }) + + it.each<[string, string, boolean]>([ + ["6d", "Delay ≥ 7d", false], + ["91d", "Delay ≤ 90d", false], + ["14d", "Delay ≥ notice 1d", true], + ])("bounds a proposed delay of %s by %s", (text, label, met) => { + const delay = find("propose-upgrade-delay", [governance], { + "propose-upgrade-delay-secs": text, + }) + + expect(condition(delay, label)).toBe(met) + }) + + it("lets the current upgrade delay be proposed again", () => { + const delay = find("propose-upgrade-delay", [governance], { + "propose-upgrade-delay-secs": "7d", + }) + + expect(delay.enabled).toBe(true) + expect(delay.conditions.map(({ label }) => label)).not.toContain( + "Differs from current", + ) + }) + + it("proposes a new upgrade delay at or above the notice", () => { + const delay = find("propose-upgrade-delay", [governance], { + "propose-upgrade-delay-secs": "14d", + }) + const belowNotice = find( + "propose-upgrade-delay", + [governance], + { "propose-upgrade-delay-secs": "8d" }, + withUpgrades({ notice: 10n * DAY }), + ) + + expect(delay.enabled).toBe(true) + expect(delay.arg).toBe(14n * DAY) + expect(delay.next).toBe("Delay 14d") + expect(condition(belowNotice, "Delay ≥ notice 10d")).toBe(false) + }) + + it("applies a code upgrade after its ETA, unpaused and confirmed", () => { + const early = find( + "apply-upgrade", + [governance], + {}, + withUpgrades({ + proposal: codeProposal, + }), + ) + const due = withUpgrades({ proposal: codeProposal, now: CLOSE_TIME + DAY }) + const unconfirmed = find("apply-upgrade", [governance], {}, due) + const confirm = fieldOf(unconfirmed, "confirm") + const confirmed = find( + "apply-upgrade", + [governance], + { [confirm?.key ?? ""]: confirm?.value }, + due, + ) + const paused = find( + "apply-upgrade", + [governance], + { [confirm?.key ?? ""]: confirm?.value }, + withUpgrades({ + proposal: codeProposal, + now: CLOSE_TIME + DAY, + paused: "paused", + }), + ) + + expect(condition(early, "ETA reached")).toBe(false) + expect(confirm?.label).toBe(`Replaces the vault's code with ${HASH}.`) + expect(unconfirmed.tone).toBe("danger") + expect(unconfirmed.enabled).toBe(false) + expect(confirmed.enabled).toBe(true) + expect(confirmed.next).toBe(`Code ${HASH}`) + expect(condition(paused, "Vault not paused")).toBe(false) + }) + + it("re-checks a delay proposal against the notice when applying", () => { + const proposal = { kind: "delay" as const, secs: 8n * DAY, eta: CLOSE_TIME } + const apply = find( + "apply-upgrade", + [governance], + {}, + withUpgrades({ proposal, notice: 10n * DAY }), + ) + const fine = find( + "apply-upgrade", + [governance], + {}, + withUpgrades({ proposal }), + ) + + expect(condition(apply, "Delay ≥ notice 10d")).toBe(false) + expect(fine.fields).toEqual([]) + expect(fine.enabled).toBe(true) + expect(fine.outcome).toBe( + `Upgrade delay becomes ${formatDuration(8n * DAY)}.`, + ) + }) + + it("offers apply and cancel only while an upgrade is pending", () => { + const ids = (current: GovernanceState) => + toGovernanceActions(current, [governance], WALLET, {}).map( + (action) => action.id, + ) + + expect(ids(state())).not.toContain("apply-upgrade") + expect(ids(state())).not.toContain("cancel-upgrade") + expect(ids(withUpgrades({ proposal: codeProposal }))).toEqual( + expect.arrayContaining(["apply-upgrade", "cancel-upgrade"]), + ) + }) + + it("proposes a wind-down once confirmed, and cancels only a proposed one", () => { + const draft = find("propose-wind-down", [governance]) + const confirm = fieldOf(draft, "confirm") + const confirmed = find("propose-wind-down", [governance], { + [confirm?.key ?? ""]: confirm?.value, + }) + const windDown = (phase: "proposed" | "active") => + withUpgrades({ + windDown: { + phase, + activeAt: CLOSE_TIME + DAY, + round: 0, + delay: 3n * DAY, + owed: null, + supply: null, + }, + }) + const ids = (current: GovernanceState) => + toGovernanceActions(current, [governance], WALLET, {}).map( + (action) => action.id, + ) + + expect(confirm?.label).toBe("Starts closing the vault.") + expect(draft.enabled).toBe(false) + expect(confirmed.enabled).toBe(true) + expect(confirmed.outcome).toBe( + `Activates ${formatTimestamp(CLOSE_TIME + 3n * DAY)}.`, + ) + expect( + condition( + find("propose-wind-down", [governance], {}, windDown("proposed")), + "No wind-down proposed", + ), + ).toBe(false) + expect( + condition( + find("propose-wind-down", [governance], {}, windDown("active")), + "Wind-down not active", + ), + ).toBe(false) + expect(ids(windDown("proposed"))).toContain("cancel-wind-down") + expect(ids(windDown("active"))).not.toContain("cancel-wind-down") + expect(ids(state())).not.toContain("cancel-wind-down") + }) + + it("keeps a pending code upgrade short in its tile but exposes the full hash", () => { + const [vault] = toGovernanceGroups(withUpgrades({ proposal: codeProposal })) + + expect( + vault?.metrics.find(({ label }) => label === "Pending upgrade"), + ).toEqual({ + label: "Pending upgrade", + value: "Code abab...abab", + title: `Code ${HASH}`, + note: `ETA ${formatTimestamp(CLOSE_TIME + DAY)}`, + }) + }) + + it("shows the pending upgrade and the proposed wind-down in the vault group", () => { + const [vault] = toGovernanceGroups( + withUpgrades({ + proposal: { kind: "delay", secs: 14n * DAY, eta: CLOSE_TIME + DAY }, + windDown: { + phase: "proposed", + activeAt: CLOSE_TIME + 2n * DAY, + round: 0, + delay: 3n * DAY, + owed: null, + supply: null, + }, + }), + ) + + expect(vault?.metrics.slice(-2)).toEqual([ + { + label: "Pending upgrade", + value: "Delay 14d", + note: `ETA ${formatTimestamp(CLOSE_TIME + DAY)}`, + }, + { + label: "Wind-down", + value: "Proposed", + note: `Activates ${formatTimestamp(CLOSE_TIME + 2n * DAY)}`, + }, + ]) + expect( + toGovernanceGroups(state())[0]?.metrics.map(({ label }) => label), + ).not.toContain("Pending upgrade") + }) +}) + +describe("toGovernanceGroups", () => { + it("shows a pending admin with its expiry", () => { + const groups = toGovernanceGroups( + state( + {}, + { + vault: { + address: ACCOUNT, + liveUntilLedger: SEQUENCE + DAY_IN_LEDGERS, + expired: false, + }, + token: { address: ACCOUNT, liveUntilLedger: 900, expired: true }, + }, + ), + ) + + expect(groups[0]?.metrics.at(-1)).toEqual({ + label: "Pending admin", + value: "GABA...EJXA", + note: `Expires ${formatTimestamp(CLOSE_TIME + DAY)}`, + }) + expect(groups[1]?.metrics.map(({ label }) => label)).not.toContain( + "Pending admin", + ) + expect(groups[2]?.metrics.at(-1)?.note).toBe( + `Expired ${formatTimestamp(CLOSE_TIME - 500n)}`, + ) + }) + + it.each<[Handovers, Metric[]]>([ + [{ status: "checking" }, []], + [ + { status: "unreadable" }, + [{ label: "Pending admin", value: null, note: "Could not be read" }], + ], + ])("shows the pending admin of a %o read", (handovers, metrics) => { + const groups = toGovernanceGroups({ ...state(), handovers }) + + for (const group of groups) + expect( + group.metrics.filter(({ label }) => label === "Pending admin"), + ).toEqual(metrics) + }) + + it("lists every role of each contract with its holders", () => { + const groups = toGovernanceGroups( + state({ "oracle attester": [MANAGER, ACCOUNT] }), + ) + + expect(groups.map((group) => group.title)).toEqual([ + "Vault", + "Oracle", + "Share token", + ]) + expect(groups[1]?.metrics).toEqual([ + { label: "Admin", value: "1", note: "GAAQ...DZ7H" }, + { label: "Attester", value: "2", note: "GACA...JJHP, GABA...EJXA" }, + { label: "Guardian", value: "0" }, + ]) + expect(groups[0]?.metrics[0]).toEqual({ + label: "Governance", + value: "1", + note: "GAAQ...DZ7H", + }) + expect(groups[2]?.metrics.map((metric) => metric.label)).toEqual([ + "Admin", + "Compliance", + "Manager", + ]) + }) +}) diff --git a/app/src/pages/governance.ts b/app/src/pages/governance.ts new file mode 100644 index 0000000..f312997 --- /dev/null +++ b/app/src/pages/governance.ts @@ -0,0 +1,863 @@ +import { StrKey } from "@stellar/stellar-sdk" +import { type PendingTransfer, shortAddress } from "@stellar-scaffold/app-lib" +import { type Metric } from "../components/vault/MetricsStrip" +import { + GOVERNED_CONTRACTS, + type GovernedContract, +} from "../hooks/pendingAdmins" +import { type Grant, type RoleHolders } from "../hooks/useAdminAuthority" +import { + type Handovers, + type PendingUpgrade, + type Upgrades, +} from "../hooks/useGovernanceState" +import { + type AdminOffer, + type RoleChange, +} from "../hooks/useGovernanceTransactions" +import { + allMet, + type Condition, + grantFor, + type RailAction, + type RailField, + ROLES, + type Spec, + toAction, +} from "./actions" +import { DAY, formatDuration, formatTimestamp, parseDuration } from "./cycle" + +type Operation = "grant" | "revoke" + +type RoleActionId = `${Operation}-${GovernedContract}-role` +type OfferActionId = + `hand-over-${GovernedContract}-admin` | `cancel-${GovernedContract}-handover` +type AcceptActionId = `accept-${GovernedContract}-admin` + +type ProposalActionId = "propose-code-upgrade" | "propose-upgrade-delay" +type VaultActionId = + "apply-upgrade" | "cancel-upgrade" | "propose-wind-down" | "cancel-wind-down" + +export type GovernanceActionId = + | RoleActionId + | OfferActionId + | AcceptActionId + | ProposalActionId + | VaultActionId + +const VAULT_ACTION_IDS: GovernanceActionId[] = [ + "propose-code-upgrade", + "propose-upgrade-delay", + "apply-upgrade", + "cancel-upgrade", + "propose-wind-down", + "cancel-wind-down", +] + +export const GOVERNANCE_ACTION_IDS: GovernanceActionId[] = [ + ...GOVERNED_CONTRACTS.flatMap((contract) => [ + `grant-${contract}-role` as const, + `revoke-${contract}-role` as const, + `hand-over-${contract}-admin` as const, + `cancel-${contract}-handover` as const, + `accept-${contract}-admin` as const, + ]), + ...VAULT_ACTION_IDS, +] + +export const confirmKey = (id: GovernanceActionId): string => `${id}-confirm` + +type Built = Id extends string + ? RailAction & { id: Id; arg: Arg; next: string } + : never + +export type GovernanceAction = + | Built + | Built + | Built + | Built<"propose-code-upgrade", string | undefined> + | Built<"propose-upgrade-delay", bigint | undefined> + | Built + +export type GovernanceInputs = Partial> + +export type GovernanceState = { + holders: RoleHolders + vault: string + handovers: Handovers + upgrades: Upgrades +} + +export type GovernanceGroup = { title: string; metrics: Metric[] } + +type Holder = keyof RoleHolders + +type RoleEntry = { role: string; label: string; holders: Holder } + +type ContractEntry = { + title: string + subject: string + name: string + pendingGrant: string + noun: string + admin: { grant: string; label: string; holders: Holder } + roles: [RoleEntry, ...RoleEntry[]] +} + +const CONTRACTS: Record = { + vault: { + title: "Vault", + subject: "vault", + name: "vault", + pendingGrant: "vault pending admin", + noun: "a vault role", + admin: { + grant: ROLES.governance, + label: "Governance", + holders: "vault governance", + }, + roles: [ + { role: "manager", label: "Manager", holders: "vault manager" }, + { role: "treasury", label: "Treasury", holders: "vault treasury" }, + { role: "guardian", label: "Guardian", holders: "vault guardian" }, + ], + }, + oracle: { + title: "Oracle", + subject: "oracle", + name: "oracle", + pendingGrant: "oracle pending admin", + noun: "an oracle role", + admin: { + grant: ROLES.oracleAdmin, + label: "Admin", + holders: "oracle admin", + }, + roles: [ + { role: "attester", label: "Attester", holders: "oracle attester" }, + { role: "guardian", label: "Guardian", holders: "oracle guardian" }, + ], + }, + token: { + title: "Share token", + subject: "share token", + name: "share-token", + pendingGrant: "share-token pending admin", + noun: "a share-token role", + admin: { + grant: ROLES.tokenAdmin, + label: "Admin", + holders: "share-token admin", + }, + roles: [ + { + role: "compliance", + label: "Compliance", + holders: "share-token compliance", + }, + { role: "manager", label: "Manager", holders: "share-token manager" }, + ], + }, +} + +const SEPARATED_VAULT_ROLES: Holder[] = [ + "vault governance", + "vault manager", + "vault treasury", + "vault guardian", +] + +const WARNINGS = { + vaultMinting: "Stops the vault minting and burning shares.", + mergedRoles: "Merges two vault roles on one account.", +} as const + +const parseAccount = (text: string): string | undefined => { + const trimmed = text.trim() + return StrKey.isValidEd25519PublicKey(trimmed) || + StrKey.isValidContract(trimmed) + ? trimmed + : undefined +} + +const holderList = (accounts: string[]): string => + accounts.length === 0 ? "none" : accounts.map(shortAddress).join(", ") + +const capitalize = (text: string): string => + `${text.charAt(0).toUpperCase()}${text.slice(1)}` + +const LEDGER_SECONDS = 5n +const MIN_EXPIRY = 60n +const U32_MAX = 4_294_967_295 +const DEFAULT_EXPIRY = "7d" + +type Draft = { + operation: Operation + contract: GovernedContract + entry: RoleEntry + account: string + state: GovernanceState +} + +const warningOf = ({ + operation, + contract, + entry, + account, + state, +}: Draft): string | undefined => { + if ( + operation === "revoke" && + entry.holders === "share-token manager" && + account === state.vault + ) + return WARNINGS.vaultMinting + const mergesRoles = + operation === "grant" && + contract === "vault" && + SEPARATED_VAULT_ROLES.some( + (other) => + other !== entry.holders && state.holders[other].includes(account), + ) + return mergesRoles ? WARNINGS.mergedRoles : undefined +} + +const resultingHolders = ({ + operation, + entry, + account, + state, +}: Draft): string[] => { + const current = state.holders[entry.holders] + return operation === "grant" + ? [...current, account] + : current.filter((holder) => holder !== account) +} + +type Confirmation = { field: RailField; condition: Condition } + +function confirmation( + key: string, + label: string, + value: string, + inputs: GovernanceInputs, +): Confirmation { + const checked = inputs[key] === value + return { + field: { kind: "confirm", key, label, value, checked }, + condition: { label: "Confirmed", met: checked }, + } +} + +const roleConfirmation = ( + draft: Draft, + key: string, + inputs: GovernanceInputs, +): Confirmation | undefined => { + const label = warningOf(draft) + return label === undefined + ? undefined + : confirmation(key, label, `${draft.entry.role}:${draft.account}`, inputs) +} + +const accountField = (key: string, text: string, label: string): RailField => ({ + key, + label, + placeholder: "Address", + value: text, + invalid: text.trim() !== "" && parseAccount(text) === undefined, + text: true, +}) + +function roleSpec( + operation: Operation, + contract: GovernedContract, + state: GovernanceState, + inputs: GovernanceInputs, +): Spec & { + arg: RoleChange | undefined + next: string +} { + const id: RoleActionId = `${operation}-${contract}-role` + const { noun, roles } = CONTRACTS[contract] + const roleKey = `${id}-role` + const accountKey = `${id}-account` + const entry = + roles.find((candidate) => candidate.role === inputs[roleKey]) ?? roles[0] + const text = inputs[accountKey] ?? "" + const account = parseAccount(text) + const holds = + account === undefined + ? null + : state.holders[entry.holders].includes(account) + const fields: RailField[] = [ + { + kind: "select", + key: roleKey, + label: "Role", + value: entry.role, + options: roles.map(({ role, label }) => ({ value: role, label })), + }, + accountField(accountKey, text, "Account"), + ] + const conditions: Condition[] = [ + operation === "grant" + ? { + label: "Account does not hold the role", + met: holds === null ? null : !holds, + } + : { label: "Account holds the role", met: holds }, + ] + const draft = + account === undefined + ? undefined + : { operation, contract, entry, account, state } + const confirm = + draft === undefined + ? undefined + : roleConfirmation(draft, confirmKey(id), inputs) + if (confirm !== undefined) { + fields.push(confirm.field) + conditions.push(confirm.condition) + } + const changes = holds === (operation === "revoke") + return { + id, + label: `${capitalize(operation)} ${noun}`, + conditions, + outcome: + draft === undefined || !changes + ? null + : `${entry.label} holders: ${holderList(resultingHolders(draft))}`, + fields, + ready: allMet(conditions), + ...(confirm === undefined ? {} : { danger: true }), + arg: account === undefined ? undefined : { role: entry.role, account }, + next: + account === undefined + ? "" + : `${entry.label} ${operation === "grant" ? "to" : "from"} ${shortAddress(account)}`, + } +} + +type Ledger = Extract["ledger"] + +const ledgerTime = (ledger: Ledger, sequence: number): bigint => + ledger.closeTime + BigInt(sequence - ledger.sequence) * LEDGER_SECONDS + +const LIMIT_LABEL = "Expiry within the network limit" + +const limitCondition = ( + ledger: Ledger | undefined, + liveUntil: bigint | undefined, +): Condition => { + if (ledger === undefined) return { label: LIMIT_LABEL, met: null } + const limit = Math.min(ledger.maxLiveUntil ?? U32_MAX, U32_MAX) + return { + label: + ledger.maxLiveUntil === null + ? LIMIT_LABEL + : `Expiry ≤ ${formatDuration(BigInt(limit - ledger.sequence) * LEDGER_SECONDS)}`, + met: liveUntil === undefined ? null : liveUntil <= BigInt(limit), + } +} + +function handOverSpec( + contract: GovernedContract, + state: GovernanceState, + inputs: GovernanceInputs, +): Spec & { + arg: AdminOffer | undefined + next: string +} { + const id: OfferActionId = `hand-over-${contract}-admin` + const accountKey = `${id}-account` + const expiryKey = `${id}-expiry` + const text = inputs[accountKey] ?? "" + const account = parseAccount(text) + const expiryText = inputs[expiryKey] ?? DEFAULT_EXPIRY + const secs = /^\s*\d+\s*$/.test(expiryText) + ? undefined + : (parseDuration(expiryText) ?? undefined) + const ledger = + state.handovers.status === "ready" ? state.handovers.ledger : undefined + const liveUntil = + ledger === undefined || secs === undefined + ? undefined + : BigInt(ledger.sequence) + secs / LEDGER_SECONDS + const fields: RailField[] = [ + accountField(accountKey, text, "New admin"), + { + key: expiryKey, + label: "Expiry", + placeholder: DEFAULT_EXPIRY, + value: expiryText, + invalid: expiryText.trim() !== "" && secs === undefined, + text: true, + }, + ] + const conditions: Condition[] = [ + { + label: "New admin entered", + met: text.trim() === "" ? null : account !== undefined, + }, + { + label: "Expiry has a unit, e.g. 7d", + met: expiryText.trim() === "" ? null : secs !== undefined, + }, + { + label: `Expiry ≥ ${formatDuration(MIN_EXPIRY)}`, + met: secs === undefined ? null : secs >= MIN_EXPIRY, + }, + limitCondition(ledger, liveUntil), + ] + if (account !== undefined) { + const confirm = confirmation( + confirmKey(id), + `Hands control of the ${CONTRACTS[contract].subject} to ${shortAddress(account)}.`, + `${account}:${secs ?? ""}`, + inputs, + ) + fields.push(confirm.field) + conditions.push(confirm.condition) + } + const offer = + account === undefined || liveUntil === undefined || liveUntil > U32_MAX + ? undefined + : { newAdmin: account, liveUntilLedger: Number(liveUntil) } + const outcome = + offer === undefined || ledger === undefined + ? null + : `${shortAddress(offer.newAdmin)} becomes admin once it accepts before ${formatTimestamp( + ledgerTime(ledger, offer.liveUntilLedger), + )}.` + return { + id, + label: `Hand over ${CONTRACTS[contract].name} admin`, + conditions, + outcome, + fields, + danger: true, + ready: allMet(conditions) && offer !== undefined, + arg: offer, + next: outcome ?? "", + } +} + +const cancelSpec = ( + contract: GovernedContract, + pending: PendingTransfer, +): Spec & { arg: AdminOffer; next: string } => ({ + id: `cancel-${contract}-handover`, + label: `Cancel the ${CONTRACTS[contract].name} handover`, + conditions: [], + outcome: `${shortAddress(pending.address)} can no longer accept.`, + arg: { newAdmin: pending.address, liveUntilLedger: 0 }, + next: shortAddress(pending.address), +}) + +const acceptSpec = ( + contract: GovernedContract, + ledger: Ledger, + pending: PendingTransfer, +): Spec & { arg: undefined; next: string } => ({ + id: `accept-${contract}-admin`, + label: `Accept ${CONTRACTS[contract].name} admin`, + conditions: [ + { + label: "Offer open until", + met: true, + detail: formatTimestamp(ledgerTime(ledger, pending.liveUntilLedger)), + }, + ], + outcome: `${shortAddress(pending.address)} becomes the admin of the ${CONTRACTS[contract].subject}.`, + arg: undefined, + next: CONTRACTS[contract].subject, +}) + +type ReadyUpgrades = Extract +type VaultSpec = Spec & { + arg: Arg + next: string +} + +const MIN_UPGRADE_DELAY = 7n * DAY +const MAX_UPGRADE_DELAY = 90n * DAY +const WASM_HASH = /^[0-9a-f]{64}$/i + +const upgradeName = (proposal: PendingUpgrade, full = false): string => + proposal.kind === "code" + ? `Code ${full ? proposal.hash : shortAddress(proposal.hash)}` + : `Delay ${formatDuration(proposal.secs)}` + +const noPendingUpgrade = (upgrades?: ReadyUpgrades): Condition => ({ + label: "No pending upgrade", + met: upgrades?.proposal === undefined ? null : upgrades.proposal === null, +}) + +const applyAfter = (upgrades?: ReadyUpgrades): string | null => + upgrades?.now == null || upgrades.delay === null + ? null + : `Can apply after ${formatTimestamp(upgrades.now + upgrades.delay)}.` + +const aboveNotice = ( + secs: bigint | undefined, + notice: bigint | null | undefined, +): Condition => ({ + label: + notice == null + ? "Delay ≥ notice" + : `Delay ≥ notice ${formatDuration(notice)}`, + met: secs === undefined || notice == null ? null : secs >= notice, +}) + +function codeUpgradeSpec( + inputs: GovernanceInputs, + upgrades?: ReadyUpgrades, +): VaultSpec<"propose-code-upgrade", string | undefined> { + const key = "propose-code-upgrade-hash" + const text = inputs[key] ?? "" + const hash = WASM_HASH.test(text.trim()) + ? text.trim().toLowerCase() + : undefined + const conditions = [ + { + label: "Hash of 64 hex characters", + met: text.trim() === "" ? null : hash !== undefined, + }, + noPendingUpgrade(upgrades), + ] + return { + id: "propose-code-upgrade", + label: "Propose a code upgrade", + conditions, + outcome: hash === undefined ? null : applyAfter(upgrades), + fields: [ + { + key, + label: "Code hash", + placeholder: "Wasm hash", + value: text, + invalid: text.trim() !== "" && hash === undefined, + text: true, + }, + ], + ready: allMet(conditions), + arg: hash, + next: hash === undefined ? "" : `Code ${hash}`, + } +} + +function delayUpgradeSpec( + inputs: GovernanceInputs, + upgrades?: ReadyUpgrades, +): VaultSpec<"propose-upgrade-delay", bigint | undefined> { + const key = "propose-upgrade-delay-secs" + const current = upgrades?.delay ?? undefined + const text = + inputs[key] ?? (current === undefined ? "" : formatDuration(current, 4)) + const secs = parseDuration(text) ?? undefined + const conditions: Condition[] = [ + { + label: "Delay ≥ 7d", + met: secs === undefined ? null : secs >= MIN_UPGRADE_DELAY, + }, + { + label: "Delay ≤ 90d", + met: secs === undefined ? null : secs <= MAX_UPGRADE_DELAY, + }, + aboveNotice(secs, upgrades?.notice), + noPendingUpgrade(upgrades), + ] + return { + id: "propose-upgrade-delay", + label: "Propose an upgrade delay", + conditions, + outcome: secs === undefined ? null : applyAfter(upgrades), + fields: [ + { + key, + label: "Upgrade delay", + placeholder: "7d", + value: text, + invalid: text.trim() !== "" && secs === undefined, + text: true, + current: current === undefined ? "—" : formatDuration(current), + next: secs === undefined ? null : formatDuration(secs), + }, + ], + ready: allMet(conditions), + arg: secs, + next: secs === undefined ? "" : `Delay ${formatDuration(secs)}`, + } +} + +const notPaused = (paused: ReadyUpgrades["paused"]): Condition => ({ + label: "Vault not paused", + met: paused === "open" ? true : paused === "paused" ? false : null, +}) + +function applySpec( + proposal: PendingUpgrade, + upgrades: ReadyUpgrades, + inputs: GovernanceInputs, +): VaultSpec<"apply-upgrade", undefined> { + const conditions: Condition[] = [ + { + label: "ETA reached", + met: upgrades.now === null ? null : upgrades.now >= proposal.eta, + detail: formatTimestamp(proposal.eta), + }, + notPaused(upgrades.paused), + ] + const fields: RailField[] = [] + if (proposal.kind === "delay") + conditions.push(aboveNotice(proposal.secs, upgrades.notice)) + else { + const confirm = confirmation( + confirmKey("apply-upgrade"), + `Replaces the vault's code with ${proposal.hash}.`, + proposal.hash, + inputs, + ) + fields.push(confirm.field) + conditions.push(confirm.condition) + } + return { + id: "apply-upgrade", + label: "Apply the upgrade", + conditions, + outcome: + proposal.kind === "delay" + ? `Upgrade delay becomes ${formatDuration(proposal.secs)}.` + : null, + fields, + ...(proposal.kind === "code" ? { danger: true } : {}), + ready: allMet(conditions), + arg: undefined, + next: upgradeName(proposal, true), + } +} + +const cancelUpgradeSpec = ( + proposal: PendingUpgrade, +): VaultSpec<"cancel-upgrade", undefined> => ({ + id: "cancel-upgrade", + label: "Cancel the upgrade", + conditions: [], + outcome: null, + arg: undefined, + next: upgradeName(proposal), +}) + +function windDownSpec( + inputs: GovernanceInputs, + upgrades?: ReadyUpgrades, +): VaultSpec<"propose-wind-down", undefined> { + const phase = upgrades?.windDown.phase ?? null + const confirm = confirmation( + confirmKey("propose-wind-down"), + "Starts closing the vault.", + "wind-down", + inputs, + ) + const conditions: Condition[] = [ + { + label: "No wind-down proposed", + met: phase === null ? null : phase !== "proposed", + }, + { + label: "Wind-down not active", + met: phase === null ? null : phase !== "active", + }, + confirm.condition, + ] + const delay = upgrades?.windDown.delay ?? null + const outcome = + upgrades?.now == null || delay === null + ? null + : `Activates ${formatTimestamp(upgrades.now + delay)}.` + return { + id: "propose-wind-down", + label: "Propose a wind-down", + conditions, + outcome, + fields: [confirm.field], + danger: true, + ready: allMet(conditions), + arg: undefined, + next: outcome ?? "", + } +} + +const cancelWindDownSpec = (): VaultSpec<"cancel-wind-down", undefined> => ({ + id: "cancel-wind-down", + label: "Cancel the wind-down", + conditions: [], + outcome: null, + arg: undefined, + next: "", +}) + +const build = ( + spec: Spec & { arg: Arg; next: string }, + grant: Grant, + wallet: string, +) => ({ ...toAction(spec, grant, wallet), arg: spec.arg, next: spec.next }) + +function adminActions( + contract: GovernedContract, + state: GovernanceState, + grant: Grant, + wallet: string, + inputs: GovernanceInputs, +): GovernanceAction[] { + const pending = + state.handovers.status === "ready" + ? state.handovers.pending[contract] + : null + return [ + build(roleSpec("grant", contract, state, inputs), grant, wallet), + build(roleSpec("revoke", contract, state, inputs), grant, wallet), + build(handOverSpec(contract, state, inputs), grant, wallet), + ...(pending === null + ? [] + : [build(cancelSpec(contract, pending), grant, wallet)]), + ...(contract === "vault" ? vaultActions(state, grant, wallet, inputs) : []), + ] +} + +function vaultActions( + state: GovernanceState, + grant: Grant, + wallet: string, + inputs: GovernanceInputs, +): GovernanceAction[] { + const upgrades = + state.upgrades.status === "ready" ? state.upgrades : undefined + const proposal = upgrades?.proposal ?? null + return [ + build(codeUpgradeSpec(inputs, upgrades), grant, wallet), + build(delayUpgradeSpec(inputs, upgrades), grant, wallet), + ...(proposal === null || upgrades === undefined + ? [] + : [ + build(applySpec(proposal, upgrades, inputs), grant, wallet), + build(cancelUpgradeSpec(proposal), grant, wallet), + ]), + build(windDownSpec(inputs, upgrades), grant, wallet), + ...(upgrades?.windDown.phase === "proposed" + ? [build(cancelWindDownSpec(), grant, wallet)] + : []), + ] +} + +function acceptAction( + contract: GovernedContract, + state: GovernanceState, + grants: Grant[], + wallet: string, +): GovernanceAction[] { + if (state.handovers.status !== "ready") return [] + const pending = state.handovers.pending[contract] + const grant = grantFor(grants, CONTRACTS[contract].pendingGrant, wallet) + if ( + pending === null || + pending.expired || + grant === undefined || + grant.authority !== pending.address + ) + return [] + return [ + build(acceptSpec(contract, state.handovers.ledger, pending), grant, wallet), + ] +} + +export function toGovernanceActions( + state: GovernanceState, + grants: Grant[], + wallet: string, + inputs: GovernanceInputs, +): GovernanceAction[] { + return GOVERNED_CONTRACTS.flatMap((contract) => { + const admin = grantFor(grants, CONTRACTS[contract].admin.grant, wallet) + return [ + ...(admin === undefined + ? [] + : adminActions(contract, state, admin, wallet, inputs)), + ...acceptAction(contract, state, grants, wallet), + ] + }) +} + +const holdersMetric = (label: string, accounts: string[]): Metric => + accounts.length === 0 + ? { label, value: "0" } + : { + label, + value: String(accounts.length), + note: holderList(accounts), + } + +const pendingMetric = ( + handovers: Handovers, + contract: GovernedContract, +): Metric[] => { + const label = "Pending admin" + if (handovers.status === "checking") return [] + if (handovers.status === "unreadable") + return [{ label, value: null, note: "Could not be read" }] + const pending = handovers.pending[contract] + if (pending === null) return [] + const at = formatTimestamp( + ledgerTime(handovers.ledger, pending.liveUntilLedger), + ) + return [ + { + label, + value: shortAddress(pending.address), + note: pending.expired ? `Expired ${at}` : `Expires ${at}`, + }, + ] +} + +const upgradeMetrics = (upgrades: Upgrades): Metric[] => { + if (upgrades.status !== "ready") return [] + const metrics: Metric[] = [] + const { proposal, windDown } = upgrades + if (proposal != null) + metrics.push({ + label: "Pending upgrade", + value: upgradeName(proposal), + ...(proposal.kind === "code" + ? { title: upgradeName(proposal, true) } + : {}), + note: `ETA ${formatTimestamp(proposal.eta)}`, + }) + if (windDown.phase === "proposed" && windDown.activeAt !== null) + metrics.push({ + label: "Wind-down", + value: "Proposed", + note: `Activates ${formatTimestamp(windDown.activeAt)}`, + }) + return metrics +} + +export const toGovernanceGroups = ({ + holders, + handovers, + upgrades, +}: GovernanceState): GovernanceGroup[] => + GOVERNED_CONTRACTS.map((contract) => { + const { title, admin, roles } = CONTRACTS[contract] + return { + title, + metrics: [ + ...[admin, ...roles].map((entry) => + holdersMetric(entry.label, holders[entry.holders]), + ), + ...pendingMetric(handovers, contract), + ...(contract === "vault" ? upgradeMetrics(upgrades) : []), + ], + } + })