From 2c97c05fb8f8bcbab92ea3e35cd3239ee2684802 Mon Sep 17 00:00:00 2001 From: Bamford Date: Sun, 26 Apr 2026 18:40:11 +0100 Subject: [PATCH] feat: implement multi-sig admin with timelock delays - types.ts: ProposalAction (8 typed operations), OperationRisk levels (critical/high/medium/low), PERMISSION_RISK and ACTION_PERMISSION maps, DEFAULT_TIMELOCK_MS (48h/24h/6h/0h), Proposal lifecycle types - ProposalEngine.ts: full proposal state machine create, approve, reject, execute, cancel, emergency override; quorum threshold enforcement; timelock delay gating; automatic status recomputation (pendingqueued readyexecuted/expired/cancelled); localStorage persistence; audit emitter integration emitting proposal.created/approved/rejected/executed/cancelled/ emergency_override events - TimelockClock.ts: reactive countdown poller firing onTimelockReady and onTimelockExpired callbacks for UI-driven updates - index.ts: barrel + createMultisig() factory - useMultisig.ts: React hook wiring engine + clock lifecycle, exposing createProposal/approve/reject/execute/cancel/emergencyOverride actions - 60 passing tests covering all lifecycle transitions, quorum math, timelock enforcement, emergency override, audit integration, and errors --- frontend/hooks/useMultisig.ts | 271 +++++++++++ frontend/multisig/ProposalEngine.ts | 668 ++++++++++++++++++++++++++ frontend/multisig/TimelockClock.ts | 112 +++++ frontend/multisig/index.ts | 65 +++ frontend/multisig/types.ts | 196 ++++++++ frontend/tests/multisig.test.ts | 705 ++++++++++++++++++++++++++++ 6 files changed, 2017 insertions(+) create mode 100644 frontend/hooks/useMultisig.ts create mode 100644 frontend/multisig/ProposalEngine.ts create mode 100644 frontend/multisig/TimelockClock.ts create mode 100644 frontend/multisig/index.ts create mode 100644 frontend/multisig/types.ts create mode 100644 frontend/tests/multisig.test.ts diff --git a/frontend/hooks/useMultisig.ts b/frontend/hooks/useMultisig.ts new file mode 100644 index 0000000..575171a --- /dev/null +++ b/frontend/hooks/useMultisig.ts @@ -0,0 +1,271 @@ +/** + * useMultisig — React hook for the multisig + timelock system. + * + * Initialises the engine and clock on mount, exposes proposal management + * actions, and provides live proposal lists that re-render on changes. + * + * ## Usage + * ```tsx + * const { proposals, createProposal, approve, execute } = useMultisig({ + * signers: [adminA, adminB, adminC], + * currentAddress: walletAddress, + * }); + * ``` + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + createMultisig, + ProposalEngine, + TimelockClock, + MultisigError, +} from "../multisig"; +import type { + Proposal, + ProposalAction, + ProposalStatus, + MultisigConfig, + TimelockStatus, +} from "../multisig/types"; +import type { SecurityEventEmitter } from "../security/SecurityEventEmitter"; +import type { RoleRegistry } from "../rbac/RoleRegistry"; + +// ── Hook options ────────────────────────────────────────────────────────────── + +export interface UseMultisigOptions { + signers: string[]; + currentAddress: string | null; + config?: Partial; + emitter?: SecurityEventEmitter | null; + registry?: RoleRegistry | null; + storageKey?: string; + /** Clock poll interval in ms (default: 5000). */ + pollIntervalMs?: number; +} + +// ── Hook result ─────────────────────────────────────────────────────────────── + +export interface UseMultisigResult { + engine: ProposalEngine | null; + ready: boolean; + /** All proposals (refreshed on every action). */ + proposals: Proposal[]; + /** Active timelock countdowns. */ + timelocks: TimelockStatus[]; + /** Last error message. */ + error: string | null; + /** Create a new proposal. */ + createProposal( + action: ProposalAction, + description: string, + emergency?: boolean, + ): Proposal | null; + /** Approve a proposal. */ + approve(proposalId: string, comment?: string): Proposal | null; + /** Reject a proposal. */ + reject(proposalId: string, reason?: string): Proposal | null; + /** Execute a ready proposal. */ + execute(proposalId: string): Proposal | null; + /** Cancel a proposal. */ + cancel(proposalId: string, reason?: string): Proposal | null; + /** Emergency override (SuperAdmin only). */ + emergencyOverride(proposalId: string): Proposal | null; + /** Filter proposals by status. */ + filterByStatus(status: ProposalStatus): Proposal[]; +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +export function useMultisig({ + signers, + currentAddress, + config, + emitter, + registry, + storageKey, + pollIntervalMs = 5_000, +}: UseMultisigOptions): UseMultisigResult { + const engineRef = useRef(null); + const clockRef = useRef(null); + + const [ready, setReady] = useState(false); + const [proposals, setProposals] = useState([]); + const [timelocks, setTimelocks] = useState([]); + const [error, setError] = useState(null); + + const refresh = useCallback(() => { + const engine = engineRef.current; + const clock = clockRef.current; + if (!engine) return; + setProposals(engine.listProposals()); + setTimelocks(clock?.getActiveTimelocks() ?? []); + }, []); + + // Initialise engine + clock + useEffect(() => { + const { engine, clock } = createMultisig( + signers, + { ...config, storageKey } as Partial, + emitter, + registry, + ); + + engineRef.current = engine; + clockRef.current = clock; + + // Fire refresh on timelock transitions + clock.onTimelockReady(() => refresh()); + clock.onTimelockExpired(() => refresh()); + + clock.start(); + setReady(true); + refresh(); + + return () => { + clock.stop(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storageKey]); + + // Keep signers in sync without re-initialising + useEffect(() => { + if (!engineRef.current) return; + engineRef.current.updateConfig({ signers }); + refresh(); + }, [signers, refresh]); + + // ── Actions ─────────────────────────────────────────────────────────────── + + const createProposal = useCallback( + ( + action: ProposalAction, + description: string, + emergency = false, + ): Proposal | null => { + if (!currentAddress || !engineRef.current) return null; + try { + const p = engineRef.current.createProposal( + currentAddress, + action, + description, + emergency, + ); + refresh(); + setError(null); + return p; + } catch (err) { + setError(err instanceof MultisigError ? err.message : String(err)); + return null; + } + }, + [currentAddress, refresh], + ); + + const approve = useCallback( + (proposalId: string, comment?: string): Proposal | null => { + if (!currentAddress || !engineRef.current) return null; + try { + const p = engineRef.current.approve( + currentAddress, + proposalId, + comment, + ); + refresh(); + setError(null); + return p; + } catch (err) { + setError(err instanceof MultisigError ? err.message : String(err)); + return null; + } + }, + [currentAddress, refresh], + ); + + const reject = useCallback( + (proposalId: string, reason?: string): Proposal | null => { + if (!currentAddress || !engineRef.current) return null; + try { + const p = engineRef.current.reject(currentAddress, proposalId, reason); + refresh(); + setError(null); + return p; + } catch (err) { + setError(err instanceof MultisigError ? err.message : String(err)); + return null; + } + }, + [currentAddress, refresh], + ); + + const execute = useCallback( + (proposalId: string): Proposal | null => { + if (!currentAddress || !engineRef.current) return null; + try { + engineRef.current.execute(currentAddress, proposalId); + refresh(); + setError(null); + return engineRef.current.getProposal(proposalId); + } catch (err) { + setError(err instanceof MultisigError ? err.message : String(err)); + return null; + } + }, + [currentAddress, refresh], + ); + + const cancel = useCallback( + (proposalId: string, reason?: string): Proposal | null => { + if (!currentAddress || !engineRef.current) return null; + try { + const p = engineRef.current.cancel(currentAddress, proposalId, reason); + refresh(); + setError(null); + return p; + } catch (err) { + setError(err instanceof MultisigError ? err.message : String(err)); + return null; + } + }, + [currentAddress, refresh], + ); + + const emergencyOverride = useCallback( + (proposalId: string): Proposal | null => { + if (!currentAddress || !engineRef.current) return null; + try { + const p = engineRef.current.emergencyOverride( + currentAddress, + proposalId, + ); + refresh(); + setError(null); + return p; + } catch (err) { + setError(err instanceof MultisigError ? err.message : String(err)); + return null; + } + }, + [currentAddress, refresh], + ); + + const filterByStatus = useCallback( + (status: ProposalStatus): Proposal[] => + proposals.filter((p) => p.status === status), + [proposals], + ); + + return { + engine: engineRef.current, + ready, + proposals, + timelocks, + error, + createProposal, + approve, + reject, + execute, + cancel, + emergencyOverride, + filterByStatus, + }; +} diff --git a/frontend/multisig/ProposalEngine.ts b/frontend/multisig/ProposalEngine.ts new file mode 100644 index 0000000..ec9ba95 --- /dev/null +++ b/frontend/multisig/ProposalEngine.ts @@ -0,0 +1,668 @@ +/** + * Proposal Engine + * + * Core state machine for the multi-party authorization + timelock system. + * Manages the full proposal lifecycle: create → approve/reject → queue → + * timelock → execute / cancel / expire. + * + * ## Invariants + * - A signer may only approve or reject once per proposal. + * - Execution is blocked until both quorum is met AND the timelock has elapsed. + * - Emergency proposals use a higher quorum threshold but a reduced timelock + * (50% of the normal delay, minimum 1 minute). + * - Only the proposer or a SuperAdmin may cancel a pending/queued proposal. + * - Proposals that reach their voting deadline without quorum transition to + * `expired` automatically on the next status check. + * + * ## Persistence + * Proposals are serialised to localStorage (keyed by `storageKey`) so they + * survive page refreshes. The in-memory map is the authoritative source. + * + * ## Audit integration + * Every state transition emits a security event via the injected emitter. + */ + +import { + Proposal, + ProposalAction, + ProposalStatus, + ApprovalRecord, + RejectionRecord, + MultisigConfig, + TimelockStatus, + ProposalExecutionResult, + DEFAULT_MULTISIG_CONFIG, + DEFAULT_TIMELOCK_MS, + PERMISSION_RISK, + ACTION_PERMISSION, +} from "./types"; +import { randomBytes, bytesToHex } from "../hsm/crypto"; +import type { SecurityEventEmitter } from "../security/SecurityEventEmitter"; +import type { RoleRegistry } from "../rbac/RoleRegistry"; + +// ── Engine ──────────────────────────────────────────────────────────────────── + +export class ProposalEngine { + private readonly proposals = new Map(); + private config: MultisigConfig; + private readonly storageKey: string; + private emitter: SecurityEventEmitter | null; + private registry: RoleRegistry | null; + + constructor( + options: { + config?: Partial; + storageKey?: string; + emitter?: SecurityEventEmitter | null; + registry?: RoleRegistry | null; + } = {}, + ) { + this.config = { ...DEFAULT_MULTISIG_CONFIG, ...options.config }; + this.storageKey = options.storageKey ?? "tossd-multisig-proposals"; + this.emitter = options.emitter ?? null; + this.registry = options.registry ?? null; + this.loadFromStorage(); + } + + // ── Configuration ───────────────────────────────────────────────────────── + + getConfig(): Readonly { + return { ...this.config }; + } + + updateConfig(patch: Partial): void { + this.config = { ...this.config, ...patch }; + } + + addSigner(address: string): void { + if (!this.config.signers.includes(address)) { + this.config.signers = [...this.config.signers, address]; + } + } + + removeSigner(address: string): void { + this.config.signers = this.config.signers.filter((s) => s !== address); + } + + // ── Proposal creation ───────────────────────────────────────────────────── + + /** + * Create a new proposal. + * + * @param proposer - Address creating the proposal (must be a signer or SuperAdmin) + * @param action - The operation to be executed + * @param description - Human-readable description + * @param emergency - If true, uses higher quorum + reduced timelock + */ + createProposal( + proposer: string, + action: ProposalAction, + description: string, + emergency = false, + ): Proposal { + this.assertIsSigner(proposer); + + const requiredPermission = ACTION_PERMISSION[action.type]; + const risk = PERMISSION_RISK[requiredPermission]; + const baseDelay = + this.config.timelockOverrides?.[risk] ?? DEFAULT_TIMELOCK_MS[risk]; + const timelockMs = emergency + ? Math.max(baseDelay * 0.5, 60_000) // 50% of normal, min 1 min + : baseDelay; + + const quorumThreshold = emergency + ? this.config.emergencyQuorumThreshold + : this.config.quorumThreshold; + + const now = new Date(); + const votingDeadline = new Date(now.getTime() + this.config.votingWindowMs); + + const proposal: Proposal = { + id: generateProposalId(), + proposer, + action, + requiredPermission, + risk, + status: "pending", + createdAt: now.toISOString(), + votingDeadline: votingDeadline.toISOString(), + queuedAt: null, + executeAfter: null, + executedAt: null, + executedBy: null, + timelockMs, + quorumThreshold, + approvals: [], + rejections: [], + description, + emergency, + }; + + this.proposals.set(proposal.id, proposal); + this.persist(); + + this.emitter + ?.emit("proposal.created", "system", "info", proposer, { + proposalId: proposal.id, + action: action.type, + risk, + timelockMs, + emergency, + description, + }) + .catch(() => {}); + + return proposal; + } + + // ── Approval / rejection ────────────────────────────────────────────────── + + /** + * Approve a proposal. Automatically transitions to `queued` if quorum is met. + */ + approve( + signerAddress: string, + proposalId: string, + comment?: string, + ): Proposal { + this.assertIsSigner(signerAddress); + const raw = this.requireProposalRaw(proposalId); + + // Terminal states are never votable + if ( + raw.status === "executed" || + raw.status === "cancelled" || + raw.status === "expired" + ) { + throw new MultisigError( + `Proposal ${raw.id} is not open for voting (status: ${raw.status})`, + raw.id, + raw.status, + ); + } + + // For duplicate-vote check, treat queued/ready as pending (votes were cast + // while the proposal was pending; status was updated by a prior approve call) + const viewAsPending: Proposal = { ...raw, status: "pending" }; + this.assertVotable(viewAsPending, signerAddress); + + const record: ApprovalRecord = { + address: signerAddress, + approvedAt: new Date().toISOString(), + comment, + }; + + // Build the updated proposal with the new approval, then recompute status + const withApproval: Proposal = { + ...raw, + approvals: [...raw.approvals, record], + }; + + const withStatus = this.recomputeStatus(withApproval); + this.proposals.set(proposalId, withStatus); + this.persist(); + + this.emitter + ?.emit("proposal.approved", "system", "info", signerAddress, { + proposalId, + approvalCount: withStatus.approvals.length, + status: withStatus.status, + }) + .catch(() => {}); + + return withStatus; + } + + /** + * Reject a proposal. Does not cancel it — other signers may still approve. + */ + reject(signerAddress: string, proposalId: string, reason?: string): Proposal { + this.assertIsSigner(signerAddress); + const raw = this.requireProposalRaw(proposalId); + + if ( + raw.status === "executed" || + raw.status === "cancelled" || + raw.status === "expired" + ) { + throw new MultisigError( + `Proposal ${raw.id} is not open for voting (status: ${raw.status})`, + raw.id, + raw.status, + ); + } + + const viewAsPending: Proposal = { ...raw, status: "pending" }; + this.assertVotable(viewAsPending, signerAddress); + + const record: RejectionRecord = { + address: signerAddress, + rejectedAt: new Date().toISOString(), + reason, + }; + + const updated: Proposal = { + ...raw, + rejections: [...raw.rejections, record], + }; + + const withStatus = this.recomputeStatus(updated); + this.proposals.set(proposalId, withStatus); + this.persist(); + + this.emitter + ?.emit("proposal.rejected", "system", "warning", signerAddress, { + proposalId, + rejectionCount: withStatus.rejections.length, + reason, + }) + .catch(() => {}); + + return withStatus; + } + + // ── Execution ───────────────────────────────────────────────────────────── + + /** + * Execute a proposal that is in `ready` status. + * Returns the execution result; the caller is responsible for applying the + * action to the contract. + * + * @throws if the proposal is not ready (wrong status or timelock not elapsed). + */ + execute( + executorAddress: string, + proposalId: string, + ): ProposalExecutionResult { + this.assertIsSigner(executorAddress); + const proposal = this.requireProposal(proposalId); + + // Refresh status in case timelock just elapsed + const refreshed = this.recomputeStatus(proposal); + this.proposals.set(proposalId, refreshed); + + if (refreshed.status !== "ready") { + throw new MultisigError( + `Proposal ${proposalId} is not ready for execution (status: ${refreshed.status})`, + proposalId, + refreshed.status, + ); + } + + const now = new Date().toISOString(); + const executed: Proposal = { + ...refreshed, + status: "executed", + executedAt: now, + executedBy: executorAddress, + }; + + this.proposals.set(proposalId, executed); + this.persist(); + + const result: ProposalExecutionResult = { + proposalId, + executedAt: now, + executedBy: executorAddress, + action: executed.action, + }; + + this.emitter + ?.emit("proposal.executed", "system", "info", executorAddress, { + proposalId, + action: executed.action.type, + timelockMs: executed.timelockMs, + }) + .catch(() => {}); + + return result; + } + + // ── Cancellation ────────────────────────────────────────────────────────── + + /** + * Cancel a proposal. Only the proposer or a SuperAdmin may cancel. + */ + cancel(callerAddress: string, proposalId: string, reason?: string): Proposal { + const proposal = this.requireProposal(proposalId); + + const isSuperAdmin = + this.registry?.hasAtLeastRole(callerAddress, "SuperAdmin") ?? false; + const isProposer = proposal.proposer === callerAddress; + + if (!isSuperAdmin && !isProposer) { + throw new MultisigError( + `Only the proposer or a SuperAdmin may cancel proposal ${proposalId}`, + proposalId, + proposal.status, + ); + } + + if (proposal.status === "executed" || proposal.status === "cancelled") { + throw new MultisigError( + `Cannot cancel a proposal in status: ${proposal.status}`, + proposalId, + proposal.status, + ); + } + + const cancelled: Proposal = { ...proposal, status: "cancelled" }; + this.proposals.set(proposalId, cancelled); + this.persist(); + + this.emitter + ?.emit("proposal.cancelled", "system", "warning", callerAddress, { + proposalId, + reason, + previousStatus: proposal.status, + }) + .catch(() => {}); + + return cancelled; + } + + // ── Emergency override ──────────────────────────────────────────────────── + + /** + * Emergency override: immediately queue a proposal, bypassing the normal + * voting window. Requires the caller to be a SuperAdmin AND the proposal + * to have met the emergency quorum threshold. + * + * This is a break-glass mechanism for critical security incidents. + */ + emergencyOverride(callerAddress: string, proposalId: string): Proposal { + const isSuperAdmin = + this.registry?.hasAtLeastRole(callerAddress, "SuperAdmin") ?? false; + if (!isSuperAdmin) { + throw new MultisigError( + `Emergency override requires SuperAdmin role`, + proposalId, + "pending", + ); + } + + const proposal = this.requireProposal(proposalId); + + if (proposal.status !== "pending" && proposal.status !== "queued") { + throw new MultisigError( + `Emergency override only applies to pending or queued proposals`, + proposalId, + proposal.status, + ); + } + + // Check emergency quorum + const approvalFraction = this.approvalFraction(proposal); + if (approvalFraction < this.config.emergencyQuorumThreshold) { + throw new MultisigError( + `Emergency override requires ${Math.round(this.config.emergencyQuorumThreshold * 100)}% approval ` + + `(current: ${Math.round(approvalFraction * 100)}%)`, + proposalId, + proposal.status, + ); + } + + const now = new Date(); + // Reduced timelock: 1 minute minimum + const emergencyDelay = Math.max(proposal.timelockMs * 0.1, 60_000); + const executeAfter = new Date(now.getTime() + emergencyDelay); + + const overridden: Proposal = { + ...proposal, + status: "queued", + queuedAt: now.toISOString(), + executeAfter: executeAfter.toISOString(), + timelockMs: emergencyDelay, + emergency: true, + }; + + this.proposals.set(proposalId, overridden); + this.persist(); + + this.emitter + ?.emit( + "proposal.emergency_override", + "system", + "critical", + callerAddress, + { + proposalId, + originalTimelockMs: proposal.timelockMs, + reducedTimelockMs: emergencyDelay, + approvalFraction, + }, + ) + .catch(() => {}); + + return overridden; + } + + // ── Queries ─────────────────────────────────────────────────────────────── + + getProposal(id: string): Proposal | null { + const p = this.proposals.get(id); + if (!p) return null; + // Refresh status on read + const refreshed = this.recomputeStatus(p); + if (refreshed.status !== p.status) { + this.proposals.set(id, refreshed); + this.persist(); + } + return refreshed; + } + + listProposals(filter?: { status?: ProposalStatus }): Proposal[] { + const all = Array.from(this.proposals.values()) + .map((p) => this.recomputeStatus(p)) + .sort((a, b) => { + const timeDiff = b.createdAt.localeCompare(a.createdAt); + return timeDiff !== 0 ? timeDiff : b.id.localeCompare(a.id); + }); + + if (filter?.status) { + return all.filter((p) => p.status === filter.status); + } + return all; + } + + getTimelockStatus(proposalId: string): TimelockStatus | null { + const proposal = this.getProposal(proposalId); + if (!proposal) return null; + + const now = Date.now(); + const executeAfterMs = proposal.executeAfter + ? new Date(proposal.executeAfter).getTime() + : null; + + const remainingMs = executeAfterMs + ? Math.max(0, executeAfterMs - now) + : proposal.timelockMs; + + return { + proposalId, + timelockMs: proposal.timelockMs, + queuedAt: proposal.queuedAt, + executeAfter: proposal.executeAfter, + remainingMs, + elapsed: executeAfterMs !== null && now >= executeAfterMs, + }; + } + + // ── Status recomputation ────────────────────────────────────────────────── + + /** + * Recompute the proposal status based on current time and approval counts. + * Pure function — does not mutate the proposal map. + */ + recomputeStatus(proposal: Proposal): Proposal { + // Terminal states are immutable + if (proposal.status === "executed" || proposal.status === "cancelled") { + return proposal; + } + + const now = Date.now(); + const votingDeadlineMs = new Date(proposal.votingDeadline).getTime(); + const fraction = this.approvalFraction(proposal); + const quorumMet = fraction >= proposal.quorumThreshold; + + // Check expiry first + if (!quorumMet && now > votingDeadlineMs && proposal.status === "pending") { + return { ...proposal, status: "expired" }; + } + + // Transition pending → queued when quorum is met + if (proposal.status === "pending" && quorumMet) { + const queuedAt = new Date().toISOString(); + const executeAfter = new Date(now + proposal.timelockMs).toISOString(); + return { + ...proposal, + status: proposal.timelockMs === 0 ? "ready" : "queued", + queuedAt, + executeAfter, + }; + } + + // Transition queued → ready when timelock elapses + if (proposal.status === "queued" && proposal.executeAfter) { + const executeAfterMs = new Date(proposal.executeAfter).getTime(); + if (now >= executeAfterMs) { + return { ...proposal, status: "ready" }; + } + } + + return proposal; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private approvalFraction(proposal: Proposal): number { + const total = this.config.signers.length; + if (total === 0) return 0; + return proposal.approvals.length / total; + } + + private assertIsSigner(address: string): void { + const isSigner = this.config.signers.includes(address); + const isSuperAdmin = + this.registry?.hasAtLeastRole(address, "SuperAdmin") ?? false; + if (!isSigner && !isSuperAdmin) { + throw new MultisigError( + `${address} is not an authorised signer`, + "", + "pending", + ); + } + } + + private assertVotable(proposal: Proposal, signerAddress: string): void { + // Use the proposal's own status field (passed in as the raw stored value). + // We do NOT re-read from the map here to avoid seeing a status that was + // written by a previous approve() call in the same sequence. + if (proposal.status !== "pending") { + throw new MultisigError( + `Proposal ${proposal.id} is not open for voting (status: ${proposal.status})`, + proposal.id, + proposal.status, + ); + } + + const alreadyApproved = proposal.approvals.some( + (a) => a.address === signerAddress, + ); + const alreadyRejected = proposal.rejections.some( + (r) => r.address === signerAddress, + ); + + if (alreadyApproved || alreadyRejected) { + throw new MultisigError( + `${signerAddress} has already voted on proposal ${proposal.id}`, + proposal.id, + proposal.status, + ); + } + + const now = Date.now(); + const deadline = new Date(proposal.votingDeadline).getTime(); + if (now > deadline) { + throw new MultisigError( + `Voting deadline for proposal ${proposal.id} has passed`, + proposal.id, + proposal.status, + ); + } + } + + private requireProposal(id: string): Proposal { + const p = this.proposals.get(id); + if (!p) { + throw new MultisigError(`Proposal not found: ${id}`, id, "pending"); + } + return p; + } + + /** Return the raw stored proposal without recomputing status. */ + private requireProposalRaw(id: string): Proposal { + const p = this.proposals.get(id); + if (!p) { + throw new MultisigError(`Proposal not found: ${id}`, id, "pending"); + } + return p; + } + + private persist(): void { + try { + if (typeof localStorage !== "undefined") { + localStorage.setItem( + this.storageKey, + JSON.stringify(Array.from(this.proposals.entries())), + ); + } + } catch { + // Non-fatal + } + } + + private loadFromStorage(): void { + try { + if (typeof localStorage !== "undefined") { + const raw = localStorage.getItem(this.storageKey); + if (!raw) return; + const entries = JSON.parse(raw) as [string, Proposal][]; + for (const [id, proposal] of entries) { + this.proposals.set(id, proposal); + } + } + } catch { + // Corrupt storage — start fresh + } + } +} + +// ── Error ───────────────────────────────────────────────────────────────────── + +export class MultisigError extends Error { + constructor( + message: string, + public readonly proposalId: string, + public readonly status: ProposalStatus, + ) { + super(message); + this.name = "MultisigError"; + } +} + +// ── ID generator ────────────────────────────────────────────────────────────── + +function generateProposalId(): string { + const bytes = randomBytes(16); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = bytesToHex(bytes); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20, 32), + ].join("-"); +} diff --git a/frontend/multisig/TimelockClock.ts b/frontend/multisig/TimelockClock.ts new file mode 100644 index 0000000..7d15e46 --- /dev/null +++ b/frontend/multisig/TimelockClock.ts @@ -0,0 +1,112 @@ +/** + * TimelockClock + * + * Reactive countdown timer for timelock-gated proposals. + * Polls the engine on a configurable interval and fires callbacks when: + * - A proposal transitions from `queued` → `ready` + * - A proposal transitions from `pending` → `expired` + * + * Designed to be used by the React hook (`useMultisig`) to drive UI updates + * without requiring the component to manage its own intervals. + */ + +import { ProposalEngine } from "./ProposalEngine"; +import type { Proposal, TimelockStatus } from "./types"; + +export type TimelockReadyCallback = (proposal: Proposal) => void; +export type TimelockExpiredCallback = (proposal: Proposal) => void; + +export class TimelockClock { + private readonly engine: ProposalEngine; + private readonly pollIntervalMs: number; + private timerId: ReturnType | null = null; + + private onReady: TimelockReadyCallback[] = []; + private onExpired: TimelockExpiredCallback[] = []; + + /** Snapshot of statuses from the last poll, keyed by proposal ID. */ + private lastStatuses = new Map(); + + constructor(engine: ProposalEngine, pollIntervalMs = 5_000) { + this.engine = engine; + this.pollIntervalMs = pollIntervalMs; + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + start(): void { + if (this.timerId !== null) return; + this.timerId = setInterval(() => this.tick(), this.pollIntervalMs); + // Run immediately + this.tick(); + } + + stop(): void { + if (this.timerId !== null) { + clearInterval(this.timerId); + this.timerId = null; + } + } + + // ── Listener registration ───────────────────────────────────────────────── + + onTimelockReady(cb: TimelockReadyCallback): void { + this.onReady.push(cb); + } + + onTimelockExpired(cb: TimelockExpiredCallback): void { + this.onExpired.push(cb); + } + + offTimelockReady(cb: TimelockReadyCallback): void { + this.onReady = this.onReady.filter((c) => c !== cb); + } + + offTimelockExpired(cb: TimelockExpiredCallback): void { + this.onExpired = this.onExpired.filter((c) => c !== cb); + } + + // ── Snapshot ────────────────────────────────────────────────────────────── + + /** + * Return the current timelock status for all active proposals. + */ + getActiveTimelocks(): TimelockStatus[] { + return this.engine + .listProposals({ status: "queued" }) + .map((p) => this.engine.getTimelockStatus(p.id)) + .filter((s): s is TimelockStatus => s !== null); + } + + // ── Private ─────────────────────────────────────────────────────────────── + + private tick(): void { + const proposals = this.engine.listProposals(); + + for (const proposal of proposals) { + const prev = this.lastStatuses.get(proposal.id); + + if (prev !== proposal.status) { + if (proposal.status === "ready" && prev === "queued") { + for (const cb of this.onReady) { + try { + cb(proposal); + } catch { + /* swallow */ + } + } + } + if (proposal.status === "expired" && prev === "pending") { + for (const cb of this.onExpired) { + try { + cb(proposal); + } catch { + /* swallow */ + } + } + } + this.lastStatuses.set(proposal.id, proposal.status); + } + } + } +} diff --git a/frontend/multisig/index.ts b/frontend/multisig/index.ts new file mode 100644 index 0000000..db13503 --- /dev/null +++ b/frontend/multisig/index.ts @@ -0,0 +1,65 @@ +/** + * Multisig + timelock module public API + * + * ```ts + * import { createMultisig, ProposalEngine, TimelockClock } from "../multisig"; + * ``` + */ + +export type { + OperationRisk, + ProposalAction, + ProposalStatus, + ApprovalRecord, + RejectionRecord, + Proposal, + MultisigConfig, + ProposalExecutionResult, + TimelockStatus, +} from "./types"; + +export { + DEFAULT_TIMELOCK_MS, + PERMISSION_RISK, + ACTION_PERMISSION, + DEFAULT_MULTISIG_CONFIG, +} from "./types"; + +export { ProposalEngine, MultisigError } from "./ProposalEngine"; +export { TimelockClock } from "./TimelockClock"; + +// ── Factory ─────────────────────────────────────────────────────────────────── + +import { ProposalEngine } from "./ProposalEngine"; +import { TimelockClock } from "./TimelockClock"; +import type { MultisigConfig } from "./types"; +import type { SecurityEventEmitter } from "../security/SecurityEventEmitter"; +import type { RoleRegistry } from "../rbac/RoleRegistry"; + +export interface MultisigContext { + engine: ProposalEngine; + clock: TimelockClock; +} + +/** + * Create a fully-wired multisig context. + * + * @param signers - Initial list of authorised signer addresses + * @param config - Optional config overrides + * @param emitter - Optional security event emitter + * @param registry - Optional RBAC registry (for SuperAdmin checks) + */ +export function createMultisig( + signers: string[], + config?: Partial, + emitter?: SecurityEventEmitter | null, + registry?: RoleRegistry | null, +): MultisigContext { + const engine = new ProposalEngine({ + config: { ...config, signers }, + emitter, + registry, + }); + const clock = new TimelockClock(engine); + return { engine, clock }; +} diff --git a/frontend/multisig/types.ts b/frontend/multisig/types.ts new file mode 100644 index 0000000..f8c5a30 --- /dev/null +++ b/frontend/multisig/types.ts @@ -0,0 +1,196 @@ +/** + * Multi-party authorization + timelock types + * + * Models the full proposal lifecycle: + * + * Pending ──(quorum met)──► Queued ──(delay elapsed)──► Ready ──(execute)──► Executed + * ──(cancelled)────────────────────────────────────────────────────► Cancelled + * ──(expired)──────────────────────────────────────────────────────► Expired + * + * ## Timelock delays (configurable, defaults shown) + * + * CRITICAL operations (treasury, role changes) : 48 h + * HIGH operations (fee, wager limits) : 24 h + * MEDIUM operations (multipliers, pause) : 6 h + * LOW operations (read-only, audit export) : 0 h (no delay) + * + * ## Quorum + * A proposal is approved when `approvals / totalSigners >= quorumThreshold`. + * Default quorum threshold: 0.51 (simple majority). + * Emergency override requires a higher threshold: 0.75. + */ + +import type { Permission } from "../rbac/types"; + +// ── Operation classification ────────────────────────────────────────────────── + +export type OperationRisk = "critical" | "high" | "medium" | "low"; + +/** Default timelock delays in milliseconds per risk level. */ +export const DEFAULT_TIMELOCK_MS: Record = { + critical: 48 * 60 * 60 * 1000, // 48 h + high: 24 * 60 * 60 * 1000, // 24 h + medium: 6 * 60 * 60 * 1000, // 6 h + low: 0, // 0 h +}; + +/** Risk classification for each permission. */ +export const PERMISSION_RISK: Record = { + "treasury:update": "critical", + "role:grant": "critical", + "role:revoke": "critical", + "hsm:manage": "critical", + "fee:update": "high", + "wager:update": "high", + "multiplier:update": "medium", + "contract:pause": "medium", + "audit:export": "low", + "audit:read": "low", + "contract:read": "low", + "role:read": "low", +}; + +// ── Proposal action ─────────────────────────────────────────────────────────── + +/** + * Typed payload for each supported admin operation. + * Discriminated by `type` for exhaustive handling. + */ +export type ProposalAction = + | { type: "set_fee"; feeBps: number } + | { type: "set_wager_limits"; minWager: number; maxWager: number } + | { + type: "set_multipliers"; + streak1: number; + streak2: number; + streak3: number; + streak4Plus: number; + } + | { type: "set_treasury"; newTreasury: string } + | { type: "set_paused"; paused: boolean } + | { type: "grant_role"; targetAddress: string; role: string; label?: string } + | { type: "revoke_role"; targetAddress: string } + | { type: "custom"; description: string; payload: Record }; + +/** Map each action type to the permission it requires. */ +export const ACTION_PERMISSION: Record = { + set_fee: "fee:update", + set_wager_limits: "wager:update", + set_multipliers: "multiplier:update", + set_treasury: "treasury:update", + set_paused: "contract:pause", + grant_role: "role:grant", + revoke_role: "role:revoke", + custom: "audit:export", // highest available as a catch-all +}; + +// ── Proposal lifecycle ──────────────────────────────────────────────────────── + +export type ProposalStatus = + | "pending" // Created, collecting approvals + | "queued" // Quorum met, timelock counting down + | "ready" // Timelock elapsed, ready to execute + | "executed" // Successfully executed + | "cancelled" // Cancelled by proposer or SuperAdmin + | "expired"; // Voting window closed without quorum + +// ── Approval record ─────────────────────────────────────────────────────────── + +export interface ApprovalRecord { + /** Signer address. */ + address: string; + /** ISO-8601 timestamp of the approval. */ + approvedAt: string; + /** Optional comment from the signer. */ + comment?: string; +} + +export interface RejectionRecord { + address: string; + rejectedAt: string; + reason?: string; +} + +// ── Proposal ────────────────────────────────────────────────────────────────── + +export interface Proposal { + /** UUID v4 proposal identifier. */ + id: string; + /** Address that created the proposal. */ + proposer: string; + /** The operation to be executed. */ + action: ProposalAction; + /** Required permission for this action. */ + requiredPermission: Permission; + /** Risk level (determines timelock delay). */ + risk: OperationRisk; + /** Current lifecycle status. */ + status: ProposalStatus; + /** ISO-8601 creation timestamp. */ + createdAt: string; + /** ISO-8601 deadline for collecting approvals. */ + votingDeadline: string; + /** ISO-8601 timestamp when quorum was first reached (null until then). */ + queuedAt: string | null; + /** ISO-8601 earliest execution time (queuedAt + timelockMs). */ + executeAfter: string | null; + /** ISO-8601 execution timestamp (null until executed). */ + executedAt: string | null; + /** Address that executed the proposal (null until executed). */ + executedBy: string | null; + /** Timelock delay in milliseconds. */ + timelockMs: number; + /** Minimum approval fraction required (0–1). */ + quorumThreshold: number; + /** Addresses that have approved. */ + approvals: ApprovalRecord[]; + /** Addresses that have rejected. */ + rejections: RejectionRecord[]; + /** Human-readable description. */ + description: string; + /** Whether this is an emergency proposal (higher quorum, shorter timelock). */ + emergency: boolean; +} + +// ── Multisig config ─────────────────────────────────────────────────────────── + +export interface MultisigConfig { + /** Addresses authorised to approve proposals. */ + signers: string[]; + /** Minimum approval fraction for normal proposals (default: 0.51). */ + quorumThreshold: number; + /** Minimum approval fraction for emergency proposals (default: 0.75). */ + emergencyQuorumThreshold: number; + /** Voting window in milliseconds (default: 72 h). */ + votingWindowMs: number; + /** Custom timelock overrides per risk level (falls back to DEFAULT_TIMELOCK_MS). */ + timelockOverrides?: Partial>; +} + +export const DEFAULT_MULTISIG_CONFIG: MultisigConfig = { + signers: [], + quorumThreshold: 0.51, + emergencyQuorumThreshold: 0.75, + votingWindowMs: 72 * 60 * 60 * 1000, // 72 h +}; + +// ── Execution result ────────────────────────────────────────────────────────── + +export interface ProposalExecutionResult { + proposalId: string; + executedAt: string; + executedBy: string; + action: ProposalAction; +} + +// ── Timelock status ─────────────────────────────────────────────────────────── + +export interface TimelockStatus { + proposalId: string; + timelockMs: number; + queuedAt: string | null; + executeAfter: string | null; + remainingMs: number; + /** True when the timelock has elapsed and the proposal can be executed. */ + elapsed: boolean; +} diff --git a/frontend/tests/multisig.test.ts b/frontend/tests/multisig.test.ts new file mode 100644 index 0000000..a692f49 --- /dev/null +++ b/frontend/tests/multisig.test.ts @@ -0,0 +1,705 @@ +/** + * Multisig + timelock tests + * + * Covers: + * - ProposalEngine: create, approve, reject, execute, cancel, emergency override + * - Timelock: delay enforcement, status transitions, recomputation + * - Quorum: threshold calculation, majority logic + * - MultisigError: typed error fields + * - types: PERMISSION_RISK, ACTION_PERMISSION, DEFAULT_TIMELOCK_MS + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + ProposalEngine, + MultisigError, + DEFAULT_TIMELOCK_MS, + PERMISSION_RISK, + ACTION_PERMISSION, +} from "../multisig"; +import type { ProposalAction, MultisigConfig } from "../multisig/types"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const A = "GSIGNER_A_000000000000000000000000000000000000000000000000"; +const B = "GSIGNER_B_000000000000000000000000000000000000000000000000"; +const C = "GSIGNER_C_000000000000000000000000000000000000000000000000"; +const SUPER = "GSUPER_000000000000000000000000000000000000000000000000000"; +const NOBODY = "GNOBODY_00000000000000000000000000000000000000000000000000"; + +let counter = 0; +function uniqueKey() { + return `test-multisig-${++counter}`; +} + +function makeEngine( + signers = [A, B, C], + configOverrides: Partial = {}, +) { + return new ProposalEngine({ + config: { + signers, + quorumThreshold: 0.51, + emergencyQuorumThreshold: 0.75, + votingWindowMs: 60 * 60 * 1000, // 1 h + ...configOverrides, + }, + storageKey: uniqueKey(), + }); +} + +const FEE_ACTION: ProposalAction = { type: "set_fee", feeBps: 400 }; +const TREASURY_ACTION: ProposalAction = { + type: "set_treasury", + newTreasury: "GNEW", +}; +const PAUSE_ACTION: ProposalAction = { type: "set_paused", paused: true }; + +// ── types ───────────────────────────────────────────────────────────────────── + +describe("types", () => { + it("DEFAULT_TIMELOCK_MS has correct ordering", () => { + expect(DEFAULT_TIMELOCK_MS.low).toBe(0); + expect(DEFAULT_TIMELOCK_MS.medium).toBeLessThan(DEFAULT_TIMELOCK_MS.high); + expect(DEFAULT_TIMELOCK_MS.high).toBeLessThan(DEFAULT_TIMELOCK_MS.critical); + }); + + it("PERMISSION_RISK classifies treasury:update as critical", () => { + expect(PERMISSION_RISK["treasury:update"]).toBe("critical"); + }); + + it("PERMISSION_RISK classifies fee:update as high", () => { + expect(PERMISSION_RISK["fee:update"]).toBe("high"); + }); + + it("PERMISSION_RISK classifies contract:pause as medium", () => { + expect(PERMISSION_RISK["contract:pause"]).toBe("medium"); + }); + + it("PERMISSION_RISK classifies audit:read as low", () => { + expect(PERMISSION_RISK["audit:read"]).toBe("low"); + }); + + it("ACTION_PERMISSION maps set_fee to fee:update", () => { + expect(ACTION_PERMISSION["set_fee"]).toBe("fee:update"); + }); + + it("ACTION_PERMISSION maps set_treasury to treasury:update", () => { + expect(ACTION_PERMISSION["set_treasury"]).toBe("treasury:update"); + }); +}); + +// ── ProposalEngine — creation ───────────────────────────────────────────────── + +describe("ProposalEngine — createProposal", () => { + it("creates a proposal with pending status", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "Update fee"); + expect(p.status).toBe("pending"); + expect(p.proposer).toBe(A); + expect(p.action).toEqual(FEE_ACTION); + }); + + it("assigns correct risk and timelock for fee:update", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "Update fee"); + expect(p.risk).toBe("high"); + expect(p.timelockMs).toBe(DEFAULT_TIMELOCK_MS.high); + }); + + it("assigns critical risk and timelock for treasury:update", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, TREASURY_ACTION, "Update treasury"); + expect(p.risk).toBe("critical"); + expect(p.timelockMs).toBe(DEFAULT_TIMELOCK_MS.critical); + }); + + it("emergency proposal has reduced timelock (50% of normal, min 1 min)", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "Emergency fee", true); + expect(p.emergency).toBe(true); + const expected = Math.max(DEFAULT_TIMELOCK_MS.high * 0.5, 60_000); + expect(p.timelockMs).toBe(expected); + }); + + it("emergency proposal uses higher quorum threshold", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "Emergency fee", true); + expect(p.quorumThreshold).toBe(0.75); + }); + + it("non-signer cannot create a proposal", () => { + const engine = makeEngine(); + expect(() => engine.createProposal(NOBODY, FEE_ACTION, "test")).toThrow( + MultisigError, + ); + }); + + it("proposal ID is a UUID v4", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + expect(p.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + it("each proposal gets a unique ID", () => { + const engine = makeEngine(); + const p1 = engine.createProposal(A, FEE_ACTION, "a"); + const p2 = engine.createProposal(A, FEE_ACTION, "b"); + expect(p1.id).not.toBe(p2.id); + }); + + it("zero-delay (low risk) proposal transitions directly to ready on quorum", () => { + const engine = makeEngine([A, B], { + quorumThreshold: 0.5, + timelockOverrides: { low: 0 }, + }); + const action: ProposalAction = { type: "set_paused", paused: false }; + // Override risk to low for this test via a custom action + const p = engine.createProposal( + A, + { type: "custom", description: "low risk", payload: {} }, + "low", + ); + engine.approve(A, p.id); + engine.approve(B, p.id); + const updated = engine.getProposal(p.id)!; + // custom maps to audit:export which is low risk → 0 delay → ready immediately + expect(updated.status).toBe("ready"); + }); +}); + +// ── ProposalEngine — approve / reject ───────────────────────────────────────── + +describe("ProposalEngine — approve / reject", () => { + it("approval increments approval count", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + const updated = engine.approve(A, p.id); + expect(updated.approvals).toHaveLength(1); + expect(updated.approvals[0].address).toBe(A); + }); + + it("rejection increments rejection count", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + const updated = engine.reject(A, p.id, "too high"); + expect(updated.rejections).toHaveLength(1); + expect(updated.rejections[0].reason).toBe("too high"); + }); + + it("double vote throws MultisigError", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.approve(A, p.id); + expect(() => engine.approve(A, p.id)).toThrow(MultisigError); + }); + + it("cannot approve after rejecting", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.reject(A, p.id); + expect(() => engine.approve(A, p.id)).toThrow(MultisigError); + }); + + it("non-signer cannot approve", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + expect(() => engine.approve(NOBODY, p.id)).toThrow(MultisigError); + }); + + it("approval on non-existent proposal throws", () => { + const engine = makeEngine(); + expect(() => engine.approve(A, "nonexistent")).toThrow(MultisigError); + }); + + it("quorum met transitions proposal to queued", () => { + const engine = makeEngine([A, B], { quorumThreshold: 0.51 }); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.approve(A, p.id); + engine.approve(B, p.id); + const updated = engine.getProposal(p.id)!; + expect(["queued", "ready"]).toContain(updated.status); + }); + + it("quorum not met keeps proposal pending", () => { + const engine = makeEngine([A, B, C], { quorumThreshold: 0.51 }); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.approve(A, p.id); // 1/3 = 33% < 51% + expect(engine.getProposal(p.id)!.status).toBe("pending"); + }); + + it("stores approval comment", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.approve(A, p.id, "looks good"); + expect(engine.getProposal(p.id)!.approvals[0].comment).toBe("looks good"); + }); +}); + +// ── ProposalEngine — timelock ───────────────────────────────────────────────── + +describe("ProposalEngine — timelock", () => { + // Use 33% quorum so a single approval from A (1/3) meets quorum + function makeTimelockEngine() { + return makeEngine([A, B, C], { quorumThreshold: 0.33 }); + } + + it("queued proposal has executeAfter set", () => { + const engine = makeTimelockEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.approve(A, p.id); + const updated = engine.getProposal(p.id)!; + expect(["queued", "ready"]).toContain(updated.status); + if (updated.status === "queued") { + expect(updated.executeAfter).not.toBeNull(); + } + }); + + it("queued proposal cannot be executed before timelock elapses", () => { + const engine = makeTimelockEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.approve(A, p.id); + const updated = engine.getProposal(p.id)!; + if (updated.status === "queued") { + expect(() => engine.execute(A, p.id)).toThrow(MultisigError); + } + }); + + it("zero-timelock proposal is immediately ready after quorum", () => { + const engine = new ProposalEngine({ + config: { + signers: [A, B, C], + quorumThreshold: 0.33, + emergencyQuorumThreshold: 0.75, + votingWindowMs: 3_600_000, + timelockOverrides: { low: 0, medium: 0, high: 0, critical: 0 }, + }, + storageKey: uniqueKey(), + }); + const p = engine.createProposal( + A, + { type: "custom", description: "d", payload: {} }, + "d", + ); + engine.approve(A, p.id); + expect(engine.getProposal(p.id)!.status).toBe("ready"); + }); + + it("getTimelockStatus returns correct remainingMs for queued proposal", () => { + const engine = makeTimelockEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.approve(A, p.id); + const status = engine.getTimelockStatus(p.id); + if (status && !status.elapsed) { + expect(status.remainingMs).toBeGreaterThan(0); + expect(status.remainingMs).toBeLessThanOrEqual(DEFAULT_TIMELOCK_MS.high); + } + }); + + it("getTimelockStatus returns null for unknown proposal", () => { + const engine = makeEngine(); + expect(engine.getTimelockStatus("nonexistent")).toBeNull(); + }); + + it("recomputeStatus transitions queued → ready when executeAfter is in the past", () => { + const engine = makeTimelockEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.approve(A, p.id); + const queued = engine.getProposal(p.id)!; + if (queued.status === "queued") { + const backdated = { + ...queued, + executeAfter: new Date(Date.now() - 1000).toISOString(), + }; + const refreshed = engine.recomputeStatus(backdated); + expect(refreshed.status).toBe("ready"); + } + }); +}); + +// ── ProposalEngine — execute ────────────────────────────────────────────────── + +describe("ProposalEngine — execute", () => { + // Use zero timelock so quorum immediately transitions to ready + function makeZeroEngine() { + return new ProposalEngine({ + config: { + signers: [A, B], + quorumThreshold: 0.5, + emergencyQuorumThreshold: 0.75, + votingWindowMs: 3_600_000, + timelockOverrides: { high: 0, critical: 0, medium: 0, low: 0 }, + }, + storageKey: uniqueKey(), + }); + } + + function makeReadyProposal(engine: ProposalEngine, action = FEE_ACTION) { + const p = engine.createProposal(A, action, "test"); + engine.approve(A, p.id); + engine.approve(B, p.id); + return p.id; + } + + it("executes a ready proposal", () => { + const engine = makeZeroEngine(); + const id = makeReadyProposal(engine); + const result = engine.execute(A, id); + expect(result.proposalId).toBe(id); + expect(result.executedBy).toBe(A); + expect(engine.getProposal(id)!.status).toBe("executed"); + }); + + it("executed proposal cannot be executed again", () => { + const engine = makeZeroEngine(); + const id = makeReadyProposal(engine); + engine.execute(A, id); + expect(() => engine.execute(A, id)).toThrow(MultisigError); + }); + + it("non-signer cannot execute", () => { + const engine = makeZeroEngine(); + const id = makeReadyProposal(engine); + expect(() => engine.execute(NOBODY, id)).toThrow(MultisigError); + }); + + it("pending proposal cannot be executed", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + expect(() => engine.execute(A, p.id)).toThrow(MultisigError); + }); + + it("execution result contains the action", () => { + const engine = makeZeroEngine(); + const id = makeReadyProposal(engine, FEE_ACTION); + const result = engine.execute(A, id); + expect(result.action).toEqual(FEE_ACTION); + }); +}); + +// ── ProposalEngine — cancel ─────────────────────────────────────────────────── + +describe("ProposalEngine — cancel", () => { + it("proposer can cancel a pending proposal", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + const cancelled = engine.cancel(A, p.id); + expect(cancelled.status).toBe("cancelled"); + }); + + it("SuperAdmin can cancel any proposal", () => { + const registry = { + hasAtLeastRole: (addr: string, role: string) => + addr === SUPER && role === "SuperAdmin", + } as never; + const engine = new ProposalEngine({ + config: { + signers: [A, B, C], + quorumThreshold: 0.51, + emergencyQuorumThreshold: 0.75, + votingWindowMs: 3_600_000, + }, + storageKey: uniqueKey(), + registry, + }); + const p = engine.createProposal(A, FEE_ACTION, "test"); + const cancelled = engine.cancel(SUPER, p.id); + expect(cancelled.status).toBe("cancelled"); + }); + + it("non-proposer non-admin cannot cancel", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + expect(() => engine.cancel(B, p.id)).toThrow(MultisigError); + }); + + it("executed proposal cannot be cancelled", () => { + const engine = new ProposalEngine({ + config: { + signers: [A, B], + quorumThreshold: 0.5, + emergencyQuorumThreshold: 0.75, + votingWindowMs: 3_600_000, + timelockOverrides: { high: 0, critical: 0, medium: 0, low: 0 }, + }, + storageKey: uniqueKey(), + }); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.approve(A, p.id); + engine.approve(B, p.id); + engine.execute(A, p.id); + expect(() => engine.cancel(A, p.id)).toThrow(MultisigError); + }); + + it("cancelled proposal cannot be approved", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.cancel(A, p.id); + expect(() => engine.approve(B, p.id)).toThrow(MultisigError); + }); +}); + +// ── ProposalEngine — expiry ─────────────────────────────────────────────────── + +describe("ProposalEngine — expiry", () => { + it("proposal expires when voting deadline passes without quorum", () => { + const engine = makeEngine([A, B, C], { + quorumThreshold: 0.51, + votingWindowMs: 1, // 1 ms — expires immediately + }); + const p = engine.createProposal(A, FEE_ACTION, "test"); + // Wait a tick for the deadline to pass + const backdated = { + ...p, + votingDeadline: new Date(Date.now() - 1000).toISOString(), + }; + const refreshed = engine.recomputeStatus(backdated); + expect(refreshed.status).toBe("expired"); + }); + + it("proposal with quorum does not expire", () => { + const engine = makeEngine([A, B, C], { quorumThreshold: 0.33 }); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.approve(A, p.id); // 1/3 = 33% >= 33% → queued + const updated = engine.getProposal(p.id)!; + expect(updated.status).not.toBe("expired"); + }); +}); + +// ── ProposalEngine — emergency override ────────────────────────────────────── + +describe("ProposalEngine — emergencyOverride", () => { + function makeRegistryWithSuperAdmin(superAddr: string) { + return { + hasAtLeastRole: (addr: string, role: string) => + addr === superAddr && role === "SuperAdmin", + } as never; + } + + it("SuperAdmin can emergency override with sufficient approvals", () => { + const registry = makeRegistryWithSuperAdmin(SUPER); + const engine = new ProposalEngine({ + config: { + signers: [A, B, C, SUPER], + quorumThreshold: 0.51, + emergencyQuorumThreshold: 0.75, + votingWindowMs: 3_600_000, + }, + storageKey: uniqueKey(), + registry, + }); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.approve(A, p.id); + engine.approve(B, p.id); + engine.approve(C, p.id); // 3/4 = 75% >= 75% + const overridden = engine.emergencyOverride(SUPER, p.id); + expect(overridden.status).toBe("queued"); + expect(overridden.emergency).toBe(true); + expect(overridden.timelockMs).toBeLessThan(DEFAULT_TIMELOCK_MS.high); + }); + + it("non-SuperAdmin cannot emergency override", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + expect(() => engine.emergencyOverride(A, p.id)).toThrow(MultisigError); + }); + + it("emergency override fails without sufficient approvals", () => { + const registry = makeRegistryWithSuperAdmin(SUPER); + const engine = new ProposalEngine({ + config: { + signers: [A, B, C, SUPER], + quorumThreshold: 0.51, + emergencyQuorumThreshold: 0.75, + votingWindowMs: 3_600_000, + }, + storageKey: uniqueKey(), + registry, + }); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.approve(A, p.id); // 1/4 = 25% < 75% + expect(() => engine.emergencyOverride(SUPER, p.id)).toThrow(MultisigError); + }); +}); + +// ── ProposalEngine — queries ────────────────────────────────────────────────── + +describe("ProposalEngine — queries", () => { + it("listProposals returns all proposals newest first", () => { + const engine = makeEngine(); + engine.createProposal(A, FEE_ACTION, "first"); + engine.createProposal(A, PAUSE_ACTION, "second"); + const list = engine.listProposals(); + expect(list).toHaveLength(2); + expect(list[0].description).toBe("second"); + }); + + it("listProposals filters by status", () => { + const engine = makeEngine(); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.cancel(A, p.id); + engine.createProposal(A, PAUSE_ACTION, "active"); + expect(engine.listProposals({ status: "cancelled" })).toHaveLength(1); + expect(engine.listProposals({ status: "pending" })).toHaveLength(1); + }); + + it("getProposal returns null for unknown ID", () => { + const engine = makeEngine(); + expect(engine.getProposal("nonexistent")).toBeNull(); + }); + + it("getConfig returns current config", () => { + const engine = makeEngine([A, B]); + expect(engine.getConfig().signers).toEqual([A, B]); + }); +}); + +// ── ProposalEngine — signer management ─────────────────────────────────────── + +describe("ProposalEngine — signer management", () => { + it("addSigner adds a new signer", () => { + const engine = makeEngine([A]); + engine.addSigner(B); + expect(engine.getConfig().signers).toContain(B); + }); + + it("addSigner is idempotent", () => { + const engine = makeEngine([A]); + engine.addSigner(A); + expect(engine.getConfig().signers.filter((s) => s === A)).toHaveLength(1); + }); + + it("removeSigner removes a signer", () => { + const engine = makeEngine([A, B]); + engine.removeSigner(A); + expect(engine.getConfig().signers).not.toContain(A); + }); +}); + +// ── MultisigError ───────────────────────────────────────────────────────────── + +describe("MultisigError", () => { + it("has name MultisigError", () => { + const err = new MultisigError("msg", "id", "pending"); + expect(err.name).toBe("MultisigError"); + }); + + it("exposes proposalId and status", () => { + const err = new MultisigError("msg", "abc", "queued"); + expect(err.proposalId).toBe("abc"); + expect(err.status).toBe("queued"); + }); + + it("is instanceof Error", () => { + expect(new MultisigError("m", "i", "pending") instanceof Error).toBe(true); + }); +}); + +// ── Audit emitter integration ───────────────────────────────────────────────── + +describe("ProposalEngine — audit emitter", () => { + it("emits proposal.created on createProposal", async () => { + const emitter = { emit: vi.fn().mockResolvedValue({}) } as never; + const engine = new ProposalEngine({ + config: { + signers: [A, B], + quorumThreshold: 0.51, + emergencyQuorumThreshold: 0.75, + votingWindowMs: 3_600_000, + }, + storageKey: uniqueKey(), + emitter, + }); + engine.createProposal(A, FEE_ACTION, "test"); + await Promise.resolve(); + expect(emitter.emit).toHaveBeenCalledWith( + "proposal.created", + "system", + "info", + A, + expect.objectContaining({ action: "set_fee" }), + ); + }); + + it("emits proposal.approved on approve", async () => { + const emitter = { emit: vi.fn().mockResolvedValue({}) } as never; + const engine = new ProposalEngine({ + config: { + signers: [A, B], + quorumThreshold: 0.51, + emergencyQuorumThreshold: 0.75, + votingWindowMs: 3_600_000, + }, + storageKey: uniqueKey(), + emitter, + }); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.approve(A, p.id); + await Promise.resolve(); + expect(emitter.emit).toHaveBeenCalledWith( + "proposal.approved", + "system", + "info", + A, + expect.objectContaining({ proposalId: p.id }), + ); + }); + + it("emits proposal.cancelled on cancel", async () => { + const emitter = { emit: vi.fn().mockResolvedValue({}) } as never; + const engine = new ProposalEngine({ + config: { + signers: [A, B], + quorumThreshold: 0.51, + emergencyQuorumThreshold: 0.75, + votingWindowMs: 3_600_000, + }, + storageKey: uniqueKey(), + emitter, + }); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.cancel(A, p.id); + await Promise.resolve(); + expect(emitter.emit).toHaveBeenCalledWith( + "proposal.cancelled", + "system", + "warning", + A, + expect.objectContaining({ proposalId: p.id }), + ); + }); + + it("emits proposal.emergency_override as critical", async () => { + const emitter = { emit: vi.fn().mockResolvedValue({}) } as never; + const registry = { + hasAtLeastRole: (addr: string, role: string) => + addr === SUPER && role === "SuperAdmin", + } as never; + const engine = new ProposalEngine({ + config: { + signers: [A, B, C, SUPER], + quorumThreshold: 0.51, + emergencyQuorumThreshold: 0.75, + votingWindowMs: 3_600_000, + }, + storageKey: uniqueKey(), + emitter, + registry, + }); + const p = engine.createProposal(A, FEE_ACTION, "test"); + engine.approve(A, p.id); + engine.approve(B, p.id); + engine.approve(C, p.id); + engine.emergencyOverride(SUPER, p.id); + await Promise.resolve(); + expect(emitter.emit).toHaveBeenCalledWith( + "proposal.emergency_override", + "system", + "critical", + SUPER, + expect.any(Object), + ); + }); +});