A simple prediction market smart contract enabling decentralized binary outcome betting with automated reward distribution, liquidity management, and platform fee collection.
- Quick Start
- Project Structure
- Setup & Installation
- Network Configuration
- Compilation & Testing
- Deployment Guide
- Contract Architecture
- Core Functions
- Market Lifecycle
- Fee Structure
- Important Constants
- Integration Examples
- Security Features
- Troubleshooting
- License
# Clone and setup
git clone https://github.com/ChijiokeDivine/harbor-predict-main
cd harbor-predict-main
# Install dependencies
npm install
# or with pnpm
pnpm install
# Compile contracts
npx hardhat compile
# Run tests
npx hardhat test
# Deploy to Base Sepolia testnet
npx hardhat run scripts/deploy.js --network sepoliaharbor-predict/
├── contracts/
│ ├── PredictionMarket.sol # Main contract
│ ├── MockFunctionsRouter.sol # Router mock for testing
│ └── MockRejectingReceiver.sol # Test utilities
├── scripts/
│ └── deploy.js # Deployment script
├── test/
│ └── test.js # Comprehensive test suite (26+ cases)
├── artifacts/ # Compiled contract artifacts
├── hardhat.config.js # Hardhat configuration
├── .env # Environment variables
├── package.json
└── README.md
- Node.js v16+ or v18+ recommended
- npm or pnpm package manager
- Basic knowledge of Ethereum and Solidity
git clone https://github.com/awortuibenem/harbor-predict
cd harbor-predict-main/harbor-predict-mainnpm install
# or
pnpm installThis installs:
hardhat- Development environment@nomicfoundation/hardhat-toolbox- Essential plugins@openzeppelin/contracts- Secure contract librariesethers.js- Ethereum interaction librarydotenv- Environment variable management
Create a .env file in the root directory:
PRIVATE_KEY=your_64_character_hex_private_key_here
CONTRACT_ADDRESS=your_deployed_contract_address_here
BASE_CONTRACT_ADDRESS=forwarder_contract_address_here
ALCHEMY_API_KEY=your_alchemy_api_key_here
⚠️ SECURITY WARNING: Never commit.envto version control. Always use.gitignore.
| Network | Status | Chain ID | RPC Endpoint |
|---|---|---|---|
| Base Sepolia | ✅ Active | 84532 | Alchemy |
| Localhost | ✅ Development | 1337 | http://127.0.0.1:8545 |
| Hardhat | ✅ Testing | 1337 | Internal |
// hardhat.config.js excerpt
networks: {
sepolia: {
url: `https://base-sepolia.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}`,
chainId: 84532,
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
gasPrice: 10000000 // 10 gwei
},
hardhat: {
chainId: 1337
}
}npx hardhat compileOutput: Generates artifacts in ./artifacts folder with:
- ABI files
- Contract bytecode
- Debug information
npx hardhat testTest Coverage: 26+ comprehensive test cases covering:
- ✓ Market creation and initialization
- ✓ Bet placement with fee deduction
- ✓ Market resolution mechanisms
- ✓ Single and batch reward claims
- ✓ Refund logic for canceled markets
- ✓ Edge cases and error scenarios
- ✓ Reentrancy attack prevention
- ✓ Liquidity reserve management
REPORT_GAS=true npx hardhat test- Visit Base Sepolia Faucet
- Enter your wallet address
- Claim test ETH
Edit scripts/deploy.js and set the correct FORWARDER_ADDRESS:
const FORWARDER_ADDRESS = "0x82300bd7c3958625581cc2f77bc6464dcecdf3e5";npx hardhat run scripts/deploy.js --network sepoliaExpected Output:
ℹ️ Deploying from address: 0x...
ℹ️ Network: sepolia
ℹ️ Account balance: 10.5 ETH
⏳ Deploying PredictionMarket...
✅ PredictionMarket deployed to: 0x1234...
ℹ️ Transaction hash: 0x5678...
Update your .env file:
CONTRACT_ADDRESS=0x1234...(your_new_contract_address)struct Market {
uint256 id; // Unique market ID
uint256 startTime; // Betting start (Unix timestamp)
uint256 endTime; // Betting end + resolution window
uint256 minBet; // Minimum bet amount (Wei)
uint256 maxBet; // Maximum bet amount (Wei)
uint256 yesPool; // Total YES side liquidity
uint256 noPool; // Total NO side liquidity
address creator; // Market creator address
string question; // Markets question/description
bool resolved; // Resolution status
bool outcome; // Final outcome (true=YES, false=NO)
bool exists; // Existence flag
bool canceled; // Cancellation status
uint256 totalClaimed; // Total payout distributed
}struct Bet {
uint256 amount; // Bet stake (after fees)
bool claimed; // Claim status
bool side; // Prediction (true=YES, false=NO)
}┌─────────────────────────────────────────────────────────────────┐
│ PREDICTION MARKET LIFECYCLE │
└─────────────────────────────────────────────────────────────────┘
[1] CREATE [2] BETTING [3] RESOLUTION
──────── ──────────── ──────────────
Owner funds Users place bets Wait for endTime
liquidity reserve (YES/NO) + 5 min buffer
↓ ↓ ↓
createMarket() placeBet() onReport() / resolveMarket()
↓ ↓ ↓
Market initialized Bets recorded Market marked RESOLVED
Initial pools set Fees collected Outcome recorded
│ │ │
│ │ │
└──────────────────────┴────────────────────────┘
↓
[4] CLAIM REWARDS
──────────────────
Winners call:
• claimReward()
• batchClaimFor()
• claimFor()
↓
Calculate pro-rata share
Deduct 1.5% platform fee
Send payout
↓
✅ Rewards distributed
Creates a new binary outcome prediction market.
function createMarket(
string calldata question,
uint256 startTime,
uint256 endTime,
uint256 minBet,
uint256 maxBet
) externalParameters:
| Param | Type | Description | Example |
|---|---|---|---|
question |
string | Market question | "Will ETH hit $10K by EOY?" |
startTime |
uint256 | Betting start (Unix timestamp) | 1704067200 |
endTime |
uint256 | Betting end (Unix timestamp) | 1704153600 |
minBet |
uint256 | Minimum bet (Wei) | 10^18 (1 ETH) |
maxBet |
uint256 | Maximum bet (Wei) | 10^19 (10 ETH) |
Requirements:
- startTime > current block timestamp
- endTime > startTime
- minBet > 0 and < maxBet
- Platform liquidity reserve ≥ 0.01 ETH
- Question cannot be empty
Example:
const tx = await predictionMarket.createMarket(
"Will Bitcoin reach $100k in 2024?",
Math.floor(Date.now() / 1000) + 86400, // 1 day from now
Math.floor(Date.now() / 1000) + 604800, // 7 days from now
ethers.parseEther("0.1"), // Min 0.1 ETH
ethers.parseEther("5") // Max 5 ETH
);Place a bet on a specific market outcome.
function placeBet(uint256 marketId, bool side) external payableParameters:
| Param | Type | Description |
|---|---|---|
marketId |
uint256 | Market ID to bet on |
side |
bool | Prediction (true = YES, false = NO) |
msg.value |
uint256 | Bet amount (sent with transaction) |
Fee Calculation:
- Platform Fee: 150 bps (1.5%)
- Net stake = msg.value - (msg.value × 150 / 10000)
Example:
const tx = await predictionMarket.placeBet(1, true, {
value: ethers.parseEther("2") // Will deduct 0.03 ETH as fee
});
// User's stake: 1.97 ETH added to YES poolRequirements:
- Market must exist and not be resolved
- Betting phase must not have ended
- Bet must be within min/max range
- User can only bet once per market
Resolve a market with the outcomes (Owner only).
function resolveMarket(uint256 marketId, bool outcome)
external onlyOwnerParameters:
| Param | Type | Description |
|---|---|---|
marketId |
uint256 | Market to resolve |
outcome |
bool | Final result (true = YES wins, false = NO wins) |
Requirements:
- Only contract owner can call
- Market must exist and not already resolved
- Block timestamp must be ≥ endTime
Example:
const tx = await predictionMarket.resolveMarket(1, true);
// YES side wins market 1Claim reward for the calling user on a resolved market.
function claimReward(uint256 marketId) external nonReentrantReward Calculation:
userReward = (userBet × totalPool) / winningPool
platformFee = userReward × 150 / 10000
userPayout = userReward - platformFee
Example:
// Market: 100 ETH YES pool, 50 ETH NO pool
// User bet 10 ETH on YES (YES wins)
// userReward = (10 × 150) / 100 = 15 ETH
// platformFee = 15 × 1.5% = 0.225 ETH
// userPayout = 14.775 ETH
const tx = await predictionMarket.claimReward(1);Claim reward for another user (gas optimization).
function claimFor(uint256 marketId, address user)
external nonReentrantUse Case: Batch processing rewards for multiple users.
Distribute rewards to multiple users in a single transaction.
function batchClaimFor(
uint256 marketId,
address[] calldata users
) external nonReentrantConstraints:
- Maximum 100 users per call (MAX_BATCH_SIZE)
- Single gas-optimized storage write
- Automatically skips already-claimed or invalid bets
Example:
const users = [addr1, addr2, addr3, addr4, addr5];
const tx = await predictionMarket.batchClaimFor(1, users);
// All 5 users claim rewards in one transactionClaim full refund when market is canceled.
function claimRefund(uint256 marketId) external nonReentrantExample:
const tx = await predictionMarket.claimRefund(1);
// Get full bet amount back (minus original fee)Fund the platform liquidity reserve (for market creation).
function fundLiquidityReserve() external payable onlyOwnerPurpose: Markets require 0.01 ETH liquidity (split 50/50 between YES/NO pools).
Example:
const tx = await predictionMarket.fundLiquidityReserve({
value: ethers.parseEther("1") // Add 1 ETH to reserve
});Cancel a market and allow refunds (Owner only).
function cancelMarket(uint256 marketId) external onlyOwnerRequirements:
- Market must exist
- Market must not already be resolved
Example:
const tx = await predictionMarket.cancelMarket(1);
// All users can now claim refundsWithdraw accumulated platform fees.
function withdrawPlatformFees() external onlyOwnerExample:
const tx = await predictionMarket.withdrawPlatformFees();
// Transfer all collected fees to owner walletWithdraw from liquidity reserve.
function withdrawLiquidityReserve(uint256 amount) external onlyOwnerExample:
const tx = await predictionMarket.withdrawLiquidityReserve(
ethers.parseEther("0.5")
);Retrieve complete market information.
function getMarket(uint256 marketId)
external view returns (Market memory)Returns: Full Market struct with all details.
const market = await predictionMarket.getMarket(1);
console.log(market.question); // "Will it rain?"
console.log(market.yesPool); // 50000000000000000000 (50 ETH)
console.log(market.noPool); // 30000000000000000000 (30 ETH)
console.log(market.resolved); // true
console.log(market.outcome); // true (YES wins)Get a user's bet on a specific market.
function getUserBet(uint256 marketId, address user)
external view returns (Bet memory)const bet = await predictionMarket.getUserBet(1, walletAddress);
console.log(bet.amount); // User's stake (after fees)
console.log(bet.side); // true (YES) or false (NO)
console.log(bet.claimed); // true/falseGet total number of markets created.
const count = await predictionMarket.getMarketCount();
console.log(count); // 42Calculate current odds for both sides.
function getOdds(uint256 marketId)
external view returns (uint256 yesOdds, uint256 noOdds)Returns: Decimal-scaled odds (10000 = 1.0)
const [yesOdds, noOdds] = await predictionMarket.getOdds(1);
console.log(yesOdds / 10000); // e.g., 1.25 = 5/4 odds
console.log(noOdds / 10000); // e.g., 3.33 = 10/3 oddsGet comprehensive platform statistics.
function getPlatformStats() external view returns (
uint256 lifetimeFees,
uint256 withdrawnFees,
uint256 withdrawableFees,
uint256 liquidityReserve,
uint256 contractBalance
)const stats = await predictionMarket.getPlatformStats();
console.log("Lifetime fees collected:", ethers.formatEther(stats[0]));
console.log("Fees withdrawn:", ethers.formatEther(stats[1]));
console.log("Available to withdraw:", ethers.formatEther(stats[2]));
console.log("Liquidity reserve:", ethers.formatEther(stats[3]));
console.log("Contract balance:", ethers.formatEther(stats[4]));await predictionMarket.resolveMarket(1, true); // true = YES winsawait predictionMarket.claimReward(1);await predictionMarket.emergencyWithdraw();await predictionMarket.cancelMarket(1);createMarket(question, endTime, minBet, maxBet)placeBet(marketId, side)resolveMarket(marketId, outcome)claimReward(marketId)emergencyWithdraw()cancelMarket(marketId)- View functions:
getMarket,getUserBet,hasUserBet,getMarketCount,getOdds
- Only the contract owner can resolve, cancel, or emergency withdraw.
- All times are in Unix timestamp (seconds).
- All bets and payouts are in ETH (not MONAD).
MIT