diff --git a/README.md b/README.md index 0d8f8512..85e50aaa 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ This repository contains example applications demonstrating various [Base] and [ | **Mini Zora** | MiniKit | `minikit/my-mini-zora/` | MiniKit template integrated with Zora protocol for NFT interactions | | **Simple Mini App** | MiniKit | `minikit/my-simple-mini-app/` | Basic MiniKit template with essential features and notifications | | **Three Card Monte** | MiniKit | `minikit/three-card-monte/` | Interactive card game mini app with onchain rewards and leaderboard | +| **Onchain Voting Demo** | MiniKit | `mini-apps/onchain-voting-demo/` | Governance voting system with proposal creation, voting, and result tracking | ## Getting Started diff --git a/mini-apps/onchain-voting-demo/.example.env b/mini-apps/onchain-voting-demo/.example.env new file mode 100644 index 00000000..be5a7003 --- /dev/null +++ b/mini-apps/onchain-voting-demo/.example.env @@ -0,0 +1 @@ +NEXT_PUBLIC_URL= \ No newline at end of file diff --git a/mini-apps/onchain-voting-demo/.gitignore b/mini-apps/onchain-voting-demo/.gitignore new file mode 100644 index 00000000..5ef6a520 --- /dev/null +++ b/mini-apps/onchain-voting-demo/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/mini-apps/onchain-voting-demo/.prettierrc b/mini-apps/onchain-voting-demo/.prettierrc new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/mini-apps/onchain-voting-demo/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/mini-apps/onchain-voting-demo/README.md b/mini-apps/onchain-voting-demo/README.md new file mode 100644 index 00000000..e01fdca0 --- /dev/null +++ b/mini-apps/onchain-voting-demo/README.md @@ -0,0 +1,176 @@ +# Onchain Voting Demo + +A Base Mini App demonstrating onchain governance and voting functionality. Users can participate in decentralized decision-making with proposals, voting, and result tracking. + +## Features + +- **📊 Proposal Creation**: Submit governance proposals with titles and descriptions +- **🗳️ Democratic Voting**: Cast votes on active proposals (Yes/No/Abstain) +- **📈 Real-time Results**: Live vote counting and result visualization +- **⏰ Proposal Lifecycle**: Active → Voting Period → Executed/Failed states +- **🔐 Wallet Integration**: Secure voting using connected wallets +- **📱 Mobile-First**: Optimized for Base Mini App experience + +## Voting Mechanism + +### Proposal States +- **Active**: Proposal submitted, open for voting +- **Passed**: Majority voted yes, ready for execution +- **Failed**: Majority voted no or voting period expired +- **Executed**: Proposal successfully implemented + +### Voting Rules +- One vote per wallet address +- Vote changes allowed during voting period +- Quorum requirement: 3 minimum votes +- Simple majority wins (50% + 1) + +## Technology Stack + +- **Framework**: Next.js 15 with App Router +- **Blockchain**: Base (Ethereum L2) +- **Wallet**: MiniKit SDK integration +- **Styling**: Tailwind CSS +- **State Management**: React hooks + local storage +- **TypeScript**: Full type safety + +## Getting Started + +### Prerequisites +- Node.js 18+ +- npm or yarn +- Base-compatible wallet + +### Installation + +```bash +# Clone the repository +git clone https://github.com/base/demos.git +cd demos/mini-apps/onchain-voting-demo + +# Install dependencies +npm install + +# Copy environment file +cp .example.env .env.local + +# Add your MiniKit app ID +echo "NEXT_PUBLIC_MINIKIT_APP_ID=your_app_id" >> .env.local +``` + +### Development + +```bash +# Start development server +npm run dev + +# Open http://localhost:3000 +``` + +### Building + +```bash +# Build for production +npm run build + +# Start production server +npm start +``` + +## Project Structure + +``` +onchain-voting-demo/ +├── app/ +│ ├── api/proposals/ # API routes for proposals +│ ├── components/ # React components +│ │ ├── ProposalCard.tsx +│ │ ├── VotingInterface.tsx +│ │ └── ResultsChart.tsx +│ ├── lib/ +│ │ ├── proposals.ts # Proposal management +│ │ └── voting.ts # Voting logic +│ └── page.tsx # Main voting interface +├── minikit.config.ts # MiniKit configuration +└── package.json +``` + +## Usage + +### Creating Proposals +1. Connect wallet using MiniKit +2. Click "Create Proposal" +3. Enter title and description +4. Submit to blockchain + +### Voting on Proposals +1. Browse active proposals +2. Click "Vote" on desired proposal +3. Select Yes/No/Abstain +4. Confirm transaction + +### Viewing Results +- Real-time vote counts +- Proposal status updates +- Historical voting data + +## API Endpoints + +- `GET /api/proposals` - Fetch all proposals +- `POST /api/proposals` - Create new proposal +- `GET /api/proposals/[id]` - Get specific proposal +- `POST /api/proposals/[id]/vote` - Cast vote on proposal + +## Smart Contract Integration + +While this demo uses local state for simplicity, it demonstrates the patterns for integrating with governance contracts: + +```typescript +// Example contract interaction +const vote = async (proposalId: string, option: VoteOption) => { + const hash = await walletClient.writeContract({ + address: GOVERNANCE_CONTRACT, + abi: GOVERNANCE_ABI, + functionName: 'castVote', + args: [proposalId, option] + }); + return hash; +}; +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make changes +4. Test thoroughly +5. Submit a pull request + +## Demo Flow + +1. **Landing**: Welcome screen with voting overview +2. **Proposal List**: Browse active proposals with status indicators +3. **Create Proposal**: Form to submit new governance proposals +4. **Voting Interface**: Interactive voting with confirmation +5. **Results Dashboard**: Real-time results and analytics + +## Security Considerations + +- Input validation for all user inputs +- Rate limiting for proposal creation +- Wallet signature verification +- Timestamp-based voting periods +- Duplicate vote prevention + +## Future Enhancements + +- [ ] Integration with actual governance contracts +- [ ] Quadratic voting mechanisms +- [ ] Proposal categories and tagging +- [ ] Delegate voting system +- [ ] Proposal execution automation +- [ ] Multi-chain voting support + +--- + +Built for the Base ecosystem • Powered by MiniKit diff --git a/mini-apps/onchain-voting-demo/app/.well-known/farcaster.json/route.ts b/mini-apps/onchain-voting-demo/app/.well-known/farcaster.json/route.ts new file mode 100644 index 00000000..6240aab7 --- /dev/null +++ b/mini-apps/onchain-voting-demo/app/.well-known/farcaster.json/route.ts @@ -0,0 +1,6 @@ +import { withValidManifest } from "@coinbase/onchainkit/minikit"; +import { minikitConfig } from "../../../minikit.config"; + +export async function GET() { + return Response.json(withValidManifest(minikitConfig)); +} diff --git a/mini-apps/onchain-voting-demo/app/api/auth/route.ts b/mini-apps/onchain-voting-demo/app/api/auth/route.ts new file mode 100644 index 00000000..249f7833 --- /dev/null +++ b/mini-apps/onchain-voting-demo/app/api/auth/route.ts @@ -0,0 +1,82 @@ +import { Errors, createClient } from "@farcaster/quick-auth"; +import { NextRequest, NextResponse } from "next/server"; + +const client = createClient(); + +// Helper function to determine the correct domain for JWT verification +function getUrlHost(request: NextRequest): string { + // First try to get the origin from the Origin header (most reliable for CORS requests) + const origin = request.headers.get("origin"); + if (origin) { + try { + const url = new URL(origin); + return url.host; + } catch (error) { + console.warn("Invalid origin header:", origin, error); + } + } + + // Fallback to Host header + const host = request.headers.get("host"); + if (host) { + return host; + } + + // Final fallback to environment variables (your original logic) + let urlValue: string; + if (process.env.VERCEL_ENV === "production") { + urlValue = process.env.NEXT_PUBLIC_URL!; + } else if (process.env.VERCEL_URL) { + urlValue = `https://${process.env.VERCEL_URL}`; + } else { + urlValue = "http://localhost:3000"; + } + + const url = new URL(urlValue); + return url.host; +} + +export async function GET(request: NextRequest) { + // Because we're fetching this endpoint via `sdk.quickAuth.fetch`, + // if we're in a mini app, the request will include the necessary `Authorization` header. + const authorization = request.headers.get("Authorization"); + + // Here we ensure that we have a valid token. + if (!authorization || !authorization.startsWith("Bearer ")) { + return NextResponse.json({ message: "Missing token" }, { status: 401 }); + } + + try { + // Now we verify the token. `domain` must match the domain of the request. + // In our case, we're using the `getUrlHost` function to get the domain of the request + // based on the Vercel environment. This will vary depending on your hosting provider. + const payload = await client.verifyJwt({ + token: authorization.split(" ")[1] as string, + domain: getUrlHost(request), + }); + + console.log("payload", payload); + + // If the token was valid, `payload.sub` will be the user's Farcaster ID. + const userFid = payload.sub; + + // Return user information for your waitlist application + return NextResponse.json({ + success: true, + user: { + fid: userFid, + issuedAt: payload.iat, + expiresAt: payload.exp, + }, + }); + + } catch (e) { + if (e instanceof Errors.InvalidTokenError) { + return NextResponse.json({ message: "Invalid token" }, { status: 401 }); + } + if (e instanceof Error) { + return NextResponse.json({ message: e.message }, { status: 500 }); + } + throw e; + } +} \ No newline at end of file diff --git a/mini-apps/onchain-voting-demo/app/components/CreateProposalForm.module.css b/mini-apps/onchain-voting-demo/app/components/CreateProposalForm.module.css new file mode 100644 index 00000000..136eb4da --- /dev/null +++ b/mini-apps/onchain-voting-demo/app/components/CreateProposalForm.module.css @@ -0,0 +1,173 @@ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal { + background: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.95)); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 1rem; + padding: 2rem; + max-width: 500px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); +} + +.header { + text-align: center; + margin-bottom: 2rem; +} + +.title { + font-size: 1.5rem; + font-weight: 700; + color: #ffffff; + margin-bottom: 0.5rem; +} + +.subtitle { + color: rgba(255, 255, 255, 0.7); + font-size: 0.875rem; +} + +.form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.label { + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + font-size: 0.875rem; +} + +.input, +.textarea { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 0.5rem; + padding: 0.75rem; + color: #ffffff; + font-size: 0.875rem; + transition: all 0.2s ease; +} + +.input:focus, +.textarea:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); +} + +.input::placeholder, +.textarea::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +.input.error, +.textarea.error { + border-color: #ef4444; +} + +.errorText { + color: #ef4444; + font-size: 0.75rem; + font-weight: 500; +} + +.charCount { + text-align: right; + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); +} + +.textarea { + resize: vertical; + min-height: 120px; + font-family: inherit; +} + +.actions { + display: flex; + gap: 1rem; + margin-top: 1rem; +} + +.cancelButton, +.submitButton { + flex: 1; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s ease; + border: none; +} + +.cancelButton { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.9); +} + +.cancelButton:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); +} + +.submitButton { + background: linear-gradient(135deg, #10b981, #059669); + color: white; +} + +.submitButton:hover:not(.disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); +} + +.submitButton.disabled { + background: rgba(107, 114, 128, 0.5); + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +@media (max-width: 640px) { + .modal { + padding: 1.5rem; + margin: 1rem; + } + + .title { + font-size: 1.25rem; + } + + .actions { + flex-direction: column; + } + + .cancelButton, + .submitButton { + width: 100%; + } +} diff --git a/mini-apps/onchain-voting-demo/app/components/CreateProposalForm.tsx b/mini-apps/onchain-voting-demo/app/components/CreateProposalForm.tsx new file mode 100644 index 00000000..2f62919e --- /dev/null +++ b/mini-apps/onchain-voting-demo/app/components/CreateProposalForm.tsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react'; +import styles from './CreateProposalForm.module.css'; + +interface CreateProposalFormProps { + onSubmit: (title: string, description: string) => void; + onCancel: () => void; +} + +export function CreateProposalForm({ onSubmit, onCancel }: CreateProposalFormProps) { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [errors, setErrors] = useState<{ title?: string; description?: string }>({}); + + const validateForm = () => { + const newErrors: { title?: string; description?: string } = {}; + + if (!title.trim()) { + newErrors.title = 'Title is required'; + } else if (title.length < 5) { + newErrors.title = 'Title must be at least 5 characters'; + } else if (title.length > 100) { + newErrors.title = 'Title must be less than 100 characters'; + } + + if (!description.trim()) { + newErrors.description = 'Description is required'; + } else if (description.length < 20) { + newErrors.description = 'Description must be at least 20 characters'; + } else if (description.length > 500) { + newErrors.description = 'Description must be less than 500 characters'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (validateForm()) { + onSubmit(title.trim(), description.trim()); + } + }; + + return ( +
+
+
+

📝 Create New Proposal

+

+ Submit a governance proposal for community voting +

+
+ +
+
+ + setTitle(e.target.value)} + className={`${styles.input} ${errors.title ? styles.error : ''}`} + placeholder="Enter a clear, concise title for your proposal" + maxLength={100} + /> + {errors.title && {errors.title}} +
+ {title.length}/100 characters +
+
+ +
+ +