Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 63 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,57 +1,66 @@
# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:

```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
# Challenge Market

Solana-native challenge market with a Vite + React frontend and FastAPI backend. Users can create challenges, back them with SOL on Devnet, and record verified bet transactions.

## Project structure

```
.
├── backend/ # FastAPI application and SQLite persistence
├── src/ # React application source code
├── public/
└── types/ # Shared TypeScript DTOs
```

## Prerequisites

- Node.js 20+
- Python 3.11+
- Solana wallet (Phantom, Solflare, Backpack…) funded on Devnet

## Environment variables

Create a `.env` file (or export environment variables) with:

```bash
# Frontend
VITE_API_BASE_URL=http://localhost:8000
VITE_TREASURY_ADDRESS=<SOLANA_PUBLIC_KEY_FOR_CHALLENGE_TREASURY>
```

Optional backend overrides can be found in [`backend/README.md`](backend/README.md).

## Running locally

### Backend

```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
```

You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:

```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'

export default tseslint.config({
extends: [
// other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
The API will start on `http://127.0.0.1:8000`.

### Frontend

```bash
npm install
npm run dev
```

Open `http://localhost:5173` and connect a Solana wallet on Devnet. Challenge creation calls the backend API, and placing a bet sends a SOL transfer via the connected wallet. The backend verifies the signature before recording the bet.

## Testing bets on Devnet

1. Request Devnet SOL from the [Solana faucet](https://faucet.solana.com/).
2. Create a challenge with a treasury address you control.
3. Use the “Buy YES/NO” buttons to send SOL. The signature is verified against the configured RPC before it is saved.

## Production considerations

- Configure a persistent database (e.g., Postgres) instead of SQLite.
- Securely manage escrow keypairs if the backend is responsible for challenge treasuries.
- Add rate limiting and authentication to the API before mainnet deployment.
34 changes: 34 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Challenge Market Backend

FastAPI service that persists challenge metadata, records Solana bet transactions, and verifies transfer signatures on Devnet.

## Quick start

```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
```

The API listens on `http://127.0.0.1:8000` by default. Configure the base URL on the frontend with `VITE_API_BASE_URL`.

## Environment variables

| Variable | Description | Default |
| --- | --- | --- |
| `CHALLENGE_MARKET_DATABASE_URL` | SQL database URL | `sqlite:///./challenge_market.db` |
| `CHALLENGE_MARKET_SOLANA_RPC` | Solana RPC endpoint | `https://api.devnet.solana.com` |
| `CHALLENGE_MARKET_ALLOWED_ORIGINS` | Comma-separated list of allowed CORS origins | `http://localhost:5173,http://127.0.0.1:5173` |

## API overview

- `GET /health` – Healthcheck with Solana RPC status.
- `GET /api/challenges` – List all challenges with aggregated bet totals.
- `POST /api/challenges` – Create a new challenge.
- `GET /api/challenges/{id}` – Retrieve a single challenge and its bets.
- `POST /api/challenges/{id}/bets` – Record a bet after verifying the on-chain transfer.
- `GET /api/challenges/{id}/bets` – List bets for a challenge.

All bet submissions are verified against the configured Solana RPC to ensure the transfer signature exists and matches the treasury address.
Empty file added backend/__init__.py
Empty file.
Empty file added backend/app/__init__.py
Empty file.
25 changes: 25 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import os
from functools import lru_cache
from typing import List


class Settings:
"""Application configuration with environment fallbacks."""

api_prefix: str = "/api"

def __init__(self) -> None:
self.database_url: str = os.getenv("CHALLENGE_MARKET_DATABASE_URL", "sqlite:///./challenge_market.db")
self.solana_rpc_url: str = os.getenv("CHALLENGE_MARKET_SOLANA_RPC", "https://api.devnet.solana.com")
self.allowed_origins: List[str] = (
os.getenv("CHALLENGE_MARKET_ALLOWED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173")
.split(",")
)


@lru_cache
def get_settings() -> Settings:
return Settings()


settings = get_settings()
27 changes: 27 additions & 0 deletions backend/app/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from contextlib import contextmanager
from typing import Iterator

from sqlmodel import Session, SQLModel, create_engine

from .config import settings
from .seed import seed_initial_data


connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=connect_args, echo=False)


def init_db() -> None:
"""Create database tables."""
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
seed_initial_data(session)


@contextmanager
def get_session() -> Iterator[Session]:
session = Session(engine)
try:
yield session
finally:
session.close()
Comment on lines +21 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge Return actual Session object from dependency

The database dependency is wrapped in @contextmanager, so FastAPI receives a _GeneratorContextManager instead of a Session. Every route that depends on get_session calls methods like .exec on the returned value and will raise AttributeError at runtime, making all challenge/bet endpoints unusable. Remove the decorator and implement the usual yield session pattern so the dependency returns a live Session instance.

Useful? React with 👍 / 👎.

29 changes: 29 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from .config import settings
from .database import init_db
from .routers import bets, challenges, health


def create_app() -> FastAPI:
init_db()

app = FastAPI(title="Challenge Market API", version="0.1.0")

app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

app.include_router(health.router)
app.include_router(challenges.router, prefix=settings.api_prefix)
app.include_router(bets.router, prefix=settings.api_prefix)

return app


app = create_app()
69 changes: 69 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from datetime import datetime, timedelta
from typing import Optional
from uuid import UUID, uuid4

from sqlmodel import Field, SQLModel


class ChallengeBase(SQLModel):
title: str
description: str
category: str
prize_pool: float = Field(default=0, ge=0)
duration_hours: int = Field(default=24, ge=1)
allow_random_stop: bool = False
image: Optional[str] = None
treasury_address: str


class Challenge(ChallengeBase, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True, index=True)
creator_public_key: str
created_at: datetime = Field(default_factory=datetime.utcnow)
deadline: datetime = Field(default_factory=lambda: datetime.utcnow() + timedelta(hours=24))
status: str = Field(default="active")


class ChallengeCreate(ChallengeBase):
creator_public_key: str


class ChallengeRead(ChallengeBase):
id: UUID
creator_public_key: str
created_at: datetime
deadline: datetime
status: str
bet_count: int = 0
total_bet_amount: float = 0
yes_amount: float = 0
no_amount: float = 0


class BetBase(SQLModel):
challenge_id: UUID = Field(foreign_key="challenge.id")
bettor_public_key: str
amount: float = Field(ge=0)
side: str = Field(regex="^(yes|no)$")
signature: str
destination: str


class Bet(BetBase, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True, index=True)
recorded_at: datetime = Field(default_factory=datetime.utcnow)
status: str = Field(default="confirmed")


class BetCreate(BetBase):
pass
Comment on lines +43 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge Align bet payload with path parameter

BetCreate inherits challenge_id, but the create bet API already takes challenge_id from the URL and the client request body (see CreateBetPayload) does not include this field. FastAPI will reject every bet submission with a 422 validation error before the transaction is recorded, even though the UI sends the SOL transfer first. The DTO should omit challenge_id or the route should not require it in the body so bets can be persisted after sending funds.

Useful? React with 👍 / 👎.



class BetRead(BetBase):
id: UUID
recorded_at: datetime
status: str


class ChallengeDetail(ChallengeRead):
bets: list[BetRead] = []
3 changes: 3 additions & 0 deletions backend/app/routers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import bets, challenges, health

__all__ = ["bets", "challenges", "health"]
56 changes: 56 additions & 0 deletions backend/app/routers/bets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from typing import List
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select

from ..database import get_session
from ..models import Bet, BetCreate, BetRead, Challenge
from ..solana import solana_client

router = APIRouter(prefix="/challenges", tags=["bets"])


@router.post("/{challenge_id}/bets", response_model=BetRead, status_code=201)
def create_bet(challenge_id: UUID, payload: BetCreate, session: Session = Depends(get_session)) -> BetRead:
challenge = session.get(Challenge, challenge_id)
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")

if payload.destination != challenge.treasury_address:
raise HTTPException(status_code=400, detail="Destination does not match challenge treasury")

if payload.amount <= 0:
raise HTTPException(status_code=400, detail="Amount must be positive")

if not solana_client.verify_transfer(
payload.signature,
destination=payload.destination,
min_amount=payload.amount,
):
raise HTTPException(status_code=400, detail="Unable to verify transaction on Solana")

bet = Bet(
challenge_id=challenge_id,
bettor_public_key=payload.bettor_public_key,
amount=payload.amount,
side=payload.side,
signature=payload.signature,
destination=payload.destination,
)

session.add(bet)
session.commit()
session.refresh(bet)

return BetRead.model_validate(bet)


@router.get("/{challenge_id}/bets", response_model=List[BetRead])
def list_bets(challenge_id: UUID, session: Session = Depends(get_session)) -> List[BetRead]:
challenge = session.get(Challenge, challenge_id)
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")

bets = session.exec(select(Bet).where(Bet.challenge_id == challenge_id).order_by(Bet.recorded_at.desc())).all()
return [BetRead.model_validate(bet) for bet in bets]
Loading