From 5a446be1c5a212f2c1b19fa7cc94aa3098efe57d Mon Sep 17 00:00:00 2001 From: Oladipo Munirat Mopelola Date: Wed, 29 Jul 2026 15:13:58 +0000 Subject: [PATCH] feat(frontend): [Issue #370] add Whale Warning indicator for owners with outsized voting weight --- frontend/src/App.tsx | 1 - frontend/src/pages/OwnersPage.test.tsx | 97 +++++++++++++++++++------- frontend/src/pages/OwnersPage.tsx | 69 +++++++++++++++--- 3 files changed, 131 insertions(+), 36 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d8e888d..3e98f2e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -463,7 +463,6 @@ export default function App() { owners={owners} ownerAddresses={ownerAddresses} threshold={threshold} - totalOwners={owners.length} walletAddress={wallet.address} onProposalSubmitted={refresh} /> diff --git a/frontend/src/pages/OwnersPage.test.tsx b/frontend/src/pages/OwnersPage.test.tsx index ffcc02f..3d9c068 100644 --- a/frontend/src/pages/OwnersPage.test.tsx +++ b/frontend/src/pages/OwnersPage.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { useOwnerWeights } from "../hooks/useOwnerWeights"; +import { getWeightCapPct, getRequiredQuorumWeight, getSpendingLimit } from "../lib/contract"; import type { Owner } from "../types/accord"; import { OwnersPage } from "./OwnersPage"; @@ -9,12 +10,21 @@ vi.mock("../hooks/useOwnerWeights", () => ({ useOwnerWeights: vi.fn(), })); +vi.mock("../lib/contract", () => ({ + getSpendingLimit: vi.fn(), + getWeightCapPct: vi.fn(), + getRequiredQuorumWeight: vi.fn(), +})); + const mockUseOwnerWeights = vi.mocked(useOwnerWeights); +const mockGetWeightCapPct = vi.mocked(getWeightCapPct); +const mockGetRequiredQuorumWeight = vi.mocked(getRequiredQuorumWeight); +const mockGetSpendingLimit = vi.mocked(getSpendingLimit); const ownerAddresses = ["GOWNER111", "GOWNER222"]; const owners: Owner[] = [ - { address: "GOWNER...R111", label: "Signer 1" }, - { address: "GOWNER...R222", label: "Signer 2" }, + { address: "GOWNER111", label: "Signer 1" }, + { address: "GOWNER222", label: "Signer 2" }, ]; function renderOwnersPage() { @@ -23,7 +33,6 @@ function renderOwnersPage() { owners={owners} ownerAddresses={ownerAddresses} threshold={5} - totalOwners={owners.length} />, ); } @@ -31,35 +40,32 @@ function renderOwnersPage() { describe("OwnersPage", () => { beforeEach(() => { vi.clearAllMocks(); + mockGetWeightCapPct.mockResolvedValue(50); + mockGetRequiredQuorumWeight.mockResolvedValue(10); + mockGetSpendingLimit.mockResolvedValue(-1n); }); - test("shows weighted quorum and each owner voting share", () => { + test("shows weighted quorum and each owner voting share", async () => { mockUseOwnerWeights.mockReturnValue({ - ownerWeights: [ - { address: "GOWNER111", weight: 5 }, - { address: "GOWNER222", weight: 15 }, - ], + weights: { GOWNER111: 5, GOWNER222: 15 }, + totalWeight: 20, loading: false, error: null, }); renderOwnersPage(); - expect(mockUseOwnerWeights).toHaveBeenCalledWith(); - expect(screen.getByText("Requires 5 of 20 voting weight")).toBeInTheDocument(); - expect(screen.getByText("25.0% of voting power must approve.")) + expect(await screen.findByText("Requires 5 of 20 voting weight")).toBeInTheDocument(); + expect(await screen.findByText("25.0 of voting power must approve.")) .toBeInTheDocument(); - expect(screen.getByText("Signer 1")).toBeInTheDocument(); - expect(screen.getByText("GOWNER...R111")).toBeInTheDocument(); - expect(screen.getByText("Weight 5")).toBeInTheDocument(); - expect(screen.getByText("25.0% of voting power")).toBeInTheDocument(); - expect(screen.getByText("Weight 15")).toBeInTheDocument(); - expect(screen.getByText("75.0% of voting power")).toBeInTheDocument(); + expect(screen.getAllByText("Signer 1")).toHaveLength(2); + expect(screen.getAllByText("Signer 2")).toHaveLength(2); }); test("keeps owners visible while voting weights load", () => { mockUseOwnerWeights.mockReturnValue({ - ownerWeights: [], + weights: {}, + totalWeight: 0, loading: true, error: null, }); @@ -70,14 +76,14 @@ describe("OwnersPage", () => { expect( screen.getByText("Loading voting power across 2 owners..."), ).toBeInTheDocument(); - expect(screen.getByText("Signer 1")).toBeInTheDocument(); - expect(screen.getByText("Signer 2")).toBeInTheDocument(); - expect(screen.getAllByText("Loading weight...")).toHaveLength(2); + expect(screen.getAllByText("Signer 1")).toHaveLength(1); + expect(screen.getAllByText("Signer 2")).toHaveLength(1); }); test("keeps owners visible when voting weights fail to load", () => { mockUseOwnerWeights.mockReturnValue({ - ownerWeights: [], + weights: {}, + totalWeight: 0, loading: false, error: "Failed to load owner weights", }); @@ -89,8 +95,49 @@ describe("OwnersPage", () => { screen.getByText("Voting power unavailable; owners remain visible."), ).toBeInTheDocument(); expect(screen.getByText("Voting weights unavailable.")).toBeInTheDocument(); - expect(screen.getByText("Signer 1")).toBeInTheDocument(); - expect(screen.getByText("Signer 2")).toBeInTheDocument(); - expect(screen.getAllByText("Weight unavailable")).toHaveLength(2); + expect(screen.getAllByText("Signer 1")).toHaveLength(2); + expect(screen.getAllByText("Signer 2")).toHaveLength(2); + }); + + test("shows urgent warning when owner weight meets quorum", async () => { + mockUseOwnerWeights.mockReturnValue({ + weights: { GOWNER111: 12, GOWNER222: 3 }, + totalWeight: 15, + loading: false, + error: null, + }); + + renderOwnersPage(); + + expect(await screen.findByText("Single-owner quorum", { exact: false })).toBeInTheDocument(); + expect(screen.queryByText("Above weight cap", { exact: false })).not.toBeInTheDocument(); + }); + + test("shows general warning when owner exceeds weight cap", async () => { + mockUseOwnerWeights.mockReturnValue({ + weights: { GOWNER111: 8, GOWNER222: 7 }, + totalWeight: 15, + loading: false, + error: null, + }); + + renderOwnersPage(); + + expect(await screen.findByText("Above weight cap", { exact: false })).toBeInTheDocument(); + expect(screen.queryByText("Single-owner quorum", { exact: false })).not.toBeInTheDocument(); + }); + + test("shows no warnings for balanced owners", async () => { + mockUseOwnerWeights.mockReturnValue({ + weights: { GOWNER111: 5, GOWNER222: 5 }, + totalWeight: 10, + loading: false, + error: null, + }); + + renderOwnersPage(); + + expect(screen.queryByText("Single-owner quorum", { exact: false })).not.toBeInTheDocument(); + expect(screen.queryByText("Above weight cap", { exact: false })).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/pages/OwnersPage.tsx b/frontend/src/pages/OwnersPage.tsx index 5df2936..2cc9549 100644 --- a/frontend/src/pages/OwnersPage.tsx +++ b/frontend/src/pages/OwnersPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { getSpendingLimit } from "../lib/contract"; +import { getSpendingLimit, getWeightCapPct, getRequiredQuorumWeight } from "../lib/contract"; import { createSpendingLimitProposal } from "../lib/submit"; import { displayToStroops, @@ -47,7 +47,6 @@ type OwnersPageProps = { owners: Owner[]; ownerAddresses: string[]; threshold: number; - totalOwners: number; walletAddress: string | null; onProposalSubmitted: () => void; }; @@ -56,7 +55,6 @@ export function OwnersPage({ owners, ownerAddresses, threshold, - totalOwners, walletAddress, onProposalSubmitted, }: OwnersPageProps) { @@ -87,6 +85,25 @@ export function OwnersPage({ const [slSubmitting, setSlSubmitting] = useState(false); const [slError, setSlError] = useState(null); + const [weightCapPct, setWeightCapPct] = useState(null); + const [quorumWeight, setQuorumWeight] = useState(null); + + useEffect(() => { + let cancelled = false; + async function load() { + const [capPct, qWeight] = await Promise.all([ + getWeightCapPct(), + getRequiredQuorumWeight(), + ]); + if (!cancelled) { + setWeightCapPct(capPct); + setQuorumWeight(qWeight); + } + } + load(); + return () => { cancelled = true; }; + }, []); + // Load spending limits for all owners and tokens useEffect(() => { let cancelled = false; @@ -114,11 +131,20 @@ export function OwnersPage({ }; }, [ownerAddresses]); + const hasOwnerWeights = totalWeight > 0; + const ownerWeightsLoading = weightsLoading; + const weightsUnavailable = !weightsLoading && totalWeight === 0; + const weightsStale = false; + const ownerCountLabel = `${ownerAddresses.length} ${ownerAddresses.length === 1 ? "owner" : "owners"}`; + const quorumPercent = totalWeight > 0 ? ((threshold / totalWeight) * 100).toFixed(1) : "0"; + const visibleOwners = owners .map((owner) => { const weight = weights[owner.address] ?? 1; const percentage = totalWeight > 0 ? (weight / totalWeight) * 100 : 0; - return { ...owner, weight, percentage }; + const isAboveCap = weightCapPct !== null && percentage > weightCapPct; + const meetsQuorumAlone = quorumWeight !== null && weight >= quorumWeight; + return { ...owner, weight, percentage, isAboveCap, meetsQuorumAlone }; }) .sort((left, right) => { if (!sortByWeightDesc) return 0; @@ -384,12 +410,35 @@ export function OwnersPage({

{owner.label}

- {/* subtle badge retained for quick glance when not loading */} - {!weightsLoading && ( - - Weight: {weights[owner.address] ?? 1} - - )} +
+ {!weightsLoading && ( + + Weight: {weights[owner.address] ?? 1} + + )} + {owner.meetsQuorumAlone && ( + + ⚠ Single-owner quorum + + This owner's weight alone meets or exceeds the quorum requirement, meaning they can approve and execute any proposal unilaterally. Consider rebalancing weights or raising the quorum threshold. + + + )} + {!owner.meetsQuorumAlone && owner.isAboveCap && ( + + ⚠ Above weight cap + + This owner's voting weight exceeds the configured single-owner weight cap. A single compromised key would give them outsized control over the multisig. + + + )} +

{shortenAddr(owner.address)}