diff --git a/app-lib/accounts.test.ts b/app-lib/accounts.test.ts new file mode 100644 index 0000000..a7adc11 --- /dev/null +++ b/app-lib/accounts.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { accountSigners } from "./accounts" + +const { callMock, accountIdMock } = vi.hoisted(() => ({ + callMock: vi.fn(), + accountIdMock: vi.fn(), +})) + +vi.mock("./wallet", () => ({ + horizon: { accounts: () => ({ accountId: accountIdMock }) }, +})) + +const holder = "GHOLDERACCOUNT" + +describe("accountSigners", () => { + beforeEach(() => { + vi.clearAllMocks() + accountIdMock.mockReturnValue({ call: callMock }) + }) + + it("maps the signers and thresholds of an existing account", async () => { + callMock.mockResolvedValue({ + signers: [ + { key: "GSIGNERONE", weight: 1, type: "ed25519_public_key" }, + { key: "GSIGNERTWO", weight: 2, type: "ed25519_public_key" }, + ], + thresholds: { low_threshold: 1, med_threshold: 2, high_threshold: 3 }, + }) + + expect(await accountSigners(holder)).toEqual({ + signers: [ + { key: "GSIGNERONE", weight: 1 }, + { key: "GSIGNERTWO", weight: 2 }, + ], + thresholds: { low: 1, med: 2, high: 3 }, + }) + expect(accountIdMock).toHaveBeenCalledWith(holder) + }) + + it("leaves out signers that are not ed25519 keys", async () => { + callMock.mockResolvedValue({ + signers: [ + { key: "GSIGNERONE", weight: 1, type: "ed25519_public_key" }, + { key: "XHASHSIGNER", weight: 1, type: "sha256_hash" }, + { key: "TPREAUTH", weight: 1, type: "preauth_tx" }, + ], + thresholds: { low_threshold: 0, med_threshold: 0, high_threshold: 0 }, + }) + + const result = await accountSigners(holder) + + expect(result?.signers).toEqual([{ key: "GSIGNERONE", weight: 1 }]) + }) + + it("returns null when the account does not exist", async () => { + callMock.mockRejectedValue(new Error("Not Found")) + + expect(await accountSigners(holder)).toBeNull() + }) + + it("returns null on a network error without throwing", async () => { + callMock.mockRejectedValue(new TypeError("Failed to fetch")) + + expect(await accountSigners(holder)).toBeNull() + }) + + it("returns null for a contract address without asking Horizon", async () => { + expect(await accountSigners("CCONTRACTADDRESS")).toBeNull() + expect(accountIdMock).not.toHaveBeenCalled() + }) +}) diff --git a/app-lib/accounts.ts b/app-lib/accounts.ts new file mode 100644 index 0000000..15c2d14 --- /dev/null +++ b/app-lib/accounts.ts @@ -0,0 +1,27 @@ +import { horizon } from "./wallet" + +export interface AccountSigners { + signers: Array<{ key: string; weight: number }> + thresholds: { low: number; med: number; high: number } +} + +export async function accountSigners( + address: string, +): Promise { + if (!address.startsWith("G")) return null + try { + const account = await horizon.accounts().accountId(address).call() + return { + signers: account.signers + .filter((signer) => signer.type === "ed25519_public_key") + .map(({ key, weight }) => ({ key, weight })), + thresholds: { + low: account.thresholds.low_threshold, + med: account.thresholds.med_threshold, + high: account.thresholds.high_threshold, + }, + } + } catch { + return null + } +} diff --git a/app-lib/index.ts b/app-lib/index.ts index 8cc98e0..6f30636 100644 --- a/app-lib/index.ts +++ b/app-lib/index.ts @@ -3,6 +3,7 @@ // Clients in app-lib/clients/index.ts). Framework-specific code (providers, // stores, UI components) stays in each template. +export * from "./accounts" // AccountSigners, accountSigners export * from "./contracts" // AsyncVaultApi, connectAsyncVault export * from "./env" // rpcUrl, networkPassphrase, stellarNetwork, horizonUrl, network, labPrefix export * from "./format" // shortAddress, formatNetworkName, networkStatus, NetworkState diff --git a/app-lib/wallet.ts b/app-lib/wallet.ts index 2d23f5b..a7205ca 100644 --- a/app-lib/wallet.ts +++ b/app-lib/wallet.ts @@ -5,7 +5,7 @@ import { } from "@creit.tech/stellar-wallets-kit" import { defaultModules } from "@creit.tech/stellar-wallets-kit/modules/utils" import { Horizon } from "@stellar/stellar-sdk" -import { networkPassphrase, stellarNetwork } from "./env" +import { horizonUrl, networkPassphrase, stellarNetwork } from "./env" // These wallets work on Local and Futurenet while the rest enforce or fail on // any networks beside Testnet and Mainnet. Filter below based on dApp config. @@ -142,7 +142,7 @@ export const onWalletChange = ( function getHorizonHost(mode: string) { switch (mode) { case "LOCAL": - return "http://localhost:8000" + return horizonUrl case "FUTURENET": return "https://horizon-futurenet.stellar.org" case "TESTNET": @@ -154,7 +154,7 @@ function getHorizonHost(mode: string) { } } -const horizon = new Horizon.Server(getHorizonHost(stellarNetwork), { +export const horizon = new Horizon.Server(getHorizonHost(stellarNetwork), { allowHttp: stellarNetwork === "LOCAL", }) diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index 8e2faf6..e0cecae 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -39,7 +39,8 @@ const disconnectedAuthority: AdminAuthority = { configuration: [], governance: [], }, - roles: [], + grants: [], + signersUnknown: false, addresses: { oracle: null, shareToken: null, identityVerifier: null }, } diff --git a/app/src/hooks/useAdminAuthority.test.ts b/app/src/hooks/useAdminAuthority.test.ts index c1b16eb..ce42341 100644 --- a/app/src/hooks/useAdminAuthority.test.ts +++ b/app/src/hooks/useAdminAuthority.test.ts @@ -3,150 +3,277 @@ import { waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import { renderWithWallet, investorAddress } from "./testSupport" import { - type AuthorityReads, + type AdminSurface, + type RoleHolders, toSurfaces, useAdminAuthority, } from "./useAdminAuthority" const otherAddress = "GOTHERADDRESS1234567890" +const multisig = "GMULTISIGAUTHORITY" +const secondMultisig = "GSECONDMULTISIG" +const contractHolder = "CCONTRACTHOLDER" + +const noHolders: RoleHolders = { + "vault governance": [], + "vault manager": [], + "vault treasury": [], + "vault guardian": [], + "oracle attester": [], + "oracle guardian": [], + "share-token compliance": [], + "share-token admin": [], +} + +const holders = (overrides: Partial): RoleHolders => ({ + ...noHolders, + ...overrides, +}) + +const oneSignatureOf = (med: number, weight = 1) => ({ + signers: [ + { key: investorAddress, weight }, + { key: otherAddress, weight: 1 }, + ], + thresholds: { low: 0, med, high: med }, +}) + +const singleKey = { + signers: [{ key: investorAddress, weight: 1 }], + thresholds: { low: 0, med: 0, high: 0 }, +} -const baseReads: AuthorityReads = { - address: investorAddress, - governance: { kind: "value", value: null }, - manager: { kind: "value", value: null }, - treasury: { kind: "value", value: null }, - guardian: { kind: "value", value: null }, - oracleAttester: { kind: "value", value: null }, - oracleGuardian: { kind: "value", value: null }, - tokenCompliance: { kind: "value", value: null }, - tokenAdmin: { kind: "value", value: null }, +const emptyGrantedBy = { + cycle: [], + compliance: [], + emergency: [], + configuration: [], + governance: [], } describe("toSurfaces", () => { - it("grants no surface and no role label to an address with nothing", () => { - const { surfaces, grantedBy, roles } = toSurfaces(baseReads) + it("grants no surface and no grant to a wallet that holds and signs for nothing", () => { + const { surfaces, grantedBy, grants } = toSurfaces( + investorAddress, + noHolders, + {}, + ) expect(surfaces).toEqual(new Set()) - expect(grantedBy).toEqual({ - cycle: [], - compliance: [], - emergency: [], - configuration: [], - governance: [], - }) - expect(roles).toEqual([]) + expect(grantedBy).toEqual(emptyGrantedBy) + expect(grants).toEqual([]) }) - it("grants cycle for the vault manager", () => { - const { surfaces, grantedBy, roles } = toSurfaces({ - ...baseReads, - manager: { kind: "value", value: investorAddress }, - }) + it.each<[keyof RoleHolders, AdminSurface[]]>([ + ["vault manager", ["cycle"]], + ["vault treasury", ["cycle"]], + ["oracle attester", ["cycle"]], + ["share-token compliance", ["compliance"]], + ["vault guardian", ["emergency"]], + ["oracle guardian", ["emergency"]], + ["share-token admin", ["emergency"]], + ["vault governance", ["configuration", "governance"]], + ])("grants %s's surfaces to the wallet that holds it", (role, expected) => { + const { surfaces, grantedBy, grants } = toSurfaces( + investorAddress, + holders({ [role]: [investorAddress] }), + {}, + ) + const standing = { + role, + authority: investorAddress, + standing: "signs-alone", + } + + expect(surfaces).toEqual(new Set(expected)) + for (const surface of expected) + expect(grantedBy[surface]).toEqual([standing]) + expect(grants).toEqual([standing]) + }) - expect(surfaces).toEqual(new Set(["cycle"])) - expect(grantedBy.cycle).toEqual(["vault manager"]) - expect(roles).toEqual(["vault manager"]) + it("grants a single-key holder as signing alone", () => { + const { grants } = toSurfaces( + investorAddress, + holders({ "vault manager": [investorAddress] }), + { [investorAddress]: singleKey }, + ) + + expect(grants).toEqual([ + { + role: "vault manager", + authority: investorAddress, + standing: "signs-alone", + }, + ]) }) - it("grants cycle for the vault treasury", () => { - const { surfaces, grantedBy, roles } = toSurfaces({ - ...baseReads, - treasury: { kind: "value", value: investorAddress }, - }) + it("counts the signatures a holder key needs when its weight is short of the threshold", () => { + const { grants } = toSurfaces( + investorAddress, + holders({ "vault governance": [investorAddress] }), + { [investorAddress]: oneSignatureOf(2) }, + ) - expect(surfaces).toEqual(new Set(["cycle"])) - expect(grantedBy.cycle).toEqual(["vault treasury"]) - expect(roles).toEqual(["vault treasury"]) + expect(grants.map(({ standing }) => standing)).toEqual([{ needs: 2 }]) }) - it("grants cycle for the oracle attester", () => { - const { surfaces, grantedBy, roles } = toSurfaces({ - ...baseReads, - oracleAttester: { kind: "value", value: 0 }, - }) + it("does not grant a holder key whose own weight is zero", () => { + const { surfaces } = toSurfaces( + investorAddress, + holders({ "vault governance": [investorAddress] }), + { [investorAddress]: oneSignatureOf(1, 0) }, + ) - expect(surfaces).toEqual(new Set(["cycle"])) - expect(grantedBy.cycle).toEqual(["oracle attester"]) - expect(roles).toEqual(["oracle attester"]) + expect(surfaces).toEqual(new Set()) }) - it("grants compliance for the share-token compliance", () => { - const { surfaces, grantedBy, roles } = toSurfaces({ - ...baseReads, - tokenCompliance: { kind: "value", value: 0 }, - }) + it("still grants the holder when its signers are unknown", () => { + const { grants } = toSurfaces( + investorAddress, + holders({ "vault manager": [investorAddress] }), + {}, + ) - expect(surfaces).toEqual(new Set(["compliance"])) - expect(grantedBy.compliance).toEqual(["share-token compliance"]) - expect(roles).toEqual(["share-token compliance"]) + expect(grants.map(({ standing }) => standing)).toEqual(["signs-alone"]) }) - it("grants emergency for the vault guardian", () => { - const { surfaces, grantedBy, roles } = toSurfaces({ - ...baseReads, - guardian: { kind: "value", value: investorAddress }, - }) + it("ignores a role held by a different account the wallet does not sign for", () => { + const { surfaces, grants } = toSurfaces( + investorAddress, + holders({ "vault manager": [otherAddress] }), + {}, + ) - expect(surfaces).toEqual(new Set(["emergency"])) - expect(grantedBy.emergency).toEqual(["vault guardian"]) - expect(roles).toEqual(["vault guardian"]) + expect(surfaces).toEqual(new Set()) + expect(grants).toEqual([]) }) - it("grants emergency for the oracle guardian", () => { - const { surfaces, grantedBy, roles } = toSurfaces({ - ...baseReads, - oracleGuardian: { kind: "value", value: 0 }, - }) + it("grants a signer of the holder account, signing alone when its weight reaches the medium threshold", () => { + const { surfaces, grantedBy } = toSurfaces( + investorAddress, + holders({ "vault guardian": [multisig] }), + { [multisig]: oneSignatureOf(1) }, + ) expect(surfaces).toEqual(new Set(["emergency"])) - expect(grantedBy.emergency).toEqual(["oracle guardian"]) - expect(roles).toEqual(["oracle guardian"]) + expect(grantedBy.emergency).toEqual([ + { role: "vault guardian", authority: multisig, standing: "signs-alone" }, + ]) }) - it("grants emergency for the share-token admin", () => { - const { surfaces, grantedBy, roles } = toSurfaces({ - ...baseReads, - tokenAdmin: { kind: "value", value: investorAddress }, - }) + it("counts the signatures needed when every signer weighs 1 and the weight is short of the threshold", () => { + const { grants } = toSurfaces( + investorAddress, + holders({ "oracle attester": [multisig] }), + { [multisig]: oneSignatureOf(2) }, + ) - expect(surfaces).toEqual(new Set(["emergency"])) - expect(grantedBy.emergency).toEqual(["share-token admin"]) - expect(roles).toEqual(["share-token admin"]) + expect(grants).toEqual([ + { role: "oracle attester", authority: multisig, standing: { needs: 2 } }, + ]) }) - it("grants both configuration and governance for the vault governance, with the role listed once", () => { - const { surfaces, grantedBy, roles } = toSurfaces({ - ...baseReads, - governance: { kind: "value", value: investorAddress }, - }) + it("falls back to weight against threshold when the signers weigh differently", () => { + const { grants } = toSurfaces( + investorAddress, + holders({ "oracle attester": [multisig] }), + { + [multisig]: { + signers: [ + { key: investorAddress, weight: 1 }, + { key: otherAddress, weight: 3 }, + ], + thresholds: { low: 0, med: 3, high: 3 }, + }, + }, + ) + + expect(grants).toEqual([ + { + role: "oracle attester", + authority: multisig, + standing: { weight: 1, threshold: 3 }, + }, + ]) + }) - expect(surfaces).toEqual(new Set(["configuration", "governance"])) - expect(grantedBy.configuration).toEqual(["vault governance"]) - expect(grantedBy.governance).toEqual(["vault governance"]) - expect(roles).toEqual(["vault governance"]) + it("does not grant a signer whose weight is zero", () => { + const { surfaces } = toSurfaces( + investorAddress, + holders({ "vault guardian": [multisig] }), + { [multisig]: oneSignatureOf(0, 0) }, + ) + + expect(surfaces).toEqual(new Set()) }) - it("ignores a role held by a different address", () => { - const { surfaces, roles } = toSurfaces({ - ...baseReads, - manager: { kind: "value", value: otherAddress }, - }) + it("does not grant a wallet that is not among the signers of the holder", () => { + const { surfaces } = toSurfaces( + investorAddress, + holders({ "vault guardian": [multisig] }), + { + [multisig]: { + signers: [{ key: otherAddress, weight: 1 }], + thresholds: { low: 0, med: 1, high: 1 }, + }, + }, + ) expect(surfaces).toEqual(new Set()) - expect(roles).toEqual([]) }) - it("lists every distinct role in surface order, deduplicating the governance label", () => { - const { roles } = toSurfaces({ - ...baseReads, - tokenCompliance: { kind: "value", value: 0 }, - tokenAdmin: { kind: "value", value: investorAddress }, - governance: { kind: "value", value: investorAddress }, - }) + it("grants nothing for a holder whose signers are unknown", () => { + const { surfaces } = toSurfaces( + investorAddress, + holders({ "vault guardian": [multisig] }), + {}, + ) + + expect(surfaces).toEqual(new Set()) + }) + + it("never grants a role held by a contract address", () => { + const { surfaces } = toSurfaces( + investorAddress, + holders({ "share-token admin": [contractHolder] }), + {}, + ) + + expect(surfaces).toEqual(new Set()) + }) + + it("matches the wallet against every holder of a role", () => { + const { grants } = toSurfaces( + investorAddress, + holders({ "oracle attester": [otherAddress, multisig, secondMultisig] }), + { + [multisig]: oneSignatureOf(1), + [secondMultisig]: oneSignatureOf(2), + }, + ) + + expect(grants).toEqual([ + { role: "oracle attester", authority: multisig, standing: "signs-alone" }, + { + role: "oracle attester", + authority: secondMultisig, + standing: { needs: 2 }, + }, + ]) + }) - expect(roles).toEqual([ + it("lists each grant once, in surface order, when a role grants two surfaces", () => { + const { grants } = toSurfaces( + investorAddress, + holders({ + "share-token compliance": [investorAddress], + "vault governance": [multisig], + }), + { [multisig]: oneSignatureOf(1) }, + ) + + expect(grants.map(({ role }) => role)).toEqual([ "share-token compliance", - "share-token admin", "vault governance", ]) }) @@ -158,6 +285,7 @@ const { tokenMock, connectNavOracleMock, connectShareTokenMock, + accountSignersMock, } = vi.hoisted(() => ({ vaultMock: { governance: vi.fn(), @@ -167,14 +295,16 @@ const { oracle: vi.fn(), share_token: vi.fn(), }, - oracleMock: { has_role: vi.fn() }, + oracleMock: { get_role_member_count: vi.fn(), get_role_member: vi.fn() }, tokenMock: { - has_role: vi.fn(), + get_role_member_count: vi.fn(), + get_role_member: vi.fn(), get_admin: vi.fn(), identity_verifier: vi.fn(), }, connectNavOracleMock: vi.fn(), connectShareTokenMock: vi.fn(), + accountSignersMock: vi.fn(), })) vi.mock("../config/clients", () => ({ @@ -187,9 +317,19 @@ vi.mock("@stellar-scaffold/app-lib", async (importOriginal) => { ...actual, connectNavOracle: connectNavOracleMock, connectShareToken: connectShareTokenMock, + accountSigners: accountSignersMock, } }) +const members = ( + client: { get_role_member: ReturnType }, + addresses: string[], +) => { + client.get_role_member.mockImplementation( + async ({ index }: { index: number }) => ({ result: addresses[index] }), + ) +} + describe("useAdminAuthority", () => { const derivedOracleAddress = "CDERIVEDORACLEADDRESS" const derivedShareTokenAddress = "CDERIVEDSHARETOKENADDRESS" @@ -206,12 +346,13 @@ describe("useAdminAuthority", () => { }) connectNavOracleMock.mockResolvedValue(oracleMock) connectShareTokenMock.mockResolvedValue(tokenMock) - oracleMock.has_role.mockResolvedValue({ result: null }) - tokenMock.has_role.mockResolvedValue({ result: null }) + oracleMock.get_role_member_count.mockResolvedValue({ result: 0 }) + tokenMock.get_role_member_count.mockResolvedValue({ result: 0 }) tokenMock.get_admin.mockResolvedValue({ result: null }) tokenMock.identity_verifier.mockResolvedValue({ result: "CVERIFIERADDRESS", }) + accountSignersMock.mockResolvedValue(singleKey) }) it("reports the held surfaces and the derived addresses once every read resolves", async () => { @@ -220,8 +361,21 @@ describe("useAdminAuthority", () => { await waitFor(() => expect(result.current.status).toBe("ready")) expect(result.current.surfaces).toEqual(new Set(["cycle"])) - expect(result.current.grantedBy.cycle).toEqual(["vault manager"]) - expect(result.current.roles).toEqual(["vault manager"]) + expect(result.current.grantedBy.cycle).toEqual([ + { + role: "vault manager", + authority: investorAddress, + standing: "signs-alone", + }, + ]) + expect(result.current.grants).toEqual([ + { + role: "vault manager", + authority: investorAddress, + standing: "signs-alone", + }, + ]) + expect(result.current.signersUnknown).toBe(false) expect(result.current.addresses).toEqual({ oracle: derivedOracleAddress, shareToken: derivedShareTokenAddress, @@ -229,24 +383,137 @@ describe("useAdminAuthority", () => { }) }) - it("asks the share token for the compliance role, not manager", async () => { + it("builds the oracle and token clients from the vault's own oracle()/share_token(), not a configured address", async () => { const { result } = renderWithWallet(useAdminAuthority) await waitFor(() => expect(result.current.status).toBe("ready")) - expect(tokenMock.has_role).toHaveBeenCalledWith({ - account: investorAddress, + expect(connectNavOracleMock).toHaveBeenCalledWith(derivedOracleAddress) + expect(connectShareTokenMock).toHaveBeenCalledWith(derivedShareTokenAddress) + }) + + it("enumerates every member of a role on the oracle and the share token", async () => { + oracleMock.get_role_member_count.mockImplementation( + async ({ role }: { role: string }) => ({ + result: role === "attester" ? 2 : 0, + }), + ) + members(oracleMock, [otherAddress, investorAddress]) + tokenMock.get_role_member_count.mockResolvedValue({ result: 1 }) + members(tokenMock, [investorAddress]) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + + expect(oracleMock.get_role_member_count).toHaveBeenCalledWith({ + role: "attester", + }) + expect(oracleMock.get_role_member).toHaveBeenCalledWith({ + role: "attester", + index: 0, + }) + expect(oracleMock.get_role_member).toHaveBeenCalledWith({ + role: "attester", + index: 1, + }) + expect(tokenMock.get_role_member).toHaveBeenCalledWith({ role: "compliance", + index: 0, }) + expect(result.current.surfaces).toEqual(new Set(["cycle", "compliance"])) }) - it("builds the oracle and token clients from the vault's own oracle()/share_token(), not a configured address", async () => { + it("reads the share-token admin from get_admin", async () => { + tokenMock.get_admin.mockResolvedValue({ result: investorAddress }) const { result } = renderWithWallet(useAdminAuthority) await waitFor(() => expect(result.current.status).toBe("ready")) - expect(connectNavOracleMock).toHaveBeenCalledWith(derivedOracleAddress) - expect(connectShareTokenMock).toHaveBeenCalledWith(derivedShareTokenAddress) + expect(result.current.surfaces).toEqual(new Set(["cycle", "emergency"])) + expect(result.current.grantedBy.emergency[0]?.role).toBe( + "share-token admin", + ) + }) + + it("grants a signer of a multisig authority and looks each account up once", async () => { + vaultMock.manager.mockResolvedValue({ result: null }) + vaultMock.governance.mockResolvedValue({ result: multisig }) + vaultMock.guardian.mockResolvedValue({ result: multisig }) + accountSignersMock.mockResolvedValue(oneSignatureOf(2)) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + + expect(result.current.surfaces).toEqual( + new Set(["configuration", "governance", "emergency"]), + ) + expect(result.current.grants).toEqual([ + { role: "vault guardian", authority: multisig, standing: { needs: 2 } }, + { + role: "vault governance", + authority: multisig, + standing: { needs: 2 }, + }, + ]) + expect(accountSignersMock).toHaveBeenCalledWith(multisig) + expect(accountSignersMock).toHaveBeenCalledTimes(1) + expect(result.current.signersUnknown).toBe(false) + }) + + it("reports the holder key's standing under a 2-of-2 authority", async () => { + vaultMock.manager.mockResolvedValue({ result: null }) + vaultMock.governance.mockResolvedValue({ result: investorAddress }) + accountSignersMock.mockResolvedValue(oneSignatureOf(2)) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + + expect(accountSignersMock).toHaveBeenCalledWith(investorAddress) + expect(result.current.grants).toEqual([ + { + role: "vault governance", + authority: investorAddress, + standing: { needs: 2 }, + }, + ]) + }) + + it("grants the wallet's own holder match when Horizon has no answer", async () => { + accountSignersMock.mockResolvedValue(null) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + + expect(result.current.signersUnknown).toBe(true) + expect(result.current.grants).toEqual([ + { + role: "vault manager", + authority: investorAddress, + standing: "signs-alone", + }, + ]) + }) + + it("never asks Horizon about a contract holder", async () => { + vaultMock.guardian.mockResolvedValue({ result: contractHolder }) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + + expect(accountSignersMock).not.toHaveBeenCalledWith(contractHolder) + expect(result.current.signersUnknown).toBe(false) + expect(result.current.surfaces).toEqual(new Set(["cycle"])) + }) + + it("keeps the holder match and reports signers unknown when Horizon has no answer", async () => { + vaultMock.governance.mockResolvedValue({ result: multisig }) + accountSignersMock.mockResolvedValue(null) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("ready")) + + expect(result.current.signersUnknown).toBe(true) + expect(result.current.surfaces).toEqual(new Set(["cycle"])) }) it("reports unreadable when a role-determining read fails, without guessing a surface", async () => { @@ -256,7 +523,17 @@ describe("useAdminAuthority", () => { await waitFor(() => expect(result.current.status).toBe("unreadable")) expect(result.current.surfaces).toEqual(new Set()) - expect(result.current.roles).toEqual([]) + expect(result.current.grants).toEqual([]) + }) + + it("reports unreadable when a role member read fails", async () => { + oracleMock.get_role_member_count.mockResolvedValue({ result: 1 }) + oracleMock.get_role_member.mockRejectedValue(new Error("simulation failed")) + const { result } = renderWithWallet(useAdminAuthority) + + await waitFor(() => expect(result.current.status).toBe("unreadable")) + + expect(result.current.surfaces).toEqual(new Set()) }) it("reports unreadable without deriving any role client when the vault's share_token() read fails", async () => { diff --git a/app/src/hooks/useAdminAuthority.ts b/app/src/hooks/useAdminAuthority.ts index 680e67b..c286431 100644 --- a/app/src/hooks/useAdminAuthority.ts +++ b/app/src/hooks/useAdminAuthority.ts @@ -1,4 +1,6 @@ import { + type AccountSigners, + accountSigners, connectNavOracle, connectShareToken, type ContractRead, @@ -36,27 +38,42 @@ export interface AdminAddresses { identityVerifier: string | null } +export type Standing = + "signs-alone" | { needs: number } | { weight: number; threshold: number } + +export interface Grant { + role: string + authority: string + standing: Standing +} + export interface AdminAuthority { status: AdminAuthorityStatus surfaces: Set - grantedBy: Record - roles: string[] + grantedBy: Record + grants: Grant[] + signersUnknown: boolean addresses: AdminAddresses } -export interface AuthorityReads { - address: string - governance: ContractRead - manager: ContractRead - treasury: ContractRead - guardian: ContractRead - oracleAttester: ContractRead - oracleGuardian: ContractRead - tokenCompliance: ContractRead - tokenAdmin: ContractRead -} +const ROLE_SURFACES = { + "vault manager": ["cycle"], + "vault treasury": ["cycle"], + "oracle attester": ["cycle"], + "share-token compliance": ["compliance"], + "vault guardian": ["emergency"], + "oracle guardian": ["emergency"], + "share-token admin": ["emergency"], + "vault governance": ["configuration", "governance"], +} as const satisfies Record -const emptyGrantedBy = (): Record => ({ +type RoleLabel = keyof typeof ROLE_SURFACES + +export type RoleHolders = Record + +const ROLE_LABELS = Object.keys(ROLE_SURFACES) as RoleLabel[] + +const emptyGrantedBy = (): Record => ({ cycle: [], compliance: [], emergency: [], @@ -70,60 +87,72 @@ const emptyAddresses = (): AdminAddresses => ({ identityVerifier: null, }) -const isHeldBy = ( - read: ContractRead, - address: string, -): boolean => read.kind === "value" && read.value === address - -const hasRole = (read: ContractRead): boolean => - read.kind === "value" && read.value !== null +const emptyAuthority = ( + status: AdminAuthorityStatus, + addresses = emptyAddresses(), +): AdminAuthority => ({ + status, + surfaces: new Set(), + grantedBy: emptyGrantedBy(), + grants: [], + signersUnknown: false, + addresses, +}) -const toRoles = (grantedBy: Record): string[] => { - const roles: string[] = [] +const toGrants = (grantedBy: Record): Grant[] => { + const grants: Grant[] = [] for (const surface of SURFACE_ORDER) { - for (const role of grantedBy[surface]) { - if (!roles.includes(role)) roles.push(role) + for (const grant of grantedBy[surface]) { + if (!grants.includes(grant)) grants.push(grant) } } - return roles + return grants +} + +const standingOf = ( + wallet: string, + holder: string, + signers: AccountSigners | undefined, +): Standing | null => { + if (signers === undefined) return wallet === holder ? "signs-alone" : null + const weight = signers.signers.find(({ key }) => key === wallet)?.weight + if (weight === undefined || weight === 0) return null + const threshold = signers.thresholds.med + if (weight >= threshold) return "signs-alone" + const everySignerWeighsOne = signers.signers.every( + ({ weight: other }) => other <= 1, + ) + return everySignerWeighsOne ? { needs: threshold } : { weight, threshold } } -export function toSurfaces(reads: AuthorityReads): { +export function toSurfaces( + wallet: string, + holders: RoleHolders, + signersByAccount: Record, +): { surfaces: Set - grantedBy: Record - roles: string[] + grantedBy: Record + grants: Grant[] } { const grantedBy = emptyGrantedBy() - if (isHeldBy(reads.manager, reads.address)) { - grantedBy.cycle.push("vault manager") - } - if (isHeldBy(reads.treasury, reads.address)) { - grantedBy.cycle.push("vault treasury") - } - if (hasRole(reads.oracleAttester)) grantedBy.cycle.push("oracle attester") - - if (hasRole(reads.tokenCompliance)) { - grantedBy.compliance.push("share-token compliance") - } - - if (isHeldBy(reads.guardian, reads.address)) { - grantedBy.emergency.push("vault guardian") - } - if (hasRole(reads.oracleGuardian)) grantedBy.emergency.push("oracle guardian") - if (isHeldBy(reads.tokenAdmin, reads.address)) { - grantedBy.emergency.push("share-token admin") - } - - if (isHeldBy(reads.governance, reads.address)) { - grantedBy.configuration.push("vault governance") - grantedBy.governance.push("vault governance") + for (const role of ROLE_LABELS) { + for (const authority of holders[role]) { + const standing = standingOf( + wallet, + authority, + signersByAccount[authority], + ) + if (standing === null) continue + const grant: Grant = { role, authority, standing } + for (const surface of ROLE_SURFACES[role]) grantedBy[surface].push(grant) + } } const surfaces = new Set( SURFACE_ORDER.filter((surface) => grantedBy[surface].length > 0), ) - return { surfaces, grantedBy, roles: toRoles(grantedBy) } + return { surfaces, grantedBy, grants: toGrants(grantedBy) } } const isUnreadable = (read: ContractRead): boolean => @@ -136,17 +165,70 @@ const isValue = ( const toAddressOrNull = (read: ContractRead): string | null => read.kind === "value" ? read.value : null -interface AdminAuthorityFetchResult { - status: "unreadable" | "ready" - surfaces: Set - grantedBy: Record - roles: string[] - addresses: AdminAddresses +type ReadCall = Parameters>[0] + +interface RoleMemberClient { + get_role_member_count(args: { role: string }): ReturnType> + get_role_member(args: { + role: string + index: number + }): ReturnType> } -async function fetchAdminAuthority( - address: string, -): Promise { +async function readRoleMembers( + client: Promise, + role: string, +): Promise> { + const count = await readContract(async () => + (await client).get_role_member_count({ role }), + ) + if (!isValue(count)) return count + + const reads = await Promise.all( + Array.from({ length: count.value }, (_, index) => + readContract(async () => (await client).get_role_member({ role, index })), + ), + ) + const members: string[] = [] + for (const read of reads) { + if (!isValue(read)) return read + members.push(read.value) + } + return { kind: "value", value: members } +} + +const asHolders = (read: ContractRead): string[] => + read.kind === "value" && read.value !== null ? [read.value] : [] + +const asMembers = (read: ContractRead): string[] => + read.kind === "value" ? read.value : [] + +async function readSigners( + holders: RoleHolders, +): Promise<{ byAccount: Record; unknown: boolean }> { + const accounts = new Set() + for (const role of ROLE_LABELS) { + for (const holder of holders[role]) { + if (holder.startsWith("G")) accounts.add(holder) + } + } + + const byAccount: Record = {} + let unknown = false + const results = await Promise.all( + [...accounts].map(async (account) => ({ + account, + signers: await accountSigners(account), + })), + ) + for (const { account, signers } of results) { + if (signers === null) unknown = true + else byAccount[account] = signers + } + return { byAccount, unknown } +} + +async function fetchAdminAuthority(wallet: string): Promise { const [ governance, manager, @@ -164,41 +246,26 @@ async function fetchAdminAuthority( ]) if (!isValue(oracleAddress) || !isValue(shareTokenAddress)) { - return { - status: "unreadable", - surfaces: new Set(), - grantedBy: emptyGrantedBy(), - roles: [], - addresses: { - oracle: toAddressOrNull(oracleAddress), - shareToken: toAddressOrNull(shareTokenAddress), - identityVerifier: null, - }, - } + return emptyAuthority("unreadable", { + oracle: toAddressOrNull(oracleAddress), + shareToken: toAddressOrNull(shareTokenAddress), + identityVerifier: null, + }) } const oraclePromise = connectNavOracle(oracleAddress.value) const tokenPromise = connectShareToken(shareTokenAddress.value) const [ - oracleAttester, - oracleGuardian, + oracleAttesters, + oracleGuardians, tokenCompliance, tokenAdmin, identityVerifier, ] = await Promise.all([ - readContract(async () => - (await oraclePromise).has_role({ account: address, role: "attester" }), - ), - readContract(async () => - (await oraclePromise).has_role({ account: address, role: "guardian" }), - ), - readContract(async () => - (await tokenPromise).has_role({ - account: address, - role: "compliance", - }), - ), + readRoleMembers(oraclePromise, "attester"), + readRoleMembers(oraclePromise, "guardian"), + readRoleMembers(tokenPromise, "compliance"), readContract(async () => (await tokenPromise).get_admin()), readContract(async () => (await tokenPromise).identity_verifier()), ]) @@ -209,39 +276,37 @@ async function fetchAdminAuthority( identityVerifier: toAddressOrNull(identityVerifier), } - const roleReads = [ + const roleReads: ContractRead[] = [ governance, manager, treasury, guardian, - oracleAttester, - oracleGuardian, + oracleAttesters, + oracleGuardians, tokenCompliance, tokenAdmin, ] - if (roleReads.some(isUnreadable)) { - return { - status: "unreadable", - surfaces: new Set(), - grantedBy: emptyGrantedBy(), - roles: [], - addresses, - } + if (roleReads.some(isUnreadable)) + return emptyAuthority("unreadable", addresses) + + const holders: RoleHolders = { + "vault governance": asHolders(governance), + "vault manager": asHolders(manager), + "vault treasury": asHolders(treasury), + "vault guardian": asHolders(guardian), + "oracle attester": asMembers(oracleAttesters), + "oracle guardian": asMembers(oracleGuardians), + "share-token compliance": asMembers(tokenCompliance), + "share-token admin": asHolders(tokenAdmin), } + const { byAccount, unknown } = await readSigners(holders) - const { surfaces, grantedBy, roles } = toSurfaces({ - address, - governance, - manager, - treasury, - guardian, - oracleAttester, - oracleGuardian, - tokenCompliance, - tokenAdmin, - }) - - return { status: "ready", surfaces, grantedBy, roles, addresses } + return { + status: "ready", + ...toSurfaces(wallet, holders, byAccount), + signersUnknown: unknown, + addresses, + } } export function useAdminAuthority(): AdminAuthority { @@ -254,23 +319,6 @@ export function useAdminAuthority(): AdminAuthority { refetchOnWindowFocus: false, }) - if (address === undefined) { - return { - status: "disconnected", - surfaces: new Set(), - grantedBy: emptyGrantedBy(), - roles: [], - addresses: emptyAddresses(), - } - } - - return ( - data ?? { - status: "checking", - surfaces: new Set(), - grantedBy: emptyGrantedBy(), - roles: [], - addresses: emptyAddresses(), - } - ) + if (address === undefined) return emptyAuthority("disconnected") + return data ?? emptyAuthority("checking") } diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx index 842f9f3..9046287 100644 --- a/app/src/pages/AdminPanel.test.tsx +++ b/app/src/pages/AdminPanel.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import type * as UseAdminAuthority from "../hooks/useAdminAuthority" -import { type AdminAuthority } from "../hooks/useAdminAuthority" +import { type AdminAuthority, type Grant } from "../hooks/useAdminAuthority" import AdminPanel from "./AdminPanel" const { useAdminAuthorityMock, useWalletMock } = vi.hoisted(() => ({ @@ -19,20 +19,27 @@ vi.mock("../hooks/useWallet", () => ({ useWallet: useWalletMock })) const connectedAddress = "GCONNECTEDADDRESS1234567890" const emptyGrantedBy = { - cycle: [] as string[], - compliance: [] as string[], - emergency: [] as string[], - configuration: [] as string[], - governance: [] as string[], + cycle: [] as Grant[], + compliance: [] as Grant[], + emergency: [] as Grant[], + configuration: [] as Grant[], + governance: [] as Grant[], } +const holderOf = (role: string): Grant => ({ + role, + authority: connectedAddress, + standing: "signs-alone", +}) + const noAddresses = { oracle: null, shareToken: null, identityVerifier: null } const authority = (overrides: Partial): AdminAuthority => ({ status: "disconnected", surfaces: new Set(), grantedBy: emptyGrantedBy, - roles: [], + grants: [], + signersUnknown: false, addresses: noAddresses, ...overrides, }) @@ -93,17 +100,21 @@ describe("AdminPanel", () => { }) it("renders a card per held surface, in Cycle · Compliance · Emergency · Configuration · Governance order", () => { + const manager = holderOf("vault manager") + const guardian = holderOf("vault guardian") + const oracleGuardian = holderOf("oracle guardian") + const governance = holderOf("vault governance") useAdminAuthorityMock.mockReturnValue( authority({ status: "ready", surfaces: new Set(["governance", "cycle", "emergency"]), grantedBy: { ...emptyGrantedBy, - cycle: ["vault manager"], - emergency: ["vault guardian", "oracle guardian"], - governance: ["vault governance"], + cycle: [manager], + emergency: [guardian, oracleGuardian], + governance: [governance], }, - roles: ["vault manager", "vault guardian", "oracle guardian"], + grants: [manager, guardian, oracleGuardian, governance], }), ) render() @@ -122,8 +133,104 @@ describe("AdminPanel", () => { ).toBeNull() expect( screen.getByText( - "GCON...7890 · vault manager · vault guardian · oracle guardian", + "GCON...7890 · vault manager · vault guardian · oracle guardian · vault governance", + ), + ).toBeTruthy() + }) + + it("names the signature standing on the identity line and on the cards", () => { + const governance: Grant = { + role: "vault governance", + authority: "GGOVERNANCEMULTISIG", + standing: "signs-alone", + } + const attester: Grant = { + role: "oracle attester", + authority: "GATTESTERMULTISIG", + standing: { needs: 2 }, + } + const compliance: Grant = { + role: "share-token compliance", + authority: "GCOMPLIANCEMULTISIG", + standing: { weight: 1, threshold: 3 }, + } + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle", "compliance", "governance"]), + grantedBy: { + ...emptyGrantedBy, + cycle: [attester], + compliance: [compliance], + governance: [governance], + }, + grants: [attester, compliance, governance], + }), + ) + render() + + expect( + screen.getByText( + "GCON...7890 · oracle attester (1 of 2 signatures) · share-token compliance (weight 1 of threshold 3) · vault governance (signs alone)", + ), + ).toBeTruthy() + expect( + screen.getByText("Granted by vault governance — signs alone"), + ).toBeTruthy() + expect( + screen.getByText("Granted by oracle attester — 1 of 2 signatures"), + ).toBeTruthy() + expect( + screen.getByText( + "Granted by share-token compliance — weight 1 of threshold 3", ), ).toBeTruthy() }) + + it("says nothing extra when the wallet is the holder itself", () => { + const manager = holderOf("vault manager") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle"]), + grantedBy: { ...emptyGrantedBy, cycle: [manager] }, + grants: [manager], + }), + ) + render() + + expect(screen.getByText("Granted by vault manager")).toBeTruthy() + expect(screen.getByText("GCON...7890 · vault manager")).toBeTruthy() + expect(screen.queryByText("Signer lookup unavailable.")).toBeNull() + }) + + it("names the standing of the wallet's own key when it cannot act alone", () => { + const governance: Grant = { + role: "vault governance", + authority: connectedAddress, + standing: { needs: 2 }, + } + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["governance"]), + grantedBy: { ...emptyGrantedBy, governance: [governance] }, + grants: [governance], + }), + ) + render() + + expect( + screen.getByText("GCON...7890 · vault governance (1 of 2 signatures)"), + ).toBeTruthy() + }) + + it("adds one line when the signer lookup was unavailable", () => { + useAdminAuthorityMock.mockReturnValue( + authority({ status: "ready", signersUnknown: true }), + ) + render() + + expect(screen.getAllByText("Signer lookup unavailable.")).toHaveLength(1) + }) }) diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index cc78f9e..35c86f7 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -5,6 +5,8 @@ import AddressRows, { type AddressRow } from "../components/vault/AddressRows" import { vaultContractId } from "../config/contracts" import { type AdminAddresses, + type Grant, + type Standing, SURFACE_LABELS, SURFACE_ORDER, useAdminAuthority, @@ -20,6 +22,25 @@ const toAddressRows = (addresses: AdminAddresses): AddressRow[] => [ { label: "Identity verifier", address: addresses.identityVerifier }, ] +const describeStanding = (standing: Standing): string => { + if (standing === "signs-alone") return "signs alone" + if ("needs" in standing) return `1 of ${standing.needs} signatures` + return `weight ${standing.weight} of threshold ${standing.threshold}` +} + +const isSingleKey = (grant: Grant, wallet: string): boolean => + grant.authority === wallet && grant.standing === "signs-alone" + +const describeGrant = (grant: Grant, wallet: string): string => + isSingleKey(grant, wallet) + ? grant.role + : `${grant.role} (${describeStanding(grant.standing)})` + +const describeCardGrant = (grant: Grant, wallet: string): string => + isSingleKey(grant, wallet) + ? grant.role + : `${grant.role} — ${describeStanding(grant.standing)}` + const AddressesBlock: React.FC<{ addresses: AdminAddresses }> = ({ addresses, }) => ( @@ -32,7 +53,8 @@ const AddressesBlock: React.FC<{ addresses: AdminAddresses }> = ({ ) const AdminPanel: React.FC = () => { - const { status, surfaces, grantedBy, roles, addresses } = useAdminAuthority() + const { status, surfaces, grantedBy, grants, signersUnknown, addresses } = + useAdminAuthority() const { address } = useWallet() return ( @@ -42,10 +64,16 @@ const AdminPanel: React.FC = () => { {status === "ready" && address !== undefined && (

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

)} + {status === "ready" && signersUnknown && ( +

Signer lookup unavailable.

+ )} + {status === "disconnected" && (

Connect a wallet to operate this vault. @@ -86,7 +114,10 @@ const AdminPanel: React.FC = () => { {SURFACE_LABELS[surface]} - Granted by {grantedBy[surface].join(", ")} + Granted by{" "} + {grantedBy[surface] + .map((grant) => describeCardGrant(grant, address ?? "")) + .join(", ")} ),