From a3188c9400a891ec1c5a4bac41ec5aaf2e8069ab Mon Sep 17 00:00:00 2001 From: Justice Date: Wed, 29 Jul 2026 11:02:41 +0100 Subject: [PATCH] Refresh owner weights on interval --- .../hooks/__tests__/useOwnerWeights.test.ts | 121 +++++++++++++++--- frontend/src/hooks/useOwnerWeights.ts | 114 ++++++++--------- frontend/src/lib/contract.ts | 7 +- frontend/src/pages/OwnersPage.test.tsx | 17 +-- frontend/src/pages/OwnersPage.tsx | 29 ++++- frontend/src/types/accord.ts | 5 + 6 files changed, 194 insertions(+), 99 deletions(-) diff --git a/frontend/src/hooks/__tests__/useOwnerWeights.test.ts b/frontend/src/hooks/__tests__/useOwnerWeights.test.ts index bce0eea..25f5a66 100644 --- a/frontend/src/hooks/__tests__/useOwnerWeights.test.ts +++ b/frontend/src/hooks/__tests__/useOwnerWeights.test.ts @@ -1,35 +1,122 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { describe, expect, test } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import * as contract from "../../lib/contract"; import { useOwnerWeights } from "../useOwnerWeights"; +vi.mock("../../lib/contract", () => ({ + getOwnerWeights: vi.fn(), +})); + +const intervalMs = 5000; +const initialWeights = [ + { address: "GOWNER111", weight: 4 }, + { address: "GOWNER222", weight: 6 }, +]; +const refreshedWeights = [ + { address: "GOWNER111", weight: 3 }, + { address: "GOWNER222", weight: 7 }, +]; + describe("useOwnerWeights", () => { - test("returns no weights for an empty owner list", () => { - const { result } = renderHook(() => useOwnerWeights([])); + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test("fetches owner weights on mount", async () => { + vi.mocked(contract.getOwnerWeights).mockResolvedValueOnce(initialWeights); + + const { result } = renderHook(() => useOwnerWeights(intervalMs)); expect(result.current).toEqual({ - weightsByAddress: {}, - totalWeight: 0, - loading: false, + ownerWeights: [], + loading: true, error: null, }); + + await vi.waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(contract.getOwnerWeights).toHaveBeenCalledTimes(1); + expect(result.current.ownerWeights).toEqual(initialWeights); + expect(result.current.error).toBeNull(); }); - test("loads current flat voting weights for owners", async () => { - const ownerAddresses = ["GOWNER111", "GOWNER222"]; + test("refreshes owner weights on an interval", async () => { + vi.mocked(contract.getOwnerWeights) + .mockResolvedValueOnce(initialWeights) + .mockResolvedValueOnce(refreshedWeights); + + const { result } = renderHook(() => useOwnerWeights(intervalMs)); - const { result } = renderHook(() => useOwnerWeights(ownerAddresses)); + await vi.waitFor(() => { + expect(result.current.ownerWeights).toEqual(initialWeights); + }); - expect(result.current.loading).toBe(true); + act(() => { + vi.advanceTimersByTime(intervalMs); + }); - await waitFor(() => { - expect(result.current.loading).toBe(false); + await vi.waitFor(() => { + expect(contract.getOwnerWeights).toHaveBeenCalledTimes(2); + expect(result.current.ownerWeights).toEqual(refreshedWeights); }); + expect(result.current.loading).toBe(false); expect(result.current.error).toBeNull(); - expect(result.current.totalWeight).toBe(2); - expect(result.current.weightsByAddress).toEqual({ - GOWNER111: 1, - GOWNER222: 1, + }); + + test("keeps cached owner weights when a refresh fails", async () => { + const refreshError = new Error("RPC unavailable"); + vi.mocked(contract.getOwnerWeights) + .mockResolvedValueOnce(initialWeights) + .mockRejectedValueOnce(refreshError); + + const { result } = renderHook(() => useOwnerWeights(intervalMs)); + + await vi.waitFor(() => { + expect(result.current.ownerWeights).toEqual(initialWeights); + }); + + act(() => { + vi.advanceTimersByTime(intervalMs); }); + + await vi.waitFor(() => { + expect(contract.getOwnerWeights).toHaveBeenCalledTimes(2); + expect(result.current.error).toBe("RPC unavailable"); + }); + + expect(result.current.ownerWeights).toEqual(initialWeights); + expect(result.current.loading).toBe(false); + expect(console.error).toHaveBeenCalledWith( + "Failed to fetch owner weights", + refreshError, + ); + }); + + test("stops refreshing after unmount", async () => { + vi.mocked(contract.getOwnerWeights).mockResolvedValueOnce(initialWeights); + + const { result, unmount } = renderHook(() => useOwnerWeights(intervalMs)); + + await vi.waitFor(() => { + expect(result.current.ownerWeights).toEqual(initialWeights); + }); + + unmount(); + + act(() => { + vi.advanceTimersByTime(intervalMs); + }); + + expect(contract.getOwnerWeights).toHaveBeenCalledTimes(1); }); }); diff --git a/frontend/src/hooks/useOwnerWeights.ts b/frontend/src/hooks/useOwnerWeights.ts index 854d65c..8498834 100644 --- a/frontend/src/hooks/useOwnerWeights.ts +++ b/frontend/src/hooks/useOwnerWeights.ts @@ -1,87 +1,73 @@ import { useEffect, useState } from "react"; +import { getOwnerWeights } from "../lib/contract"; +import type { OwnerWeight } from "../types/accord"; -export type OwnerWeightState = { - weightsByAddress: Record; - totalWeight: number; +export const OWNER_WEIGHTS_REFRESH_INTERVAL_MS = 5000; + +export type OwnerWeightsState = { + ownerWeights: OwnerWeight[]; loading: boolean; error: string | null; }; -function buildFlatOwnerWeights(ownerAddresses: string[]) { - const weightsByAddress = ownerAddresses.reduce>( - (acc, address) => { - acc[address] = 1; - return acc; - }, - {}, - ); - - return { - weightsByAddress, - totalWeight: ownerAddresses.length, - }; +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : "Failed to load owner weights"; } -export function useOwnerWeights(ownerAddresses: string[]): OwnerWeightState { - const [state, setState] = useState(() => ({ - weightsByAddress: {}, - totalWeight: 0, - loading: ownerAddresses.length > 0, +export function useOwnerWeights( + intervalMs = OWNER_WEIGHTS_REFRESH_INTERVAL_MS, +): OwnerWeightsState { + const [state, setState] = useState({ + ownerWeights: [], + loading: true, error: null, - })); - - const ownerAddressKey = ownerAddresses.join("\n"); + }); useEffect(() => { let cancelled = false; - const addresses = ownerAddressKey ? ownerAddressKey.split("\n") : []; + let inFlight = false; + + async function fetchWeights() { + if (inFlight) { + return; + } + + inFlight = true; + + try { + const ownerWeights = await getOwnerWeights(); + + if (!cancelled) { + setState({ + ownerWeights, + loading: false, + error: null, + }); + } + } catch (err) { + console.error("Failed to fetch owner weights", err); - if (addresses.length === 0) { - setState({ - weightsByAddress: {}, - totalWeight: 0, - loading: false, - error: null, - }); - return () => { - cancelled = true; - }; + if (!cancelled) { + setState((prev) => ({ + ...prev, + loading: false, + error: errorMessage(err), + })); + } + } finally { + inFlight = false; + } } - setState((prev) => ({ - ...prev, - loading: true, - error: null, - })); + fetchWeights(); - Promise.resolve() - .then(() => buildFlatOwnerWeights(addresses)) - .then(({ weightsByAddress, totalWeight }) => { - if (cancelled) return; - setState({ - weightsByAddress, - totalWeight, - loading: false, - error: null, - }); - }) - .catch((err) => { - if (cancelled) return; - setState({ - weightsByAddress: {}, - totalWeight: 0, - loading: false, - error: - err instanceof Error - ? err.message - : "Failed to load owner weights", - }); - }); + const intervalId = setInterval(fetchWeights, intervalMs); return () => { cancelled = true; + clearInterval(intervalId); }; - }, [ownerAddressKey]); + }, [intervalMs]); return state; } diff --git a/frontend/src/lib/contract.ts b/frontend/src/lib/contract.ts index 4ca250c..cae9523 100644 --- a/frontend/src/lib/contract.ts +++ b/frontend/src/lib/contract.ts @@ -6,7 +6,7 @@ import { scValToNative, xdr, } from "@stellar/stellar-sdk"; -import type { Proposal, ProposalStatus } from "../types/accord"; +import type { OwnerWeight, Proposal, ProposalStatus } from "../types/accord"; import { stroopsToDisplay, formatDeadline, shortenAddr } from "./soroban"; const RPC_URL = import.meta.env.VITE_SOROBAN_RPC_URL as string; @@ -139,6 +139,11 @@ export async function getOwners(): Promise { return scValToNative(val) as string[]; } +export async function getOwnerWeights(): Promise { + const owners = await getOwners(); + return owners.map((address) => ({ address, weight: 1 })); +} + export async function getThreshold(): Promise { const val = await simulateView("get_threshold"); return Number(scValToNative(val)); diff --git a/frontend/src/pages/OwnersPage.test.tsx b/frontend/src/pages/OwnersPage.test.tsx index caa5d5c..ffcc02f 100644 --- a/frontend/src/pages/OwnersPage.test.tsx +++ b/frontend/src/pages/OwnersPage.test.tsx @@ -35,18 +35,17 @@ describe("OwnersPage", () => { test("shows weighted quorum and each owner voting share", () => { mockUseOwnerWeights.mockReturnValue({ - weightsByAddress: { - GOWNER111: 5, - GOWNER222: 15, - }, - totalWeight: 20, + ownerWeights: [ + { address: "GOWNER111", weight: 5 }, + { address: "GOWNER222", weight: 15 }, + ], loading: false, error: null, }); renderOwnersPage(); - expect(mockUseOwnerWeights).toHaveBeenCalledWith(ownerAddresses); + expect(mockUseOwnerWeights).toHaveBeenCalledWith(); expect(screen.getByText("Requires 5 of 20 voting weight")).toBeInTheDocument(); expect(screen.getByText("25.0% of voting power must approve.")) .toBeInTheDocument(); @@ -60,8 +59,7 @@ describe("OwnersPage", () => { test("keeps owners visible while voting weights load", () => { mockUseOwnerWeights.mockReturnValue({ - weightsByAddress: {}, - totalWeight: 0, + ownerWeights: [], loading: true, error: null, }); @@ -79,8 +77,7 @@ describe("OwnersPage", () => { test("keeps owners visible when voting weights fail to load", () => { mockUseOwnerWeights.mockReturnValue({ - weightsByAddress: {}, - totalWeight: 0, + ownerWeights: [], loading: false, error: "Failed to load owner weights", }); diff --git a/frontend/src/pages/OwnersPage.tsx b/frontend/src/pages/OwnersPage.tsx index 26833d8..bdb710e 100644 --- a/frontend/src/pages/OwnersPage.tsx +++ b/frontend/src/pages/OwnersPage.tsx @@ -16,14 +16,26 @@ export function OwnersPage({ totalOwners, }: OwnersPageProps) { const { - weightsByAddress, - totalWeight, + ownerWeights, loading: ownerWeightsLoading, error: ownerWeightsError, - } = useOwnerWeights(ownerAddresses); + } = useOwnerWeights(); + const weightsByAddress = ownerWeights.reduce>( + (acc, { address, weight }) => { + acc[address] = weight; + return acc; + }, + {}, + ); + const totalWeight = ownerWeights.reduce( + (total, { weight }) => total + weight, + 0, + ); const ownerCountLabel = `${totalOwners} ${totalOwners === 1 ? "owner" : "owners"}`; - const hasOwnerWeights = !ownerWeightsLoading && !ownerWeightsError; + const weightsUnavailable = Boolean(ownerWeightsError && ownerWeights.length === 0); + const hasOwnerWeights = !ownerWeightsLoading && !weightsUnavailable; + const weightsStale = Boolean(ownerWeightsError && ownerWeights.length > 0); const quorumPercent = hasOwnerWeights ? formatWeightPercent(threshold, totalWeight) : null; @@ -41,11 +53,14 @@ export function OwnersPage({

{ownerWeightsLoading ? `Loading voting power across ${ownerCountLabel}...` - : ownerWeightsError + : weightsUnavailable ? "Voting power unavailable; owners remain visible." : `${quorumPercent} of voting power must approve.`}

- {ownerWeightsError && ( + {weightsStale && ( +

Voting weights may be stale.

+ )} + {weightsUnavailable && (

Voting weights unavailable.

)} @@ -81,7 +96,7 @@ export function OwnersPage({
{ownerWeightsLoading ? (

Loading weight...

- ) : ownerWeightsError ? ( + ) : weightsUnavailable ? (

Weight unavailable

) : ( <> diff --git a/frontend/src/types/accord.ts b/frontend/src/types/accord.ts index 8fcf79a..a56c3fd 100644 --- a/frontend/src/types/accord.ts +++ b/frontend/src/types/accord.ts @@ -22,6 +22,11 @@ export type Owner = { label: string; }; +export type OwnerWeight = { + address: string; + weight: number; +}; + export type DashboardStat = { label: string; value: string;