Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions frontend/src/components/idea-generator/IdeaGeneratorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type Domain,
type IdeaFilters,
} from '@/lib/idea-generator/ideaGenerator';
import MultiSigWalletPanel from './MultiSigWalletPanel';

/**
* IdeaGeneratorPanel — the interactive Hackathon Idea Generator.
Expand Down Expand Up @@ -188,6 +189,13 @@ export default function IdeaGeneratorPanel() {
</p>
)}
</div>

{/* Multi-sig Wallet Simulator — shown when the MultiSigWallet domain is selected */}
{filters.domain === 'MultiSigWallet' && (
<div className="lg:col-span-2">
<MultiSigWalletPanel />
</div>
)}
</div>
);
}
175 changes: 175 additions & 0 deletions frontend/src/components/idea-generator/MultiSigWalletPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<MultiSigPolicy>('m-of-n');
const [wallet, setWallet] = useState<MultiSigState>(() =>
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 (
<section
aria-label="Multi-sig Wallet Simulator"
className="bg-bg-secondary border-border-theme space-y-6 rounded-2xl border p-6"
>
<div>
<h2 className="text-foreground mb-1 text-lg font-black tracking-tight uppercase">
Multi-sig Wallet <span className="text-red-500">Simulator</span>
</h2>
<p className="text-text-secondary text-xs tracking-wide">
Configure signers and policy, then simulate the approval flow.
</p>
</div>

{/* Configuration */}
<div className="grid grid-cols-3 gap-4">
<div>
<label
htmlFor="ms-signers"
className="text-text-secondary mb-1 block text-[10px] font-bold tracking-widest uppercase"
>
Signers
</label>
<input
id="ms-signers"
type="number"
min={2}
max={10}
value={numSigners}
onChange={(e) => 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"
/>
</div>
<div>
<label
htmlFor="ms-threshold"
className="text-text-secondary mb-1 block text-[10px] font-bold tracking-widest uppercase"
>
Threshold (m)
</label>
<input
id="ms-threshold"
type="number"
min={1}
max={numSigners}
value={threshold}
onChange={(e) => 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"
/>
</div>
<div>
<label
htmlFor="ms-policy"
className="text-text-secondary mb-1 block text-[10px] font-bold tracking-widest uppercase"
>
Policy
</label>
<select
id="ms-policy"
value={policy}
onChange={(e) => setPolicy(e.target.value as MultiSigPolicy)}
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"
>
{MULTISIG_POLICIES.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
</div>

<button
onClick={handleConfigure}
className="w-full rounded-xl border border-red-600 py-2 text-xs font-black tracking-[0.2em] text-red-500 uppercase transition-colors hover:bg-red-600 hover:text-white"
>
Apply Configuration
</button>

{/* Signer list */}
<fieldset>
<legend className="text-text-secondary mb-3 text-[10px] font-bold tracking-widest uppercase">
Signers — click to sign/unsign
</legend>
<ul className="space-y-2" aria-label="Signers">
{wallet.signers.map((signer, idx) => (
<li key={signer.address}>
<button
type="button"
onClick={() => handleToggleSigner(idx)}
aria-pressed={signer.signed}
className={`flex w-full items-center justify-between rounded-lg border px-4 py-3 text-left text-sm font-mono transition-colors ${
signer.signed
? 'border-red-600 bg-red-600/10 text-red-400'
: 'border-border-theme text-text-secondary hover:border-red-600/50'
}`}
>
<span>{signer.address}</span>
<span
className={`text-[10px] font-black tracking-widest uppercase ${
signer.signed ? 'text-red-400' : 'text-zinc-600'
}`}
>
{signer.signed ? '✓ Signed' : 'Pending'}
</span>
</button>
</li>
))}
</ul>
</fieldset>

{/* Status bar */}
<div
role="status"
aria-live="polite"
className={`rounded-xl border p-4 text-center transition-colors ${
wallet.approved
? 'border-green-600 bg-green-600/10'
: 'border-border-theme bg-background'
}`}
>
<p className="text-xs font-bold tracking-widest uppercase">
{signedCount} / {wallet.signers.length} signed &mdash; threshold:{' '}
{wallet.threshold}
</p>
<p
className={`mt-1 text-sm font-black tracking-tight uppercase ${
wallet.approved ? 'text-green-400' : 'text-text-secondary'
}`}
>
{wallet.approved ? '🔓 Transaction Approved' : '🔒 Awaiting Signatures'}
</p>
<p className="text-text-secondary mt-1 text-[10px] tracking-widest uppercase">
Policy: {wallet.policy}
</p>
</div>
</section>
);
}
119 changes: 119 additions & 0 deletions frontend/src/lib/idea-generator/__tests__/ideaGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import {
validateFilters,
buildGeneratorParams,
generateLocalIdea,
createMultiSigState,
toggleSigner,
MULTISIG_POLICIES,
DOMAINS,
type IdeaFilters,
} from '../ideaGenerator';

Expand Down Expand Up @@ -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');
});
});
Loading