A decentralized prediction betting application built on the Solana blockchain. Players stake SOL by predicting the price of stocks or crypto assets at a future time. The prediction closest to the real oracle price at expiry wins the pot.
- Overview
- How It Works
- Architecture
- Smart Contract
- Frontend
- Tech Stack
- Project Structure
- Getting Started
- Environment & Configuration
- Supported Assets
Stock Prediction is a peer-to-peer on-chain betting game where two players compete on price predictions. There is no house — all funds go directly to the winner. Settlement is trustless, powered by the Pyth Network price oracle.
Key properties:
- Non-custodial: funds are held in a program-derived account (PDA), never by a third party
- Oracle-settled: winners are determined by Pyth real-time prices, not self-reported data
- Permissionless: anyone with a Phantom wallet and SOL can create or enter a bet
- Devnet: currently deployed on Solana Devnet for testing
Player A Player B
| |
|-- Creates bet -----------------------> |
| (stake SOL + price prediction |
| + duration + asset) |
| |
| <------------------ |
| Enters bet |
| (match stake + own |
| price prediction) |
| |
| [Bet Expiry Time Reached] |
| |
|-- Anyone calls ClaimBet ------------> |
| (Pyth oracle price fetched on-chain)|
| Closest prediction wins 2x stake |
| Draw? Both get stake back |
- Player A creates a bet by choosing an asset, staking an amount of SOL, submitting a price prediction, and setting a duration (e.g. 1 hour).
- Player B sees the open bet and joins it with their own price prediction, matching the exact SOL stake.
- When the bet duration expires, anyone can call ClaimBet. The contract queries the Pyth oracle for the current price on-chain and awards the pot to whichever player's prediction was closest. If it's a tie, both get their stake returned.
- Players can also close expired or unclaimed bets to recover funds under specific conditions.
┌─────────────────────────────────────────────────────┐
│ Frontend (Next.js) │
│ │
│ ┌──────────┐ ┌───────────┐ ┌─────────────────┐ │
│ │ Header │ │ Chart │ │ AvailableBets │ │
│ │ (wallet) │ │(Portfolio)│ │ (Enter/Claim/ │ │
│ └──────────┘ └───────────┘ │ Close) │ │
│ └─────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ GlobalContext (state/global.js) │ │
│ │ fetchBets · createBet · enterBet │ │
│ │ claimBet · closeBet · fetchMasterAccount │ │
│ └────────────────────┬───────────────────────────┘ │
└───────────────────────┼─────────────────────────────┘
│ Anchor SDK + IDL
│
┌───────────────────────▼─────────────────────────────┐
│ Solana Blockchain (Devnet) │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ Smart Contract (Anchor / Rust) │ │
│ │ Program ID: 3vY5F41z5htZdEv3AF5vppzamEYwXFWG │ │
│ │ │ │
│ │ PDAs: │ │
│ │ • Master Account [seeds: "master"] │ │
│ │ • Bet Account [seeds: "bet", bet_id] │ │
│ └────────────────────┬───────────────────────────┘ │
└───────────────────────┼─────────────────────────────┘
│ CPI (Cross-Program Invocation)
│
┌───────────────────────▼─────────────────────────────┐
│ Pyth Network Price Oracle │
│ (real-time on-chain asset prices + exponent) │
└─────────────────────────────────────────────────────┘
Located in Contract/src/. Built with the Anchor framework (v0.28).
Master (state.rs) — singleton PDA tracking global bet ID counter.
pub struct Master {
pub last_bet_id: u64, // incremented on each new bet
}Bet (state.rs) — PDA holding all data for a single bet.
pub struct Bet {
pub id: u64,
pub players: [BetPrediction; 2], // player A and B
pub amount: u64, // stake in lamports
pub pyth_key: Pubkey, // Pyth feed account
pub expiry_time: i64, // Unix timestamp
pub state: BetState,
}
pub struct BetPrediction {
pub player: Pubkey,
pub price: i64, // predicted price (Pyth exponent-adjusted)
}
pub enum BetState {
Created, // waiting for Player B
Started, // both players in, waiting for expiry
PlayerAWon,
PlayerBWon,
Draw,
}PDA Seeds:
| Account | Seeds |
|---|---|
| Master | [b"master"] |
| Bet | [b"bet", bet_id as 8-byte little-endian] |
Defined in Contract/src/lib.rs:
| Instruction | Caller | Description |
|---|---|---|
create_master |
Anyone (once) | Initializes the global Master account |
create_bet |
Player A | Creates a new bet, transfers stake to PDA |
enter_bet |
Player B | Joins a bet, transfers matching stake to PDA |
claim_bet |
Anyone | Settles bet using Pyth oracle, transfers winnings |
close_bet |
Varies | Closes and cleans up a bet under allowed conditions |
create_bet
[Master] ──────────────────► [Bet: Created]
│
enter_bet (Player B)
│
▼
[Bet: Started]
│
┌───────────────┼──────────────────┐
│ │ │
claim_bet claim_bet claim_bet
(A closer) (B closer) (equal)
│ │ │
▼ ▼ ▼
[PlayerAWon] [PlayerBWon] [Draw]
│ │ │
└───────────────┴──────────────────┘
│
close_bet
│
[Account closed,
lamports returned
to creator]
Timing constraints (constants.rs):
MINIMUM_REMAINING_TIME_UNTIL_EXPIRY: 120 seconds — Player B cannot enter within 2 minutes of expiryMAXIMUM_CLAIMABLE_PERIOD: 300 seconds — ClaimBet must be called within 5 minutes after expiry
1. Fetch Pyth price data for the configured feed account
2. Verify the Pyth account key matches the one stored in the Bet
3. Read: pyth_price (i64) and exponent (i32)
4. multiplier = 10^(-exponent) // converts raw price to USD
5. adjusted_A = prediction_A × multiplier
6. adjusted_B = prediction_B × multiplier
7. diff_A = |pyth_price − adjusted_A|
8. diff_B = |pyth_price − adjusted_B|
9. if diff_A < diff_B → Player A wins, receives 2× stake
if diff_B < diff_A → Player B wins, receives 2× stake
if diff_A == diff_B → Draw, each player receives 1× stake back
A Next.js application in the root directory.
| File | Purpose |
|---|---|
| pages/_app.js | App wrapper — wallet + RPC providers, GlobalState |
| pages/index.js | Main page layout |
| components/Header.js | Navigation bar with Phantom wallet connect button |
| components/PortfolioChart.js | Line chart of asset price history (Chart.js) |
| components/DropDown.js | Collapsible asset selector menu |
| components/Asset.js | Asset card showing symbol, price, % change, mini chart |
| components/AvailableBets.js | Lists open/active bets with Enter / Claim / Close actions |
| components/CustomModal.js | Modal for submitting a price prediction to enter a bet |
state/global.js exposes a GlobalContext React context that wraps the entire app and provides:
{
fetchMasterAccount, // read master PDA, get last bet ID
fetchBets, // fetch all bet accounts from chain
createBet, // send CreateBet transaction
enterBet, // send EnterBet transaction
claimBet, // send ClaimBet transaction (anyone can call)
closeBet, // send CloseBet transaction
}All functions use react-hot-toast for real-time user feedback (loading, success, error).
_app.js
└── ConnectionProvider (RPC endpoint: Solana Devnet via QuikNode)
└── WalletProvider (Phantom)
└── WalletModalProvider
└── GlobalState
└── App
Program initialization (utils/program.js):
- Reads utils/idl.json — the auto-generated Anchor IDL
- Creates an
@project-serum/anchorPrograminstance tied to the connected wallet - Provides helper functions:
getPrgram(),getMasterAccountPk(),getBetAccountPk(betId)
Transaction flow:
- User connects Phantom wallet
- Frontend derives PDA addresses client-side (seeds are deterministic)
- Constructs and sends Anchor transaction with the correct account array
- Waits for confirmation on devnet
- Refreshes bet list and shows toast notification
| Technology | Version | Role |
|---|---|---|
| Rust | stable | Language |
| Anchor | 0.28 | Solana program framework |
| Pyth SDK | — | Price oracle integration |
| Technology | Version | Role |
|---|---|---|
| Next.js | 12.1.0 | React framework / SSR |
| React | 17.0.2 | UI library |
| @project-serum/anchor | — | TypeScript bindings for contract |
| @solana/web3.js | — | Solana RPC & transaction building |
| @solana/wallet-adapter | — | Phantom wallet integration |
| Tailwind CSS | 3.0.23 | Styling |
| Chart.js / react-chartjs-2 | 3.7.1 | Price charts |
| react-hot-toast | 2.4.0 | Toast notifications |
| BigNumber.js | 9.1.1 | Large number handling |
StockPrediction-Solana/
├── Contract/ # Rust smart contract (Anchor)
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs # Program entry point and 5 instructions
│ ├── state.rs # Master, Bet, BetPrediction, BetState
│ ├── error.rs # Custom error codes
│ ├── constants.rs # Timing constants, PDA seeds
│ └── utils.rs # Validation logic
│
├── components/ # React UI components
│ ├── Header.js
│ ├── PortfolioChart.js
│ ├── DropDown.js
│ ├── Asset.js
│ ├── AvailableBets.js
│ └── CustomModal.js
│
├── pages/
│ ├── _app.js # Provider setup
│ └── index.js # Home page
│
├── state/
│ └── global.js # GlobalContext + all blockchain calls
│
├── hooks/
│ └── useGlobalState.js # Hook to consume GlobalContext
│
├── utils/
│ ├── program.js # Anchor program + PDA helpers
│ ├── constants.js # Program ID, RPC endpoint
│ ├── utils.js # SOL/lamport conversion helpers
│ └── idl.json # Anchor IDL (auto-generated)
│
├── data/
│ └── asset.seed.js # Asset list with symbols and Pyth feed keys
│
├── styles/
│ ├── globals.css
│ └── Home.module.css
│
├── package.json
├── next.config.js
├── tailwind.config.js
└── README.md
- Node.js >= 16
- A Phantom Wallet browser extension
- Devnet SOL (free from faucet.solana.com)
# Install dependencies
npm install
# Start development server
npm run devOpen http://localhost:3000 in your browser.
npm run build
npm startAll network config lives in utils/constants.js:
// Solana Devnet RPC (QuikNode)
export const RPC_ENDPOINT = "https://snowy-twilight-lambo.solana-devnet.discover.quiknode.pro/";
// Deployed program ID on Devnet
export const PROGRAM_ID = new PublicKey("3vY5F41z5htZdEv3AF5vppzamEYwXFWGVatWRhZuRbBC");Make sure your Phantom wallet is set to Devnet before connecting.
Assets are seeded in data/asset.seed.js. Each asset maps to a Pyth price feed account used for on-chain settlement:
| Symbol | Type |
|---|---|
| BTC | Crypto |
| SOL | Crypto |
| AMC | Stock |
| AMZN | Stock |
| GOOG | Stock |
| BSE | Index |
| NSE | Index |
| Rule | Value |
|---|---|
| Minimum time before expiry for Player B to enter | 120 seconds |
| Maximum window to call ClaimBet after expiry | 300 seconds (5 min) |
| Both players must stake identical SOL amounts | Enforced on-chain |
| Pyth feed account must match the one set at bet creation | Enforced on-chain |
| Winner takes | 2× the staked amount |
| Draw result | Each player receives their original stake |
MIT