-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add backend API and solana challenge flows #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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. |
| 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() |
| 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() | ||
| 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() |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
|
|
||
|
|
||
| class BetRead(BetBase): | ||
| id: UUID | ||
| recorded_at: datetime | ||
| status: str | ||
|
|
||
|
|
||
| class ChallengeDetail(ChallengeRead): | ||
| bets: list[BetRead] = [] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| from . import bets, challenges, health | ||
|
|
||
| __all__ = ["bets", "challenges", "health"] |
| 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] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The database dependency is wrapped in
@contextmanager, so FastAPI receives a_GeneratorContextManagerinstead of aSession. Every route that depends onget_sessioncalls methods like.execon the returned value and will raiseAttributeErrorat runtime, making all challenge/bet endpoints unusable. Remove the decorator and implement the usualyield sessionpattern so the dependency returns a liveSessioninstance.Useful? React with 👍 / 👎.