diff --git a/frontend/src/components/idea-generator/IdeaGeneratorPanel.tsx b/frontend/src/components/idea-generator/IdeaGeneratorPanel.tsx index 875ed870..56a97954 100644 --- a/frontend/src/components/idea-generator/IdeaGeneratorPanel.tsx +++ b/frontend/src/components/idea-generator/IdeaGeneratorPanel.tsx @@ -12,6 +12,7 @@ import { type Domain, type IdeaFilters, } from '@/lib/idea-generator/ideaGenerator'; +import MultiSigWalletPanel from './MultiSigWalletPanel'; /** * IdeaGeneratorPanel — the interactive Hackathon Idea Generator. @@ -188,6 +189,13 @@ export default function IdeaGeneratorPanel() {

)} + + {/* Multi-sig Wallet Simulator — shown when the MultiSigWallet domain is selected */} + {filters.domain === 'MultiSigWallet' && ( +
+ +
+ )} ); } diff --git a/frontend/src/components/idea-generator/MultiSigWalletPanel.tsx b/frontend/src/components/idea-generator/MultiSigWalletPanel.tsx new file mode 100644 index 00000000..e0fb7f55 --- /dev/null +++ b/frontend/src/components/idea-generator/MultiSigWalletPanel.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { useState } from 'react'; +import { + createMultiSigState, + toggleSigner, + MULTISIG_POLICIES, + type MultiSigPolicy, + type MultiSigState, +} from '@/lib/idea-generator/ideaGenerator'; + +/** + * MultiSigWalletPanel — interactive simulator for multi-sig wallet concepts. + * + * Lets students configure an m-of-n signer quorum and simulate the approval + * flow that would occur in a Soroban multi-sig contract. Purely client-side; + * no network calls. + */ +export default function MultiSigWalletPanel() { + const [numSigners, setNumSigners] = useState(3); + const [threshold, setThreshold] = useState(2); + const [policy, setPolicy] = useState('m-of-n'); + const [wallet, setWallet] = useState(() => + createMultiSigState(3, 2, 'm-of-n'), + ); + + const handleConfigure = () => { + setWallet(createMultiSigState(numSigners, Math.min(threshold, numSigners), policy)); + }; + + const handleToggleSigner = (index: number) => { + setWallet((prev) => toggleSigner(prev, index)); + }; + + const signedCount = wallet.signers.filter((s) => s.signed).length; + + return ( +
+
+

+ Multi-sig Wallet Simulator +

+

+ Configure signers and policy, then simulate the approval flow. +

+
+ + {/* Configuration */} +
+
+ + setNumSigners(Number(e.target.value))} + className="bg-background border-border-theme text-foreground w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-red-500" + /> +
+
+ + setThreshold(Number(e.target.value))} + className="bg-background border-border-theme text-foreground w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-red-500" + /> +
+
+ + +
+
+ + + + {/* Signer list */} +
+ + Signers — click to sign/unsign + +
    + {wallet.signers.map((signer, idx) => ( +
  • + +
  • + ))} +
+
+ + {/* Status bar */} +
+

+ {signedCount} / {wallet.signers.length} signed — threshold:{' '} + {wallet.threshold} +

+

+ {wallet.approved ? '🔓 Transaction Approved' : '🔒 Awaiting Signatures'} +

+

+ Policy: {wallet.policy} +

