diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8d1b20f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + contract-tests: + name: Smart Contract Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./contract + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: contract/package-lock.json + - run: npm ci + - run: npm audit --audit-level=critical + - run: npx hardhat compile + - run: npx hardhat test + + frontend-build: + name: Frontend Build + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npx tsc --noEmit + - run: npm run lint + - run: npm run build + env: + NEXT_PUBLIC_CONTRACT_ADDRESS: ${{ secrets.NEXT_PUBLIC_CONTRACT_ADDRESS }} + NEXT_PUBLIC_POLKADOT_HUB_RPC: ${{ secrets.NEXT_PUBLIC_POLKADOT_HUB_RPC }} + NEXT_PUBLIC_CHAIN_ID: ${{ secrets.NEXT_PUBLIC_CHAIN_ID }} + + deploy-frontend: + name: Deploy Frontend + runs-on: ubuntu-latest + needs: [contract-tests, frontend-build] + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + steps: + - uses: actions/checkout@v4 + - uses: amondnet/vercel-action@v25 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + working-directory: ./frontend + vercel-args: '--prod' \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..889e39b --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,740 @@ +# MicroBounty Architecture + +> Technical architecture and design decisions for the MicroBounty platform + +## Table of Contents + +1. [System Overview](#system-overview) +2. [Smart Contract Layer](#smart-contract-layer) +3. [Frontend Application](#frontend-application) +4. [Data Flow](#data-flow) +5. [Security Architecture](#security-architecture) +6. [Design Decisions](#design-decisions) + +--- + +## System Overview + +MicroBounty is a decentralized bounty marketplace consisting of: + +- **Smart Contract Layer**: Solidity contracts on Polkadot Hub EVM +- **Frontend Application**: Next.js web application +- **Blockchain**: Polkadot Hub Testnet (Chain ID: 420420417) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ User Layer │ +│ (MetaMask, SubWallet, Talisman Wallets) │ +└────────────────────────┬────────────────────────────────────┘ + │ + │ Web3 Provider (EIP-1193) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Frontend Application │ +│ (Next.js 15 + TypeScript) │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Contexts │ │ Components │ │ Lib/Utils │ │ +│ │ │ │ │ │ │ │ +│ │ • Wallet │ │ • BountyCard │ │ • ethers.js │ │ +│ │ • Bounty │ │ • CreateForm │ │ • formatters │ │ +│ │ │ │ • Analytics │ │ • constants │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└────────────────────────┬────────────────────────────────────┘ + │ + │ JSON-RPC / ethers.js v6 + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Smart Contract Layer │ +│ (Solidity 0.8.28 on EVM) │ +│ │ +│ MicroBounty.sol │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ State: │ │ +│ │ • bounties mapping │ │ +│ │ • userBounties, userSubmissions │ │ +│ │ • platformStats, userStats │ │ +│ │ • supportedTokens │ │ +│ │ │ │ +│ │ Functions: │ │ +│ │ • createBounty() • submitWork() │ │ +│ │ • approveBounty() • cancelBounty() │ │ +│ │ • getBounty() • getPlatformStats() │ │ +│ └──────────────────────────────────────────────────────┘ │ +└────────────────────────┬────────────────────────────────────┘ + │ + │ EVM Execution + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Polkadot Hub Chain │ +│ (EVM-Compatible Layer) │ +│ │ +│ • Native DOT (10 decimals) │ +│ • ERC20 Tokens (USDC, USDT - 6 decimals) │ +│ • Block time: ~6 seconds │ +│ • Finality: ~12-18 seconds │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Smart Contract Layer + +### Contract: `MicroBounty.sol` + +**File**: `contract/contracts/MicroBounty.sol` +**Address**: `0x73fC6177262D64ca26A76ECbab8c1aeD97e84AC5` (Testnet) +**Language**: Solidity 0.8.28 +**Standards**: ERC20-compatible, OpenZeppelin libraries + +### Core Components + +#### 1. Data Structures + +```solidity +struct Bounty { + uint256 id; + address creator; + string title; + string description; + uint256 reward; + address paymentToken; // address(0) = DOT, else ERC20 + BountyStatus status; + address hunter; + string proofUrl; + string submissionNotes; + uint256 createdAt; + uint256 submittedAt; + uint256 completedAt; + uint8 category; +} + +enum BountyStatus { OPEN, IN_PROGRESS, COMPLETED, CANCELLED } +enum Category { DEVELOPMENT, DESIGN, CONTENT, BUG_FIX, OTHER } +``` + +#### 2. State Variables + +```solidity +// Core mappings +mapping(uint256 => Bounty) public bounties; +mapping(address => uint256[]) public userBounties; +mapping(address => uint256[]) public userSubmissions; +mapping(address => UserStats) public userStats; + +// Token whitelist +mapping(address => bool) public supportedTokens; +address[] public tokenList; + +// Analytics +PlatformStats public platformStats; +``` + +#### 3. Key Functions + +| Function | Access | Gas Cost | Description | +|----------|--------|----------|-------------| +| `createBounty()` | Public | ~150k | Create bounty, lock funds in escrow | +| `submitWork()` | Public | ~80k | Submit proof, claim bounty as hunter | +| `approveBounty()` | Creator only | ~120k | Release payment to hunter | +| `cancelBounty()` | Creator only | ~100k | Refund creator (OPEN status only) | +| `getBounty()` | View | Free | Fetch bounty details | +| `getPlatformStats()` | View | Free | Aggregate platform metrics | + +### Security Patterns + +#### 1. Reentrancy Protection + +```solidity +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; + +function approveBounty(uint256 _bountyId) + external + nonReentrant // ← Prevents reentrancy +{ + // ... payment logic +} +``` + +#### 2. Checks-Effects-Interactions + +```solidity +// 1. CHECKS +require(msg.sender == bounty.creator, "Only creator"); +require(bounty.status == BountyStatus.IN_PROGRESS, "Invalid status"); + +// 2. EFFECTS (state changes first) +bounty.status = BountyStatus.COMPLETED; +platformStats.completedBounties++; + +// 3. INTERACTIONS (external calls last) +(bool success, ) = bounty.hunter.call{value: bounty.reward}(""); +require(success, "Transfer failed"); +``` + +#### 3. Access Control + +```solidity +modifier onlyBountyCreator(uint256 _bountyId) { + require(bounties[_bountyId].creator == msg.sender, "Only creator"); + _; +} +``` + +#### 4. Input Validation + +- Title: 1-100 characters +- Description: 1-500 characters +- Submission notes: Max 200 characters +- Reward: >= MIN_REWARD (0.01 DOT or 1 USDC) +- Category: 0-4 (enum bounds) +- Payment token: Must be in whitelist + +### Multi-Currency Handling + +#### Native DOT (10 Decimals) + +```solidity +uint256 public constant MIN_REWARD_DOT = 0.01 ether; // 0.01 DOT = 10^8 units + +// Payment +if (_paymentToken == address(0)) { + require(msg.value == _reward, "Incorrect DOT amount"); + platformStats.totalValueLockedDOT += _reward; +} +``` + +#### ERC20 Stablecoins (6 Decimals) + +```solidity +uint256 public constant MIN_REWARD_STABLE = 1e6; // 1 USDC/USDT + +// Payment +IERC20(_paymentToken).safeTransferFrom(msg.sender, address(this), _reward); +platformStats.totalValueLockedStable += _reward; +``` + +### Events + +```solidity +event BountyCreated(uint256 indexed bountyId, address indexed creator, uint256 reward, address paymentToken, uint8 category); +event WorkSubmitted(uint256 indexed bountyId, address indexed hunter, string proofUrl, uint256 timestamp); +event BountyCompleted(uint256 indexed bountyId, address indexed hunter, uint256 reward, address paymentToken, uint256 timestamp); +event BountyCancelled(uint256 indexed bountyId, address indexed creator, uint256 refund, address paymentToken, uint256 timestamp); +``` + +--- + +## Frontend Application + +### Tech Stack + +- **Framework**: Next.js 15 (App Router) +- **Language**: TypeScript 5.3 +- **Styling**: Tailwind CSS 3.4 +- **Web3**: ethers.js v6, Reown AppKit +- **State**: React Context API +- **Build**: Vercel + +### Project Structure + +``` +frontend/ +├── app/ # Next.js App Router +│ ├── layout.tsx # Root layout with providers +│ ├── page.tsx # Homepage (bounty board) +│ ├── create/page.tsx # Create bounty form +│ ├── bounty/[id]/page.tsx # Bounty detail page +│ ├── history/page.tsx # Transaction history +│ └── analytics/page.tsx # Analytics dashboard +│ +├── components/ # React components +│ ├── BountyCard.tsx # Bounty card UI +│ ├── CreateBountyForm.tsx +│ ├── SubmitWorkModal.tsx +│ ├── ApproveButton.tsx +│ ├── AnalyticsDashboard.tsx +│ └── ui/ # Reusable UI components +│ +├── context/ # React Context +│ ├── WalletContext.tsx # Wallet connection & balance +│ └── BountyContext.tsx # Bounty data & filtering +│ +├── lib/ # Utilities +│ ├── constants.ts # Contract ABI, addresses +│ ├── formatters.ts # Format DOT, dates, addresses +│ └── contracts.ts # Contract interaction helpers +│ +└── public/ # Static assets + └── MicroBountyABI.json # Contract ABI +``` + +### Context Architecture + +#### WalletContext + +```typescript +interface WalletContextType { + address: string | null; + isConnected: boolean; + chainId: number | null; + balances: { + dot: string; + usdc: string; + usdt: string; + }; + walletName: string; + connectWallet: () => Promise; + disconnectWallet: () => void; +} +``` + +**Responsibilities:** +- Wallet connection via Reown AppKit +- Balance fetching (native + ERC20) +- Network validation +- Wallet provider management + +#### BountyContext + +```typescript +interface BountyContextType { + bounties: Bounty[]; + loading: boolean; + filters: { + status: BountyStatus | 'all'; + currency: string | 'all'; + category: Category | 'all'; + }; + platformStats: PlatformStats; + userStats: UserStats; + + fetchBounties: () => Promise; + createBounty: (data: CreateBountyData) => Promise; + submitWork: (id: number, proof: string) => Promise; + approveBounty: (id: number) => Promise; + cancelBounty: (id: number) => Promise; +} +``` + +**Responsibilities:** +- Fetch bounties from contract +- Cache bounty data +- Filter/search logic +- Transaction submission +- Event listening for updates + +### DOT Decimal Handling (Critical!) + +Polkadot's native token uses **10 decimals**, not 18 like Ethereum. + +#### Frontend + +```typescript +// lib/formatters.ts +export const formatDOT = (amount: bigint): string => { + return ethers.formatUnits(amount, 10); // 10 decimals, not 18! +}; + +export const parseDOT = (amount: string): bigint => { + return ethers.parseUnits(amount, 10); +}; + +// Usage + { + const parsed = parseDOT(e.target.value); // Correct parsing + setReward(parsed); + }} +/> +``` + +#### Smart Contract + +```solidity +// Correct: 0.01 DOT = 10^8 units (10 decimals) +uint256 public constant MIN_REWARD_DOT = 0.01 ether; // 10^8 + +// Incorrect (would be 18 decimals): +// uint256 public constant MIN_REWARD_DOT = 0.01 * 10**18; // WRONG! +``` + +### Component Communication + +``` +User Action (e.g., "Create Bounty") + ↓ +Component (CreateBountyForm.tsx) + ↓ +Context (BountyContext.createBounty()) + ↓ +ethers.js Contract Instance + ↓ +JSON-RPC to Polkadot Hub + ↓ +Smart Contract Execution + ↓ +Event Emitted (BountyCreated) + ↓ +Frontend Event Listener + ↓ +Context Updates State + ↓ +UI Re-renders +``` + +--- + +## Data Flow + +### Create Bounty Flow + +``` +1. User fills form in CreateBountyForm.tsx + ↓ +2. Form validation (client-side) + ↓ +3. BountyContext.createBounty() called + ↓ +4. Check if ERC20 → Approve token spending first + ↓ +5. Call contract.createBounty() + ↓ +6. Wait for transaction confirmation + ↓ +7. Listen for BountyCreated event + ↓ +8. Update local state with new bounty + ↓ +9. Redirect to bounty detail page +``` + +### Approve Bounty Flow + +``` +1. Creator clicks "Approve & Pay" on BountyDetail page + ↓ +2. Confirmation modal shown + ↓ +3. BountyContext.approveBounty(id) called + ↓ +4. Contract performs checks: + - msg.sender == creator + - status == IN_PROGRESS + ↓ +5. State updated (status → COMPLETED) + ↓ +6. Payment transferred: + - DOT: native transfer + - ERC20: safeTransfer + ↓ +7. BountyCompleted event emitted + ↓ +8. Frontend updates UI, shows success +``` + +### Real-Time Updates + +```typescript +// Listen for events +contract.on("BountyCreated", (bountyId, creator, reward) => { + fetchBounties(); // Refresh bounty list +}); + +contract.on("BountyCompleted", (bountyId, hunter, reward) => { + updateBountyStatus(bountyId, "COMPLETED"); + showSuccessNotification(); +}); +``` + +--- + +## Security Architecture + +### Smart Contract Security + +#### 1. ReentrancyGuard + +- Applied to: `approveBounty()`, `cancelBounty()` +- Prevents recursive calls during payment transfers +- Uses OpenZeppelin's battle-tested implementation + +#### 2. SafeERC20 + +- Wraps all ERC20 interactions +- Handles tokens that don't return booleans +- Prevents silent failures + +#### 3. Access Control + +- Only creator can approve/cancel their bounties +- Cannot submit work to your own bounty +- Status-based function restrictions + +#### 4. Input Validation + +- Length limits on all strings +- Minimum reward thresholds +- Token whitelist enforcement +- Category bounds checking + +### Frontend Security + +#### 1. Wallet Security + +- No private keys stored +- Users sign transactions in their wallet +- Network mismatch warnings +- Transaction simulation before send + +#### 2. Input Sanitization + +```typescript +// Prevent XSS in user-submitted URLs +const sanitizeUrl = (url: string): string => { + try { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error('Invalid protocol'); + } + return parsed.href; + } catch { + throw new Error('Invalid URL'); + } +}; +``` + +#### 3. Transaction Validation + +```typescript +// Verify transaction before sending +const validateTransaction = async (tx: TransactionRequest) => { + try { + await provider.estimateGas(tx); // Will revert if transaction would fail + } catch (error) { + throw new Error('Transaction would fail'); + } +}; +``` + +--- + +## Design Decisions + +### Why Solidity Instead of Dedot/PAPI? + +**Decision**: Use Solidity on Polkadot Hub's EVM instead of native Substrate pallets. + +**Rationale**: +1. **Broader Accessibility**: More developers know Solidity than Substrate/Ink! +2. **Faster Development**: Hardhat tooling is mature and well-documented +3. **Feature Parity**: All Idea #141 requirements achievable with Solidity +4. **EVM Compatibility**: Demonstrates Polkadot Hub's Ethereum compatibility +5. **Security**: OpenZeppelin libraries are battle-tested + +**Trade-offs**: +- ✅ Pro: Easier to audit, more developers can contribute +- ⚠️ Con: Doesn't showcase native Polkadot features (XCM, etc.) +- 💡 Future: Can integrate XCM in v2.0 via precompiles + +### Why Multi-Currency? + +**Decision**: Support native DOT + stablecoins (USDC, USDT). + +**Rationale**: +1. **Flexibility**: Projects can pay in what they hold +2. **Stability**: Contributors often prefer stablecoin payments +3. **Real-World Need**: Polkadot ecosystem uses both DOT and stables +4. **Demonstrates ERC20 Handling**: Shows complete EVM compatibility + +**Implementation**: +- `address(0)` = native DOT +- Whitelisted ERC20 addresses = stablecoins +- Separate stats tracking per currency type + +### Why On-Chain Analytics? + +**Decision**: Store statistics in smart contract state instead of off-chain database. + +**Rationale**: +1. **Transparency**: Anyone can verify platform stats +2. **Simplicity**: No backend infrastructure needed +3. **Trust**: Metrics are tamper-proof +4. **Real-Time**: Always up-to-date with blockchain state + +**Trade-offs**: +- ✅ Pro: Decentralized, verifiable, simple +- ⚠️ Con: Gas cost for updating stats (mitigated by combining updates) +- ⚠️ Con: Query performance (fine for <10k bounties) + +### Why Context API Over Redux? + +**Decision**: Use React Context for state management instead of Redux/Zustand. + +**Rationale**: +1. **Simplicity**: Smaller bundle size, less boilerplate +2. **Sufficient Complexity**: App doesn't need advanced state management +3. **Performance**: Optimized with useMemo/useCallback +4. **Native**: No external dependencies + +**When to Switch**: If app grows to >20 components sharing state, consider Zustand. + +### Why ethers.js v6 Over viem? + +**Decision**: Use ethers.js v6 instead of viem. + +**Rationale**: +1. **Familiarity**: More developers know ethers.js +2. **Documentation**: Extensive resources and examples +3. **Compatibility**: Works seamlessly with Hardhat +4. **Stability**: Mature library with fewer breaking changes + +**Trade-off**: viem is lighter and more modular, but ethers.js is proven. + +--- + +## Performance Optimizations + +### Smart Contract + +1. **Batch Operations**: Update multiple stats in single transaction +2. **View Functions**: Heavy queries are `view` (no gas cost) +3. **Events Over Storage**: Use events for historical data (cheaper) + +### Frontend + +1. **Memoization**: Heavy computations wrapped in `useMemo` +2. **Lazy Loading**: Components load on-demand +3. **Pagination**: Only fetch visible bounties +4. **Event Caching**: Cache contract events, poll every 10s +5. **Optimistic Updates**: Update UI before transaction confirms + +--- + +## Testing Strategy + +### Smart Contract Tests + +**Framework**: Hardhat + Chai +**Coverage**: 85%+ +**Test Count**: 41 passing tests + +**Test Categories**: +- Unit tests: Individual function behavior +- Integration tests: Full bounty lifecycle +- Security tests: Reentrancy, access control +- Edge cases: Minimum amounts, empty strings + +```bash +npx hardhat test # Run all tests +npx hardhat coverage # Generate coverage report +REPORT_GAS=true hardhat test # Gas usage report +``` + +### Frontend Tests + +**Framework**: Jest + React Testing Library (planned) +**Coverage Target**: 70%+ + +**Test Categories**: +- Component rendering +- User interactions +- Form validation +- Error handling + +--- + +## Deployment + +### Contract Deployment + +```bash +cd contract +npx hardhat run scripts/deploy.js --network polkadotHub +``` + +**Deployment Steps**: +1. Deploy mock USDC/USDT (testnet only) +2. Deploy MicroBounty with token addresses +3. Verify contract on block explorer +4. Save contract address to `.env` + +### Frontend Deployment + +**Platform**: Vercel +**Build Command**: `npm run build` +**Output Directory**: `.next` + +**Environment Variables**: +```env +NEXT_PUBLIC_CONTRACT_ADDRESS=0x73fC6177262D64ca26A76ECbab8c1aeD97e84AC5 +NEXT_PUBLIC_POLKADOT_HUB_RPC=https://rpc.polkadot-hub.io +NEXT_PUBLIC_CHAIN_ID=420420417 +``` + +--- + +## Monitoring & Maintenance + +### Contract Monitoring + +- **Block Explorer**: Track all transactions +- **Event Logs**: Monitor BountyCreated, BountyCompleted events +- **TVL Tracking**: Watch platformStats.totalValueLockedDOT + +### Frontend Monitoring + +- **Vercel Analytics**: Page views, performance +- **Error Tracking**: Console errors, failed transactions +- **User Feedback**: Discord, Telegram for bug reports + +--- + +## Future Architecture Improvements + +### v2.0 Enhancements + +1. **XCM Integration** + - Cross-chain bounty verification + - Pay from Asset Hub, verify on Moonbeam + - Requires XCM precompiles on Polkadot Hub + +2. **Reputation System** + - On-chain reputation scores + - NFT badges for achievements + - Weighted voting for disputes + +3. **Milestone Payments** + - Multi-step bounties + - Partial payment releases + - Time-locked escrow + +### v3.0 Vision + +1. **Parachain Integration** + - Direct governance proposal → bounty conversion + - Treasury funding automation + - Curator assignment + +2. **Off-Chain Workers** + - Automated GitHub issue import + - AI-powered skill matching + - Automated dispute mediation + +--- + +## Conclusion + +MicroBounty's architecture balances: +- **Security**: Industry-standard patterns, comprehensive testing +- **Simplicity**: No unnecessary complexity, clear separation of concerns +- **Scalability**: Ready for thousands of bounties and users +- **Polkadot-Native**: Built specifically for the Polkadot ecosystem + +The system is production-ready for testnet, with a clear path to mainnet deployment and future enhancements. + +--- + +**Last Updated**: March 2026 +**Version**: 1.0 +**Maintainer**: Fatima Aminu (@phertyameen) \ No newline at end of file diff --git a/README.md b/README.md index a5cb2b1..5dfadcf 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,22 @@ > **A decentralized bounty marketplace built natively on Polkadot Hub** [![Live Demo](https://img.shields.io/badge/Live%20Demo-micro--bounty.vercel.app-brightgreen?style=for-the-badge)](https://micro-bounty.vercel.app/) -[![Smart Contract](https://img.shields.io/badge/Contract-Blockscout-blue?style=for-the-badge)](https://blockscout-testnet.polkadot.io/address/0x73fC6177262D64ca26A76ECbab8c1aeD97e84AC5?tab=index) -[![OpenGuild Hackathon](https://img.shields.io/badge/OpenGuild-Hackathon%202025-E6007A?style=for-the-badge)](https://openguild.wtf) +[![Smart Contract](https://img.shields.io/badge/Contract-0x73fC...84AC5-gray?style=for-the-badge)](https://blockscout-testnet.polkadot.io/address/0x73fC6177262D64ca26A76ECbab8c1aeD97e84AC5?tab=index) +[![Blockscout](https://img.shields.io/badge/Explorer-Blockscout-blue?style=for-the-badge)](https://blockscout-testnet.polkadot.io/address/0x73fC6177262D64ca26A76ECbab8c1aeD97e84AC5?tab=index) +
+[![OpenGuild](https://img.shields.io/badge/OpenGuild-Hackathon%202025-E6007A?style=for-the-badge)](https://dorahacks.io/hackathon/polkadot-solidity-hackathon) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](LICENSE) +
![MicroBounty Homepage](image.png) +*This project implements Idea #141 (Bounty Payment Platform) using Polkadot Hub's EVM-compatible smart contracts. While the original specification suggested Dedot/PAPI, we chose Solidity to leverage broader developer accessibility while still delivering all core features:* + +- Multi-currency support, +- Transaction history, +- Analytics, and +- Seamless bounty workflows. + ## What Is MicroBounty? MicroBounty is an on-chain bounty platform that lets projects post tasks and pay contributors **instantly and trustlessly** using native **DOT** or stablecoins (**USDC, USDT**) on **Polkadot Hub**. @@ -76,13 +86,11 @@ Supports MetaMask, SubWallet, Talisman, and any EIP-1193 wallet via Reown AppKit | Metric | Value | |---|---| -| Total Value Transacted | $12,000+ | -| Paid to Contributors | $1,900+ | -| Bounties Posted | 20+ | -| Completion Rate | 50% | -| Cancellation Rate | 8% | - -## Technical Architecture +| Total Value Transacted | $20,000+ | +| Paid to Contributors | $4,000+ | +| Bounties Posted | 30+ | +| Completion Rate | 34% | +| Cancellation Rate | 13% | ### Smart Contract (`/contract`) @@ -102,6 +110,27 @@ Supports MetaMask, SubWallet, Talisman, and any EIP-1193 wallet via Reown AppKit - `BountyContext` — on-chain state, filtering, pagination - `WalletContext` — PAS + ERC20 balance fetching, wallet name detection +## Quick Start + +### Try It Now +1. Visit [micro-bounty.vercel.app](https://micro-bounty.vercel.app/) +2. Connect wallet (MetaMask/SubWallet) +3. Switch to Polkadot Hub Testnet +4. Get testnet DOT from [faucet link] +5. Create your first bounty! + +## Demo Video + +[![Watch Demo](https://cdn.loom.com/sessions/thumbnails/fe3baacd28764a30b28a66a7aeadc176-with-play.gif)](https://www.loom.com/share/fe3baacd28764a30b28a66a7aeadc176) + +*5-minute walkthrough of creating, submitting, and approving a bounty* + +## Documentation + +- **Architecture**: [ARCHITECTURE.md](ARCHITECTURE.md) - Technical design and decisions +- **Smart Contract**: [contract/README.md](contract/README.md) - Contract deployment and testing +- **Frontend**: [frontend/README.md](frontend/README.md) - Frontend setup and development + ## Roadmap **v1.0 — Live ✅** @@ -157,4 +186,4 @@ Special thanks to [OpenGuild](https://openguild.wtf) and [Web3 Foundation](https **Contact:** [@teemahbee](https://t.me/teemahbee) · [LinkedIn](https://www.linkedin.com/in/fatima-aminu-839835176/) · [Gmail](aminubabafatima8@gmail.com) -[MIT License](LICENSE) · *MicroBounty — where projects and talent meet on Polkadot.* \ No newline at end of file +[MIT License](LICENSE) · *MicroBounty — where projects and talent meet on Polkadot.* diff --git a/contract/hardhat.config.js b/contract/hardhat.config.js index d26437e..7fbdbfd 100644 --- a/contract/hardhat.config.js +++ b/contract/hardhat.config.js @@ -21,7 +21,7 @@ module.exports = { polkadotTestnet: { url: "https://eth-rpc-testnet.polkadot.io/", chainId: 420420417, - accounts: [private_key], + accounts: private_key ? [private_key] : [], }, local: { url: "http://127.0.0.1:8545", diff --git a/contract/package-lock.json b/contract/package-lock.json index 0a41887..03c19c2 100644 --- a/contract/package-lock.json +++ b/contract/package-lock.json @@ -923,9 +923,9 @@ } }, "node_modules/@nomicfoundation/hardhat-chai-matchers": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-chai-matchers/-/hardhat-chai-matchers-2.1.0.tgz", - "integrity": "sha512-GPhBNafh1fCnVD9Y7BYvoLnblnvfcq3j8YDbO1gGe/1nOFWzGmV7gFu5DkwFXF+IpYsS+t96o9qc/mPu3V3Vfw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-chai-matchers/-/hardhat-chai-matchers-2.1.2.tgz", + "integrity": "sha512-NlUlde/ycXw2bLzA2gWjjbxQaD9xIRbAF30nsoEprAWzH8dXEI1ILZUKZMyux9n9iygEXTzN0SDVjE6zWDZi9g==", "dev": true, "license": "MIT", "peer": true, @@ -1009,14 +1009,14 @@ } }, "node_modules/@nomicfoundation/hardhat-toolbox": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-toolbox/-/hardhat-toolbox-6.1.0.tgz", - "integrity": "sha512-iAIl6pIK3F4R3JXeq+b6tiShXUrp1sQRiPfqoCMUE7QLUzoFifzGV97IDRL6e73pWsMKpUQBsHBvTCsqn+ZdpA==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@nomicfoundation/hardhat-toolbox/-/hardhat-toolbox-6.1.2.tgz", + "integrity": "sha512-xKL2r43GC/UIcQzmtFSmj3L4KqLSQ4fK+kyUw0vbIp94nV+9o2ZkI1s3znB8EKXqitt9ClXo0qcKj9RKOFjqPQ==", "dev": true, "license": "MIT", "peerDependencies": { "@nomicfoundation/hardhat-chai-matchers": "^2.1.0", - "@nomicfoundation/hardhat-ethers": "^3.1.0", + "@nomicfoundation/hardhat-ethers": "^3.1.3", "@nomicfoundation/hardhat-ignition-ethers": "^0.15.14", "@nomicfoundation/hardhat-network-helpers": "^1.1.0", "@nomicfoundation/hardhat-verify": "^2.1.0", @@ -1027,9 +1027,9 @@ "@types/node": ">=20.0.0", "chai": "^4.2.0", "ethers": "^6.14.0", - "hardhat": "^2.26.0", + "hardhat": "^2.28.0", "hardhat-gas-reporter": "^2.3.0", - "solidity-coverage": "^0.8.1", + "solidity-coverage": "^0.8.17", "ts-node": ">=8.0.0", "typechain": "^8.3.0", "typescript": ">=4.5.0" @@ -2030,9 +2030,9 @@ "peer": true }, "node_modules/bn.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", - "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", "dev": true, "license": "MIT" }, @@ -2930,9 +2930,9 @@ } }, "node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -3810,9 +3810,9 @@ } }, "node_modules/globby/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "peer": true, @@ -4432,9 +4432,9 @@ } }, "node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", + "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", "dev": true, "license": "MIT" }, @@ -5089,14 +5089,14 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "peer": true, "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -5234,9 +5234,9 @@ } }, "node_modules/mocha/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", "dependencies": { @@ -5896,9 +5896,9 @@ } }, "node_modules/recursive-readdir/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "peer": true, @@ -6170,9 +6170,9 @@ } }, "node_modules/sc-istanbul/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "peer": true, @@ -6423,9 +6423,9 @@ } }, "node_modules/shelljs/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "peer": true, @@ -7292,9 +7292,9 @@ } }, "node_modules/typechain/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "peer": true, diff --git a/contract/test/MicroBounty.test.js b/contract/test/MicroBounty.test.js index baf66a8..3837694 100644 --- a/contract/test/MicroBounty.test.js +++ b/contract/test/MicroBounty.test.js @@ -1,12 +1,14 @@ const { expect } = require("chai"); const { ethers } = require("hardhat"); -const { loadFixture } = require("@nomicfoundation/hardhat-toolbox/network-helpers"); +const { + loadFixture, +} = require("@nomicfoundation/hardhat-toolbox/network-helpers"); // ─── Constants (mirror the contract) ───────────────────────────────────────── -const MIN_DOT = ethers.parseUnits("100", 10); // 100 DOT (10 decimals) -const MIN_STABLE = ethers.parseUnits("100", 6); // 100 USDC (6 decimals) -const ZERO_ADDR = ethers.ZeroAddress; +const MIN_DOT = ethers.parseUnits("100", 10); // 100 DOT (10 decimals) +const MIN_STABLE = ethers.parseUnits("100", 6); // 100 USDC (6 decimals) +const ZERO_ADDR = ethers.ZeroAddress; // ─── Fixture ────────────────────────────────────────────────────────────────── @@ -15,10 +17,10 @@ async function deployFixture() { const ERC20 = await ethers.getContractFactory("MockERC20"); const usdc = await ERC20.deploy("USD Coin", "USDC", 6); - const usdt = await ERC20.deploy("Tether", "USDT", 6); + const usdt = await ERC20.deploy("Tether", "USDT", 6); const Factory = await ethers.getContractFactory("MicroBounty"); - const bounty = await Factory.deploy([ + const bounty = await Factory.deploy([ await usdc.getAddress(), await usdt.getAddress(), ]); @@ -47,7 +49,12 @@ async function createDotBounty(bounty, signer, reward = MIN_DOT) { ); } -async function createStableBounty(bounty, signer, tokenAddr, reward = MIN_STABLE) { +async function createStableBounty( + bounty, + signer, + tokenAddr, + reward = MIN_STABLE, +) { return bounty.connect(signer).createBounty( "Stable Bounty", "A description for the stable bounty that is long enough", @@ -60,7 +67,6 @@ async function createStableBounty(bounty, signer, tokenAddr, reward = MIN_STABLE // ─── Tests ──────────────────────────────────────────────────────────────────── describe("MicroBounty", function () { - // ── Deployment ────────────────────────────────────────────────────────────── describe("Deployment", function () { @@ -120,11 +126,16 @@ describe("MicroBounty", function () { it("rejects msg.value != reward", async function () { const { bounty, creator } = await loadFixture(deployFixture); await expect( - bounty.connect(creator).createBounty( - "Title", "Description that is long enough for the contract", - MIN_DOT, ZERO_ADDR, 0, - { value: MIN_DOT - 1n }, - ), + bounty + .connect(creator) + .createBounty( + "Title", + "Description that is long enough for the contract", + MIN_DOT, + ZERO_ADDR, + 0, + { value: MIN_DOT - 1n }, + ), ).to.be.revertedWith("msg.value must equal reward amount"); }); @@ -132,48 +143,68 @@ describe("MicroBounty", function () { const { bounty, creator } = await loadFixture(deployFixture); const tooLow = MIN_DOT - 1n; await expect( - bounty.connect(creator).createBounty( - "Title", "Description that is long enough for the contract", - tooLow, ZERO_ADDR, 0, - { value: tooLow }, - ), + bounty + .connect(creator) + .createBounty( + "Title", + "Description that is long enough for the contract", + tooLow, + ZERO_ADDR, + 0, + { value: tooLow }, + ), ).to.be.revertedWith("Reward below minimum (100 DOT)"); }); it("rejects empty title", async function () { const { bounty, creator } = await loadFixture(deployFixture); await expect( - bounty.connect(creator).createBounty( - "", "Description", MIN_DOT, ZERO_ADDR, 0, { value: MIN_DOT }, - ), + bounty + .connect(creator) + .createBounty("", "Description", MIN_DOT, ZERO_ADDR, 0, { + value: MIN_DOT, + }), ).to.be.revertedWith("Title must be 1-100 characters"); }); it("rejects title over 100 chars", async function () { const { bounty, creator } = await loadFixture(deployFixture); await expect( - bounty.connect(creator).createBounty( - "a".repeat(101), "Description", MIN_DOT, ZERO_ADDR, 0, { value: MIN_DOT }, - ), + bounty + .connect(creator) + .createBounty( + "a".repeat(101), + "Description", + MIN_DOT, + ZERO_ADDR, + 0, + { value: MIN_DOT }, + ), ).to.be.revertedWith("Title must be 1-100 characters"); }); it("rejects invalid category", async function () { const { bounty, creator } = await loadFixture(deployFixture); await expect( - bounty.connect(creator).createBounty( - "Title", "Description", MIN_DOT, ZERO_ADDR, 5, { value: MIN_DOT }, - ), + bounty + .connect(creator) + .createBounty("Title", "Description", MIN_DOT, ZERO_ADDR, 5, { + value: MIN_DOT, + }), ).to.be.revertedWith("Invalid category"); }); }); describe("ERC20 (stablecoin)", function () { it("pulls tokens from creator into the contract", async function () { - const { bounty, usdc, creator, contractAddr } = await loadFixture(deployFixture); + const { bounty, usdc, creator, contractAddr } = await loadFixture( + deployFixture, + ); const before = await usdc.balanceOf(creator.address); await createStableBounty(bounty, creator, await usdc.getAddress()); - expect(await usdc.balanceOf(creator.address)).to.equal(before - MIN_STABLE); + expect(await usdc.balanceOf(creator.address)).to.equal( + before - MIN_STABLE, + ); expect(await usdc.balanceOf(contractAddr)).to.equal(MIN_STABLE); }); @@ -188,19 +219,25 @@ describe("MicroBounty", function () { const { bounty, creator } = await loadFixture(deployFixture); const rando = ethers.Wallet.createRandom().address; await expect( - bounty.connect(creator).createBounty( - "Title", "Description", MIN_STABLE, rando, 0, - ), + bounty + .connect(creator) + .createBounty("Title", "Description", MIN_STABLE, rando, 0), ).to.be.revertedWith("Token is not supported"); }); it("rejects msg.value > 0 alongside ERC20", async function () { const { bounty, usdc, creator } = await loadFixture(deployFixture); await expect( - bounty.connect(creator).createBounty( - "Title", "Description", MIN_STABLE, await usdc.getAddress(), 0, - { value: 1n }, - ), + bounty + .connect(creator) + .createBounty( + "Title", + "Description", + MIN_STABLE, + await usdc.getAddress(), + 0, + { value: 1n }, + ), ).to.be.revertedWith("Do not send DOT when using an ERC20 token"); }); }); @@ -212,7 +249,9 @@ describe("MicroBounty", function () { it("moves bounty to IN_PROGRESS and sets hunter", async function () { const { bounty, creator, hunter } = await loadFixture(deployFixture); await createDotBounty(bounty, creator); - await bounty.connect(hunter).submitWork(1, "https://github.com/pr/1", "Done"); + await bounty + .connect(hunter) + .submitWork(1, "https://github.com/pr/1", "Done"); const b = await bounty.getBounty(1); expect(b.status).to.equal(1); // IN_PROGRESS expect(b.hunter).to.equal(hunter.address); @@ -236,7 +275,9 @@ describe("MicroBounty", function () { }); it("rejects submission on non-OPEN bounty", async function () { - const { bounty, creator, hunter, other } = await loadFixture(deployFixture); + const { bounty, creator, hunter, other } = await loadFixture( + deployFixture, + ); await createDotBounty(bounty, creator); await bounty.connect(hunter).submitWork(1, "https://proof.url", ""); await expect( @@ -256,7 +297,9 @@ describe("MicroBounty", function () { const { bounty, creator, hunter } = await loadFixture(deployFixture); await createDotBounty(bounty, creator); await expect( - bounty.connect(hunter).submitWork(1, "https://proof.url", "n".repeat(201)), + bounty + .connect(hunter) + .submitWork(1, "https://proof.url", "n".repeat(201)), ).to.be.revertedWith("Notes exceed 200 character limit"); }); @@ -302,20 +345,26 @@ describe("MicroBounty", function () { const { bounty, creator, hunter } = await loadFixture(deployFixture); await createDotBounty(bounty, creator); await bounty.connect(hunter).submitWork(1, "https://proof.url", ""); - await expect(bounty.connect(creator).approveBounty(1)) - .to.emit(bounty, "BountyCompleted"); + await expect(bounty.connect(creator).approveBounty(1)).to.emit( + bounty, + "BountyCompleted", + ); }); }); describe("ERC20 (USDC)", function () { it("transfers USDC to hunter and decrements locked stable", async function () { - const { bounty, usdc, creator, hunter } = await loadFixture(deployFixture); + const { bounty, usdc, creator, hunter } = await loadFixture( + deployFixture, + ); await createStableBounty(bounty, creator, await usdc.getAddress()); await bounty.connect(hunter).submitWork(1, "https://proof.url", ""); const before = await usdc.balanceOf(hunter.address); await bounty.connect(creator).approveBounty(1); - expect(await usdc.balanceOf(hunter.address)).to.equal(before + MIN_STABLE); + expect(await usdc.balanceOf(hunter.address)).to.equal( + before + MIN_STABLE, + ); const stats = await bounty.getPlatformStats(); expect(stats.totalValueLockedStable).to.equal(0); @@ -324,7 +373,9 @@ describe("MicroBounty", function () { }); it("rejects non-creator calling approve", async function () { - const { bounty, creator, hunter, other } = await loadFixture(deployFixture); + const { bounty, creator, hunter, other } = await loadFixture( + deployFixture, + ); await createDotBounty(bounty, creator); await bounty.connect(hunter).submitWork(1, "https://proof.url", ""); await expect(bounty.connect(other).approveBounty(1)).to.be.revertedWith( @@ -360,10 +411,10 @@ describe("MicroBounty", function () { await createDotBounty(bounty, creator); const before = await ethers.provider.getBalance(creator.address); - const tx = await bounty.connect(creator).cancelBounty(1); + const tx = await bounty.connect(creator).cancelBounty(1); const receipt = await tx.wait(); const gasCost = receipt.gasUsed * receipt.gasPrice; - const after = await ethers.provider.getBalance(creator.address); + const after = await ethers.provider.getBalance(creator.address); expect(after - before + gasCost).to.equal(MIN_DOT); expect((await bounty.getBounty(1)).status).to.equal(3); // CANCELLED @@ -383,8 +434,10 @@ describe("MicroBounty", function () { it("emits BountyCancelled", async function () { const { bounty, creator } = await loadFixture(deployFixture); await createDotBounty(bounty, creator); - await expect(bounty.connect(creator).cancelBounty(1)) - .to.emit(bounty, "BountyCancelled"); + await expect(bounty.connect(creator).cancelBounty(1)).to.emit( + bounty, + "BountyCancelled", + ); }); }); @@ -434,7 +487,9 @@ describe("MicroBounty", function () { describe("Platform stats integrity", function () { it("tracks multiple bounties across the full lifecycle", async function () { - const { bounty, usdc, creator, hunter } = await loadFixture(deployFixture); + const { bounty, usdc, creator, hunter } = await loadFixture( + deployFixture, + ); const usdcAddr = await usdc.getAddress(); // Create 2 DOT + 1 USDC bounty @@ -465,7 +520,7 @@ describe("MicroBounty", function () { expect(stats.cancelledBounties).to.equal(1); expect(stats.totalValueLockedDOT).to.equal(0); expect(stats.totalValueLockedStable).to.equal(0); - expect(stats.totalPaidOutDOT).to.equal(MIN_DOT); // only #1 + expect(stats.totalPaidOutDOT).to.equal(MIN_DOT); // only #1 expect(stats.totalPaidOutStable).to.equal(MIN_STABLE); // only #3 }); }); @@ -479,7 +534,7 @@ describe("MicroBounty", function () { await createDotBounty(bounty, creator); // #2 await bounty.connect(hunter).submitWork(1, "https://proof.url", ""); // #1 → IN_PROGRESS - const open = await bounty.getBountiesByStatus(0); + const open = await bounty.getBountiesByStatus(0); const inProgress = await bounty.getBountiesByStatus(1); expect(open.map(Number)).to.deep.equal([2]); expect(inProgress.map(Number)).to.deep.equal([1]); @@ -492,8 +547,12 @@ describe("MicroBounty", function () { await bounty.connect(hunter).submitWork(1, "https://proof.url", ""); await bounty.connect(hunter).submitWork(2, "https://proof2.url", ""); - expect((await bounty.getUserBounties(creator.address)).map(Number)).to.deep.equal([1, 2]); - expect((await bounty.getUserSubmissions(hunter.address)).map(Number)).to.deep.equal([1, 2]); + expect( + (await bounty.getUserBounties(creator.address)).map(Number), + ).to.deep.equal([1, 2]); + expect( + (await bounty.getUserSubmissions(hunter.address)).map(Number), + ).to.deep.equal([1, 2]); }); it("getBountiesByToken returns correct IDs", async function () { @@ -501,8 +560,146 @@ describe("MicroBounty", function () { await createDotBounty(bounty, creator); await createStableBounty(bounty, creator, await usdc.getAddress()); - expect((await bounty.getBountiesByToken(ZERO_ADDR)).map(Number)).to.deep.equal([1]); - expect((await bounty.getBountiesByToken(await usdc.getAddress())).map(Number)).to.deep.equal([2]); + expect( + (await bounty.getBountiesByToken(ZERO_ADDR)).map(Number), + ).to.deep.equal([1]); + expect( + (await bounty.getBountiesByToken(await usdc.getAddress())).map(Number), + ).to.deep.equal([2]); + }); + + describe("getStatistics", function () { + it("returns correct top-level counters across lifecycle", async function () { + const { bounty, creator, hunter } = await loadFixture(deployFixture); + + await createDotBounty(bounty, creator); // #1 + await createDotBounty(bounty, creator); // #2 + await createDotBounty(bounty, creator); // #3 + + await bounty.connect(hunter).submitWork(1, "https://proof.url", ""); + await bounty.connect(creator).approveBounty(1); // completed + await bounty.connect(creator).cancelBounty(2); // cancelled + // #3 stays OPEN + + const [total, active, completed, cancelled] = + await bounty.getStatistics(); + expect(total).to.equal(3); + expect(active).to.equal(1); + expect(completed).to.equal(1); + expect(cancelled).to.equal(1); + }); + }); + + describe("getUserStatistics", function () { + it("returns correct per-user summary for creator and hunter", async function () { + const { bounty, usdc, creator, hunter } = await loadFixture( + deployFixture, + ); + + await createDotBounty(bounty, creator); + await createStableBounty(bounty, creator, await usdc.getAddress()); + + await bounty.connect(hunter).submitWork(1, "https://proof.url", ""); + await bounty.connect(creator).approveBounty(1); // hunter earns DOT + + await bounty.connect(hunter).submitWork(2, "https://proof2.url", ""); + await bounty.connect(creator).approveBounty(2); // hunter earns stable + + const [created, completed, earnedDOT, earnedStable] = + await bounty.getUserStatistics(hunter.address); + + expect(created).to.equal(0); + expect(completed).to.equal(2); + expect(earnedDOT).to.equal(MIN_DOT); + expect(earnedStable).to.equal(MIN_STABLE); + }); + + it("returns zeroes for an address with no activity", async function () { + const { bounty, other } = await loadFixture(deployFixture); + const [created, completed, earnedDOT, earnedStable] = + await bounty.getUserStatistics(other.address); + + expect(created).to.equal(0); + expect(completed).to.equal(0); + expect(earnedDOT).to.equal(0); + expect(earnedStable).to.equal(0); + }); + }); + + describe("getCurrencyStats", function () { + it("counts DOT and ERC20 bounties separately", async function () { + const { bounty, usdc, usdt, creator } = await loadFixture( + deployFixture, + ); + + await createDotBounty(bounty, creator); + await createDotBounty(bounty, creator); + await createStableBounty(bounty, creator, await usdc.getAddress()); + await createStableBounty(bounty, creator, await usdt.getAddress()); + + const { dotBounties, tokenBounties, tokens } = + await bounty.getCurrencyStats(); + + expect(dotBounties).to.equal(2); + + const usdcIndex = tokens.indexOf(await usdc.getAddress()); + const usdtIndex = tokens.indexOf(await usdt.getAddress()); + expect(tokenBounties[usdcIndex]).to.equal(1); + expect(tokenBounties[usdtIndex]).to.equal(1); + }); + + it("returns zero counts when no bounties exist", async function () { + const { bounty } = await loadFixture(deployFixture); + const { dotBounties, tokenBounties } = await bounty.getCurrencyStats(); + + expect(dotBounties).to.equal(0); + expect(tokenBounties.every((n) => n === 0n)).to.be.true; + }); + }); + + // ── getBountiesByCreator ───────────────────────────────────────────────────── + + describe("getBountiesByCreator", function () { + it("returns all bounty IDs created by a specific address", async function () { + const { bounty, creator } = await loadFixture(deployFixture); + await createDotBounty(bounty, creator); + await createDotBounty(bounty, creator); + + const result = await bounty.getBountiesByCreator(creator.address); + expect(result.map(Number)).to.deep.equal([1, 2]); + }); + }); + + // ── getUserStats ───────────────────────────────────────────────────────────── + + describe("getUserStats", function () { + it("returns the full UserStats struct for a user", async function () { + const { bounty, usdc, creator, hunter } = await loadFixture( + deployFixture, + ); + await createDotBounty(bounty, creator); + await createStableBounty(bounty, creator, await usdc.getAddress()); + await bounty.connect(hunter).submitWork(1, "https://proof.url", ""); + await bounty.connect(creator).approveBounty(1); + + const stats = await bounty.getUserStats(hunter.address); + expect(stats.bountiesCompleted).to.equal(1); + expect(stats.totalEarnedDOT).to.equal(MIN_DOT); + expect(stats.totalEarnedStable).to.equal(0); + }); + }); + + // ── getSupportedTokens ─────────────────────────────────────────────────────── + + describe("getSupportedTokens", function () { + it("returns the full list of whitelisted token addresses", async function () { + const { bounty, usdc, usdt } = await loadFixture(deployFixture); + const tokens = await bounty.getSupportedTokens(); + + expect(tokens).to.include(await usdc.getAddress()); + expect(tokens).to.include(await usdt.getAddress()); + expect(tokens.length).to.equal(2); + }); }); it("getBountyCount increments correctly", async function () { @@ -515,14 +712,26 @@ describe("MicroBounty", function () { }); }); + // ── MockERC20 ──────────────────────────────────────────────────────────────── + + describe("MockERC20", function () { + it("returns the correct decimals", async function () { + const { usdc, usdt } = await loadFixture(deployFixture); + expect(await usdc.decimals()).to.equal(6); + expect(await usdt.decimals()).to.equal(6); + }); + }); // ── receive() fallback ─────────────────────────────────────────────────────── describe("receive()", function () { it("rejects plain ETH/DOT transfers", async function () { const { creator, contractAddr } = await loadFixture(deployFixture); await expect( - creator.sendTransaction({ to: contractAddr, value: ethers.parseEther("1") }), + creator.sendTransaction({ + to: contractAddr, + value: ethers.parseEther("1"), + }), ).to.be.revertedWith("Use createBounty to fund bounties"); }); }); -}); \ No newline at end of file +}); diff --git a/frontend/app/analytics/page.tsx b/frontend/app/analytics/page.tsx index 4885faa..608ff59 100644 --- a/frontend/app/analytics/page.tsx +++ b/frontend/app/analytics/page.tsx @@ -20,26 +20,26 @@ import { Legend, ResponsiveContainer, } from "recharts"; -import { TrendingUp, Coins, Zap, Loader2 } from "lucide-react"; +import { + TrendingUp, + Coins, + Zap, + Loader2, + Check, + Copy, + Trophy, + TrophyIcon, +} from "lucide-react"; import { useBounty } from "@/context/BountyContext"; import { ethers } from "ethers"; import { CATEGORY_LABELS, Category } from "@/lib/types"; import MicroBountyABI from "@/lib/abis/MicroBounty.json"; import contractAddresses from "@/lib/abis/contract-addresses.json"; -// ─── Constants ──────────────────────────────────────────────────────────────── - const COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981"]; const CONTRACT_ADDRESS = contractAddresses.MicroBounty; -const DAYS_TO_SHOW = 8; -const BLOCKS_PER_DAY = 14_400; -const BLOCK_LOOKBACK = DAYS_TO_SHOW * BLOCKS_PER_DAY; - -// Use the same RPC the context uses — DO NOT use NETWORKS constant (different URL) const RPC_URL = "https://eth-rpc-testnet.polkadot.io/"; -// ─── Types ──────────────────────────────────────────────────────────────────── - interface TrendPoint { date: string; created: number; @@ -47,7 +47,15 @@ interface TrendPoint { cancelled: number; } -// ─── Helpers ────────────────────────────────────────────────────────────────── +interface LeaderEntry { + address: string; + score: number; + bountiesCreated: number; + bountiesCompleted: number; + totalEarnedDOT: string; + totalSpentDOT: string; + role: "Creator" | "Hunter" | "Both"; +} function formatPAS(raw: string): string { try { @@ -84,7 +92,77 @@ function buildDateRange(days: number): string[] { return result; } -// ─── Event fetcher ──────────────────────────────────────────────────────────── +async function fetchLeaderboard( + bounties: Awaited< + ReturnType + >["bounties"], +): Promise { + const provider = new ethers.JsonRpcProvider(RPC_URL); + const contract = new ethers.Contract( + CONTRACT_ADDRESS, + MicroBountyABI.abi, + provider, + ); + + // Collect every unique address that has touched the platform + const addresses = new Set(); + for (const b of bounties) { + if (b.creator) addresses.add(b.creator.toLowerCase()); + if (b.hunter && b.hunter !== ethers.ZeroAddress) + addresses.add(b.hunter.toLowerCase()); + } + + if (addresses.size === 0) return []; + + // Fetch getUserStats for each address in parallel + const entries = await Promise.all( + [...addresses].map(async (addr): Promise => { + try { + const s = await contract.getUserStats(addr); + const created = Number(s.bountiesCreated); + const completed = Number(s.bountiesCompleted); + const earnedDOT = s.totalEarnedDOT.toString(); + const spentDOT = s.totalSpentDOT.toString(); + + // Scoring formula: + // +3 per bounty completed (hunter activity — hardest to fake) + // +1 per bounty created (creator activity) + // +1 per 1000 PAS earned (hunter earnings weight) + // +0.5 per 1000 PAS spent (creator spend weight) + const earnedPAS = parseFloat(ethers.formatUnits(earnedDOT, 10)); + const spentPAS = parseFloat(ethers.formatUnits(spentDOT, 10)); + const score = + completed * 3 + created * 1 + earnedPAS / 1000 + spentPAS / 2000; + + if (score === 0) return null; + + const role: LeaderEntry["role"] = + created > 0 && completed > 0 + ? "Both" + : completed > 0 + ? "Hunter" + : "Creator"; + + return { + address: addr, + score, + bountiesCreated: created, + bountiesCompleted: completed, + totalEarnedDOT: earnedDOT, + totalSpentDOT: spentDOT, + role, + }; + } catch { + return null; + } + }), + ); + + return entries + .filter((e): e is LeaderEntry => e !== null) + .sort((a, b) => b.score - a.score) + .slice(0, 5); +} async function fetchTrendFromEvents(): Promise { const provider = new ethers.JsonRpcProvider(RPC_URL); @@ -94,16 +172,62 @@ async function fetchTrendFromEvents(): Promise { provider, ); - // Get current block so we can provide a fromBlock to avoid RPC range limits const latestBlock = await provider.getBlockNumber(); - // ~14 days of blocks: Polkadot Hub testnet ~6s block time = ~10 blocks/min = ~201,600 blocks/14days - const fromBlock = Math.max(0, latestBlock - BLOCK_LOOKBACK); + // Find the contract's deploy block by querying from block 0. + // The very first BountyCreated event tells us the earliest meaningful activity, + // but the deploy block itself is more accurate — fetch it via the contract's + // creation transaction if available, otherwise fall back to block 0. + let deployTimestampMs: number; + let fromBlock = 0; + + try { + // Query all BountyCreated events from genesis to find the oldest one + const allCreated = await contract.queryFilter( + contract.filters.BountyCreated(), + 0, + latestBlock, + ); + + if (allCreated.length > 0) { + // Sort ascending and use the first event's block as the origin + const firstBlock = allCreated + .map((l) => l.blockNumber) + .sort((a, b) => a - b)[0]; + + const deployBlock = await provider.getBlock(firstBlock); + deployTimestampMs = deployBlock + ? deployBlock.timestamp * 1000 + : Date.now(); + fromBlock = firstBlock; + } else { + // No events yet — fall back to a reasonable default (contract likely just deployed) + const latest = await provider.getBlock(latestBlock); + deployTimestampMs = latest ? latest.timestamp * 1000 : Date.now(); + fromBlock = latestBlock; + } + } catch { + console.warn( + "[Analytics] Could not resolve deploy block, falling back to latest", + ); + deployTimestampMs = Date.now(); + fromBlock = latestBlock; + } + + // Calculate how many days have passed since contract origin + const msPerDay = 86_400_000; + const daysElapsed = Math.max( + 1, + Math.ceil((Date.now() - deployTimestampMs) / msPerDay) + 1, + ); + + console.log( + `[Analytics] Contract origin: ${new Date(deployTimestampMs).toISOString()}, showing ${daysElapsed} days`, + ); console.log( `[Analytics] Querying events from block ${fromBlock} to ${latestBlock}`, ); - // ABI-confirmed event names: BountyCreated, BountyCompleted, BountyCancelled const [createdLogs, completedLogs, cancelledLogs] = await Promise.all([ contract.queryFilter( contract.filters.BountyCreated(), @@ -123,14 +247,13 @@ async function fetchTrendFromEvents(): Promise { ]); console.log( - `[Analytics] Events found — created: ${createdLogs.length}, completed: ${completedLogs.length}, cancelled: ${cancelledLogs.length}`, + `[Analytics] Events — created: ${createdLogs.length}, completed: ${completedLogs.length}, cancelled: ${cancelledLogs.length}`, ); - // Collect unique block numbers across all logs to minimise getBlock() calls + // Resolve unique block timestamps const allLogs = [...createdLogs, ...completedLogs, ...cancelledLogs]; const uniqueBlocks = [...new Set(allLogs.map((l) => l.blockNumber))]; - // Resolve block timestamps in parallel const blockTimestamps: Record = {}; await Promise.all( uniqueBlocks.map(async (blockNum) => { @@ -138,14 +261,13 @@ async function fetchTrendFromEvents(): Promise { const block = await provider.getBlock(blockNum); if (block) blockTimestamps[blockNum] = block.timestamp * 1000; } catch { - // If a single block fetch fails, skip it — don't abort the whole chart console.warn(`[Analytics] Could not fetch block ${blockNum}`); } }), ); - // Initialise day buckets - const dateRange = buildDateRange(DAYS_TO_SHOW); + // Build date buckets from deploy day to today + const dateRange = buildDateRange(daysElapsed); const counts: Record = {}; for (const key of dateRange) { counts[key] = { @@ -175,16 +297,12 @@ async function fetchTrendFromEvents(): Promise { return dateRange.map((key) => counts[key]); } -// ─── Tooltip style ──────────────────────────────────────────────────────────── - const tooltipStyle = { backgroundColor: "hsl(var(--background))", border: "1px solid hsl(var(--border))", borderRadius: "8px", }; -// ─── Page ───────────────────────────────────────────────────────────────────── - export default function AnalyticsPage() { const { fetchPlatformStats, fetchBounties, platformStats, bounties } = useBounty(); @@ -193,6 +311,25 @@ export default function AnalyticsPage() { const [trendData, setTrendData] = useState([]); const [trendLoading, setTrendLoading] = useState(true); const [trendError, setTrendError] = useState(null); + const [leaderboard, setLeaderboard] = useState([]); + const [leaderLoading, setLeaderLoading] = useState(true); + + // Run after bounties are loaded so we have addresses to score + useEffect(() => { + if (statsLoading) return; + const load = async () => { + setLeaderLoading(true); + try { + const data = await fetchLeaderboard(bounties); + setLeaderboard(data); + } catch (err) { + console.error("[Analytics] fetchLeaderboard failed:", err); + } finally { + setLeaderLoading(false); + } + }; + load(); + }, [bounties, statsLoading]); useEffect(() => { const load = async () => { @@ -221,8 +358,6 @@ export default function AnalyticsPage() { load(); }, []); - // ── Derived data ────────────────────────────────────────────────────────── - const categoryData = Object.values(Category) .filter((v): v is Category => typeof v === "number") .map((cat) => { @@ -263,14 +398,22 @@ export default function AnalyticsPage() { (p) => p.created > 0 || p.completed > 0 || p.cancelled > 0, ); - const formatStable = (raw: string) => - parseFloat(ethers.formatUnits(raw, 6)).toFixed(2); + const formatStable = (raw: string) => { + try { + const n = parseFloat(ethers.formatUnits(raw, 6)); + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toFixed(2); + } catch { + return "—"; + } + }; return (
-
+

Platform Analytics

@@ -281,7 +424,7 @@ export default function AnalyticsPage() {

{/* Key Metrics */} -
+
- - } - iconBg="bg-green-100 dark:bg-green-900" - sub={ - statsLoading - ? null - : platformStats - ? `${formatPAS(platformStats.totalPaidOutDOT)} paid out` - : "—" - } - /> ) : categoryData.length === 0 ? ( -
+
No bounties yet
) : ( @@ -496,7 +624,7 @@ export default function AnalyticsPage() { ) : ( - Last {DAYS_TO_SHOW} days · On-chain events + Since contract deployment · On-chain events )}
@@ -511,7 +639,7 @@ export default function AnalyticsPage() { {trendLoading ? ( ) : trendError ? ( -
+

Failed to load event data

@@ -520,8 +648,8 @@ export default function AnalyticsPage() {

) : !trendHasData ? ( -
- No activity found in the last {DAYS_TO_SHOW} days. +
+ No activity found since contract deployment.
) : ( @@ -636,6 +764,47 @@ export default function AnalyticsPage() { )}
+ {/* Leaderboard */} + + {/* Subtle background accent */} +
+ +
+
+ +

Top Contributors

+
+ + All time · Activity score + +
+ + {leaderLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : leaderboard.length === 0 ? ( +
+ No activity recorded yet +
+ ) : ( +
+ {leaderboard.map((entry, i) => ( + + ))} +
+ )} +