Technical architecture and design decisions for the MicroBounty platform
- System Overview
- Smart Contract Layer
- Frontend Application
- Data Flow
- Security Architecture
- Design Decisions
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 │
└─────────────────────────────────────────────────────────────┘
File: contract/contracts/MicroBounty.sol
Address: 0x73fC6177262D64ca26A76ECbab8c1aeD97e84AC5 (Testnet)
Language: Solidity 0.8.28
Standards: ERC20-compatible, OpenZeppelin libraries
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 }// 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;| 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 |
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
function approveBounty(uint256 _bountyId)
external
nonReentrant // ← Prevents reentrancy
{
// ... payment logic
}// 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");modifier onlyBountyCreator(uint256 _bountyId) {
require(bounties[_bountyId].creator == msg.sender, "Only creator");
_;
}- 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
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;
}uint256 public constant MIN_REWARD_STABLE = 1e6; // 1 USDC/USDT
// Payment
IERC20(_paymentToken).safeTransferFrom(msg.sender, address(this), _reward);
platformStats.totalValueLockedStable += _reward;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);- 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
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
interface WalletContextType {
address: string | null;
isConnected: boolean;
chainId: number | null;
balances: {
dot: string;
usdc: string;
usdt: string;
};
walletName: string;
connectWallet: () => Promise<void>;
disconnectWallet: () => void;
}Responsibilities:
- Wallet connection via Reown AppKit
- Balance fetching (native + ERC20)
- Network validation
- Wallet provider management
interface BountyContextType {
bounties: Bounty[];
loading: boolean;
filters: {
status: BountyStatus | 'all';
currency: string | 'all';
category: Category | 'all';
};
platformStats: PlatformStats;
userStats: UserStats;
fetchBounties: () => Promise<void>;
createBounty: (data: CreateBountyData) => Promise<void>;
submitWork: (id: number, proof: string) => Promise<void>;
approveBounty: (id: number) => Promise<void>;
cancelBounty: (id: number) => Promise<void>;
}Responsibilities:
- Fetch bounties from contract
- Cache bounty data
- Filter/search logic
- Transaction submission
- Event listening for updates
Polkadot's native token uses 10 decimals, not 18 like Ethereum.
// 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
<input
onChange={(e) => {
const parsed = parseDOT(e.target.value); // Correct parsing
setReward(parsed);
}}
/>// 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!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
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
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
// Listen for events
contract.on("BountyCreated", (bountyId, creator, reward) => {
fetchBounties(); // Refresh bounty list
});
contract.on("BountyCompleted", (bountyId, hunter, reward) => {
updateBountyStatus(bountyId, "COMPLETED");
showSuccessNotification();
});- Applied to:
approveBounty(),cancelBounty() - Prevents recursive calls during payment transfers
- Uses OpenZeppelin's battle-tested implementation
- Wraps all ERC20 interactions
- Handles tokens that don't return booleans
- Prevents silent failures
- Only creator can approve/cancel their bounties
- Cannot submit work to your own bounty
- Status-based function restrictions
- Length limits on all strings
- Minimum reward thresholds
- Token whitelist enforcement
- Category bounds checking
- No private keys stored
- Users sign transactions in their wallet
- Network mismatch warnings
- Transaction simulation before send
// 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');
}
};// 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');
}
};Decision: Use Solidity on Polkadot Hub's EVM instead of native Substrate pallets.
Rationale:
- Broader Accessibility: More developers know Solidity than Substrate/Ink!
- Faster Development: Hardhat tooling is mature and well-documented
- Feature Parity: All Idea #141 requirements achievable with Solidity
- EVM Compatibility: Demonstrates Polkadot Hub's Ethereum compatibility
- 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
Decision: Support native DOT + stablecoins (USDC, USDT).
Rationale:
- Flexibility: Projects can pay in what they hold
- Stability: Contributors often prefer stablecoin payments
- Real-World Need: Polkadot ecosystem uses both DOT and stables
- Demonstrates ERC20 Handling: Shows complete EVM compatibility
Implementation:
address(0)= native DOT- Whitelisted ERC20 addresses = stablecoins
- Separate stats tracking per currency type
Decision: Store statistics in smart contract state instead of off-chain database.
Rationale:
- Transparency: Anyone can verify platform stats
- Simplicity: No backend infrastructure needed
- Trust: Metrics are tamper-proof
- 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)
Decision: Use React Context for state management instead of Redux/Zustand.
Rationale:
- Simplicity: Smaller bundle size, less boilerplate
- Sufficient Complexity: App doesn't need advanced state management
- Performance: Optimized with useMemo/useCallback
- Native: No external dependencies
When to Switch: If app grows to >20 components sharing state, consider Zustand.
Decision: Use ethers.js v6 instead of viem.
Rationale:
- Familiarity: More developers know ethers.js
- Documentation: Extensive resources and examples
- Compatibility: Works seamlessly with Hardhat
- Stability: Mature library with fewer breaking changes
Trade-off: viem is lighter and more modular, but ethers.js is proven.
- Batch Operations: Update multiple stats in single transaction
- View Functions: Heavy queries are
view(no gas cost) - Events Over Storage: Use events for historical data (cheaper)
- Memoization: Heavy computations wrapped in
useMemo - Lazy Loading: Components load on-demand
- Pagination: Only fetch visible bounties
- Event Caching: Cache contract events, poll every 10s
- Optimistic Updates: Update UI before transaction confirms
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
npx hardhat test # Run all tests
npx hardhat coverage # Generate coverage report
REPORT_GAS=true hardhat test # Gas usage reportFramework: Jest + React Testing Library (planned)
Coverage Target: 70%+
Test Categories:
- Component rendering
- User interactions
- Form validation
- Error handling
cd contract
npx hardhat run scripts/deploy.js --network polkadotHubDeployment Steps:
- Deploy mock USDC/USDT (testnet only)
- Deploy MicroBounty with token addresses
- Verify contract on block explorer
- Save contract address to
.env
Platform: Vercel
Build Command: npm run build
Output Directory: .next
Environment Variables:
NEXT_PUBLIC_CONTRACT_ADDRESS=0x73fC6177262D64ca26A76ECbab8c1aeD97e84AC5
NEXT_PUBLIC_POLKADOT_HUB_RPC=https://rpc.polkadot-hub.io
NEXT_PUBLIC_CHAIN_ID=420420417- Block Explorer: Track all transactions
- Event Logs: Monitor BountyCreated, BountyCompleted events
- TVL Tracking: Watch platformStats.totalValueLockedDOT
- Vercel Analytics: Page views, performance
- Error Tracking: Console errors, failed transactions
- User Feedback: Discord, Telegram for bug reports
-
XCM Integration
- Cross-chain bounty verification
- Pay from Asset Hub, verify on Moonbeam
- Requires XCM precompiles on Polkadot Hub
-
Reputation System
- On-chain reputation scores
- NFT badges for achievements
- Weighted voting for disputes
-
Milestone Payments
- Multi-step bounties
- Partial payment releases
- Time-locked escrow
-
Parachain Integration
- Direct governance proposal → bounty conversion
- Treasury funding automation
- Curator assignment
-
Off-Chain Workers
- Automated GitHub issue import
- AI-powered skill matching
- Automated dispute mediation
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)