+
+
+ ); +} diff --git a/frontend/src/lib/idea-generator/__tests__/ideaGenerator.test.ts b/frontend/src/lib/idea-generator/__tests__/ideaGenerator.test.ts index 7148fafe..efe190f7 100644 --- a/frontend/src/lib/idea-generator/__tests__/ideaGenerator.test.ts +++ b/frontend/src/lib/idea-generator/__tests__/ideaGenerator.test.ts @@ -5,6 +5,10 @@ import { validateFilters, buildGeneratorParams, generateLocalIdea, + createMultiSigState, + toggleSigner, + MULTISIG_POLICIES, + DOMAINS, type IdeaFilters, } from '../ideaGenerator'; @@ -38,3 +42,118 @@ describe('ideaGenerator', () => { expect(a.title.length).toBeGreaterThan(0); }); }); + +describe('Multi-sig Wallet — DOMAINS', () => { + it('includes MultiSigWallet in the DOMAINS list', () => { + expect(DOMAINS).toContain('MultiSigWallet'); + }); +}); + +describe('createMultiSigState', () => { + it('creates the correct number of signers', () => { + const state = createMultiSigState(4, 3, 'm-of-n'); + expect(state.signers).toHaveLength(4); + }); + + it('initialises all signers as unsigned', () => { + const state = createMultiSigState(3, 2, 'm-of-n'); + expect(state.signers.every((s) => !s.signed)).toBe(true); + }); + + it('sets threshold and policy correctly', () => { + const state = createMultiSigState(5, 3, 'time-locked'); + expect(state.threshold).toBe(3); + expect(state.policy).toBe('time-locked'); + }); + + it('defaults to 3 signers, threshold 2, m-of-n policy', () => { + const state = createMultiSigState(); + expect(state.signers).toHaveLength(3); + expect(state.threshold).toBe(2); + expect(state.policy).toBe('m-of-n'); + }); + + it('starts with approved = false', () => { + expect(createMultiSigState().approved).toBe(false); + }); +}); + +describe('toggleSigner', () => { + it('marks a signer as signed when previously unsigned', () => { + const state = createMultiSigState(3, 2, 'm-of-n'); + const next = toggleSigner(state, 0); + expect(next.signers[0].signed).toBe(true); + }); + + it('unmarks a signer when already signed', () => { + let state = createMultiSigState(3, 2, 'm-of-n'); + state = toggleSigner(state, 0); + state = toggleSigner(state, 0); + expect(state.signers[0].signed).toBe(false); + }); + + it('does not mutate the original state', () => { + const state = createMultiSigState(3, 2, 'm-of-n'); + toggleSigner(state, 0); + expect(state.signers[0].signed).toBe(false); + }); + + it('sets approved = true once threshold is met (m-of-n)', () => { + let state = createMultiSigState(3, 2, 'm-of-n'); + state = toggleSigner(state, 0); + expect(state.approved).toBe(false); + state = toggleSigner(state, 1); + expect(state.approved).toBe(true); + }); + + it('sets approved = false when signatures drop below threshold', () => { + let state = createMultiSigState(3, 2, 'm-of-n'); + state = toggleSigner(state, 0); + state = toggleSigner(state, 1); // approved + state = toggleSigner(state, 1); // unsign → below threshold + expect(state.approved).toBe(false); + }); + + it('requires all signers for a 3-of-3 config', () => { + let state = createMultiSigState(3, 3, 'm-of-n'); + state = toggleSigner(state, 0); + state = toggleSigner(state, 1); + expect(state.approved).toBe(false); + state = toggleSigner(state, 2); + expect(state.approved).toBe(true); + }); +}); + +describe('generateLocalIdea — MultiSigWallet domain', () => { + it('generates a valid idea for the MultiSigWallet domain', () => { + const filters: IdeaFilters = { + difficulty: 'Intermediate', + domain: 'MultiSigWallet', + techStack: ['Soroban', 'React'], + }; + const idea = generateLocalIdea(filters); + expect(idea.title.length).toBeGreaterThan(0); + expect(idea.description.length).toBeGreaterThan(0); + expect(idea.keyFeatures.length).toBeGreaterThan(0); + expect(idea.difficulty).toBe('Intermediate'); + expect(idea.recommendedTech).toEqual(['Soroban', 'React']); + }); + + it('is deterministic for the MultiSigWallet domain', () => { + const filters: IdeaFilters = { + difficulty: 'Beginner', + domain: 'MultiSigWallet', + techStack: ['Rust'], + }; + expect(generateLocalIdea(filters)).toEqual(generateLocalIdea(filters)); + }); +}); + +describe('MULTISIG_POLICIES', () => { + it('contains the expected policy types', () => { + expect(MULTISIG_POLICIES).toContain('m-of-n'); + expect(MULTISIG_POLICIES).toContain('weighted'); + expect(MULTISIG_POLICIES).toContain('time-locked'); + expect(MULTISIG_POLICIES).toContain('social-recovery'); + }); +}); diff --git a/frontend/src/lib/idea-generator/ideaGenerator.ts b/frontend/src/lib/idea-generator/ideaGenerator.ts index 94fd1f63..f2310f8e 100644 --- a/frontend/src/lib/idea-generator/ideaGenerator.ts +++ b/frontend/src/lib/idea-generator/ideaGenerator.ts @@ -30,9 +30,59 @@ export const DOMAINS = [ 'Infrastructure', 'Identity', 'Sustainability', + 'MultiSigWallet', ] as const; export type Domain = (typeof DOMAINS)[number]; +/** Multi-sig wallet signature policy types. */ +export const MULTISIG_POLICIES = ['m-of-n', 'weighted', 'time-locked', 'social-recovery'] as const; +export type MultiSigPolicy = (typeof MULTISIG_POLICIES)[number]; + +/** A single signer entry in the multi-sig wallet simulator. */ +export interface MultiSigSigner { + address: string; + weight: number; + signed: boolean; +} + +/** State for the multi-sig wallet simulator panel. */ +export interface MultiSigState { + signers: MultiSigSigner[]; + /** Minimum number of signers required (m in m-of-n). */ + threshold: number; + policy: MultiSigPolicy; + /** True once enough signers have signed to meet the threshold. */ + approved: boolean; +} + +/** Create an initial multi-sig state for the simulator. */ +export function createMultiSigState( + numSigners: number = 3, + threshold: number = 2, + policy: MultiSigPolicy = 'm-of-n', +): MultiSigState { + const signers: MultiSigSigner[] = Array.from({ length: numSigners }, (_, i) => ({ + address: `G${String(i + 1).padStart(4, '0')}...STELLAR`, + weight: 1, + signed: false, + })); + return { signers, threshold, policy, approved: false }; +} + +/** Toggle a signer's signed status and recompute approval. */ +export function toggleSigner(state: MultiSigState, signerIndex: number): MultiSigState { + const signers = state.signers.map((s, i) => + i === signerIndex ? { ...s, signed: !s.signed } : s, + ); + const signedWeight = signers.filter((s) => s.signed).reduce((acc, s) => acc + s.weight, 0); + const totalWeight = signers.reduce((acc, s) => acc + s.weight, 0); + const approved = + state.policy === 'weighted' + ? signedWeight / totalWeight >= state.threshold / state.signers.length + : signers.filter((s) => s.signed).length >= state.threshold; + return { ...state, signers, approved }; +} + /** Technology-stack options the student can filter by. */ export const TECH_STACK_OPTIONS = [ 'Soroban', @@ -135,6 +185,14 @@ export const DOMAIN_TEMPLATES: Record< ], features: ['Impact Verification', 'Credit Retirement', 'Transparent Reporting'], }, + MultiSigWallet: { + titles: ['Multi-Party Treasury', 'Threshold Signature Vault', 'Social Recovery Wallet'], + descriptions: [ + 'A Soroban-native m-of-n multi-sig wallet where transactions execute only after a configurable quorum of signers approve on-chain.', + 'A threshold-signature vault with time-locked and social-recovery policies for secure team asset management on Stellar.', + ], + features: ['m-of-n Signing', 'Threshold Configuration', 'On-Chain Approval', 'Social Recovery'], + }, }; /** Toggle a technology in/out of the selected stack (pure — returns a new array). */