# HashEx — The Hashtag Exchange A stock-market simulation game where the "stocks" are Mastodon hashtags and prices are driven by real-time post activity. Inspired by the Hollywood Stock Exchange. --- ## Overview Players start with **$2,000** in fake money and use it to buy and sell shares in hashtags. Every day each player earns **research points** which can be spent to unlock new hashtags. Prices are calculated from the number of posts per hour on Mastodon and update automatically in the background. --- ## Tech Stack | Layer | Technology | |---|---| | Framework | Next.js 14 (App Router, TypeScript) | | Database | PostgreSQL via Prisma ORM | | Auth | NextAuth v4 (credentials / JWT sessions) | | Background jobs | BullMQ + Redis | | Styling | Tailwind CSS (dark theme) | | Charts | Recharts | | Data source | Mastodon REST API (configurable instance) | | Deployment | Docker / Portainer (via Gitea Actions CI) | --- ## Project Structure ``` hashex/ ├── prisma/ │ ├── schema.prisma # Full DB schema │ └── seed.ts # Seeds an initial admin user ├── src/ │ ├── app/ │ │ ├── page.tsx # Home: trending + recently traded tags │ │ ├── layout.tsx # Root layout with Navbar │ │ ├── providers.tsx # NextAuth SessionProvider wrapper │ │ ├── globals.css # Tailwind base + dark overrides │ │ ├── auth/ │ │ │ ├── signin/page.tsx # Sign-in form │ │ │ ├── signup/page.tsx # Registration form │ │ │ └── reset-password/page.tsx # Password reset (token from URL) │ │ ├── hashtag/[tag]/ │ │ │ ├── page.tsx # Price chart + trade/research panel │ │ │ ├── TradePanel.tsx # BUY/SELL controls (client component) │ │ │ └── ResearchPanel.tsx # Research prompt (client component) │ │ ├── profile/[username]/ │ │ │ └── page.tsx # Portfolio, positions, trade history │ │ ├── admin/ │ │ │ ├── layout.tsx # Admin guard + nav links │ │ │ ├── page.tsx # Overview stats + leaderboard │ │ │ ├── users/ │ │ │ │ ├── page.tsx # Paginated user table │ │ │ │ └── AdminUserActions.tsx # Edit modal + reset-link generator │ │ │ ├── stocks/ │ │ │ │ ├── page.tsx # Paginated hashtag table │ │ │ │ └── AdminStockActions.tsx # Edit modal + force-refresh button │ │ │ └── queue/ │ │ │ └── page.tsx # Live BullMQ queue monitor │ │ └── api/ │ │ ├── auth/ │ │ │ ├── [...nextauth]/route.ts # NextAuth handler │ │ │ ├── register/route.ts # POST — create account │ │ │ └── reset-password/route.ts # POST — consume token, set password │ │ ├── research/route.ts # POST — spend point, query Mastodon │ │ ├── trade/route.ts # POST — BUY/SELL with validation │ │ ├── user/me/route.ts # GET — current user's balance │ │ └── admin/ │ │ ├── users/[userId]/route.ts # PATCH — edit balance/points │ │ ├── users/[userId]/reset-link/route.ts # POST — generate reset URL │ │ ├── stocks/[hashtagId]/route.ts # PATCH — edit price/status │ │ └── stocks/[hashtagId]/force-update/route.ts # POST — queue job │ ├── components/ │ │ ├── Navbar.tsx # Top nav with search, balance badge, admin icon │ │ ├── PriceChart.tsx # Recharts line chart for price history │ │ └── HashtagCard.tsx # Card with tag, price, % change │ ├── lib/ │ │ ├── prisma.ts # Prisma client singleton │ │ ├── auth.ts # NextAuth authOptions │ │ ├── mastodon.ts # Mastodon API fetch + pagination │ │ ├── queue.ts # BullMQ Queue definitions │ │ ├── pricing.ts # Price formula, trade math, research-point tiers │ │ └── utils.ts # cn(), formatCurrency(), normalizeTag(), etc. │ ├── middleware.ts # NextAuth route protection │ ├── types/ │ │ └── next-auth.d.ts # Session type augmentation │ └── worker/ │ └── index.ts # BullMQ worker process (runs in separate container) ├── .env.example # All required env vars with descriptions ├── Dockerfile # Single image; CMD overridden for worker container ├── prod-compose.yml # Production compose: app + worker + postgres + redis ├── next.config.mjs ├── tailwind.config.ts ├── tsconfig.json └── postcss.config.js ``` --- ## Getting Started (Local) ### Prerequisites - Node.js 20+ - PostgreSQL instance (or `docker run -e POSTGRES_PASSWORD=password -p 5432:5432 postgres:16-alpine`) - Redis instance (or `docker run -p 6379:6379 redis:7-alpine`) ### Setup ```bash # 1. Copy env and fill in your values cp .env.example .env # 2. Install dependencies npm install # 3. Apply the database schema npm run db:push # 4. Seed the admin user (see .env.example for ADMIN_USERNAME / ADMIN_PASSWORD) npm run db:seed # 5. Start the web server npm run dev # 6. In a second terminal, start the background worker npm run worker:dev ``` The app will be at `http://localhost:3000`. The seeded admin credentials default to `admin` / `changeme123` — change them immediately. --- ## Environment Variables All variables are documented in `.env.example`. Key ones: | Variable | Default | Description | |---|---|---| | `DATABASE_URL` | — | PostgreSQL connection string | | `NEXTAUTH_SECRET` | — | Random secret for session signing (`openssl rand -base64 32`) | | `NEXTAUTH_URL` | `http://localhost:3000` | Base URL of your deployment | | `REDIS_URL` | `redis://localhost:6379` | Redis connection string | | `MASTODON_INSTANCE` | `https://mastodon.social` | Mastodon instance base URL | | `MASTODON_ACCESS_TOKEN` | _(optional)_ | Bearer token — increases API rate limits | | `WORKER_RATE_LIMIT_MS` | `2000` | Milliseconds to wait between Mastodon API calls | | `PRICE_UPDATE_INTERVAL_MINUTES` | `60` | How often to sweep all active hashtags | | `MAX_PAGES_PER_HASHTAG` | `5` | Max Mastodon pages fetched per tag (5 × 40 = up to 200 posts) | --- ## Pricing Formula Prices follow a **saturating curve** (Michaelis-Menten) so that viral hashtags don't produce runaway prices: ``` price = max($0.25, round((base × pph) / (1 + k × pph), 2)) ``` `k` is derived from two anchor points: floor price `$0.25` and a target of `$250` at 3,600 PPH (one post per second). | Posts/hr | Price | |---|---| | 1 | ~$0.25 | | 10 | ~$2.48 | | 100 | ~$23.32 | | 1,000 | ~$145 | | 3,600 (one post/sec) | ~$250 | | ∞ (theoretical) | ~$346 (asymptote) | At low activity the curve is approximately linear (≈ $0.25 per post/hr). At high activity it flattens, preventing a single trending hashtag from dwarfing the entire market. **Burst handling:** the worker fetches up to `MAX_PAGES_PER_HASHTAG` pages of Mastodon results and uses only posts within the most recent hour when calculating PPH. If the fetched results are exhausted before covering a full hour, PPH is extrapolated from the covered window. --- ## Research System - Every player earns **1 research point per day** (awarded at midnight EST by the maintenance worker). - Balance milestones unlock extra daily points: | Balance | Daily points | |---|---| | < $10,000 | 1 | | $10,000+ | 2 | | $100,000+ | 3 | | $1,000,000+ | 5 | - Spending a research point queries Mastodon for that hashtag: - **No results found** → point is spent, hashtag not added. - **Results found** → hashtag is created, price set, job queued for immediate update. - If no player holds a position in a hashtag and it returns zero results for **3 consecutive update cycles**, it is automatically **deactivated**. A player must research it again to reactivate it. --- ## Trading Four trade types are supported: | Type | Description | |---|---| | `BUY_LONG` | Buy shares betting price goes up | | `SELL_LONG` | Close or reduce a long position | | `BUY_SHORT` | Place collateral betting price goes down | | `SELL_SHORT` | Close a short, return collateral adjusted for price movement | **Safeguards enforced server-side:** - Cannot spend more than current balance on a buy. - Cannot sell more shares than you hold in a position. - Trades are executed in a database transaction (balance + position + trade record atomically). --- ## Background Worker The worker runs as a **separate Docker container** using the same image with a different `CMD`. Three BullMQ queues: | Queue | Purpose | |---|---| | `hashex-price-updates` | One job per active hashtag; fetches Mastodon and updates price + price history. Concurrency = 1 to respect rate limits. | | `hashex-scheduler` | Fires every `PRICE_UPDATE_INTERVAL_MINUTES`. Enqueues price-update jobs ordered by `lastUpdated ASC` (most stale first). Deduplicates by `jobId` to avoid pile-up. | | `hashex-maintenance` | Runs daily at midnight EST. Awards research points based on each player's balance. | The worker retries failed jobs up to 3 times with exponential back-off (5 s base delay). --- ## Admin Dashboard (`/admin`) Accessible only to users with `isAdmin = true`. ### Overview - Platform stats (users, active/inactive hashtags, trade count) - Top 10 players by balance - Recent 10 trades across the platform ### Users (`/admin/users`) - Searchable, paginated user list - **Edit modal** per user: adjust balance and research points - **Generate reset link**: creates a one-time password reset URL (2-hour expiry). No email is sent — the admin copies and shares the link with the user. ### Stocks (`/admin/stocks`) - Searchable, filterable (active/inactive), paginated hashtag list - **Edit modal** per hashtag: override price, toggle active/inactive - **Force price refresh**: adds a high-priority job to the update queue immediately ### Queue Monitor (`/admin/queue`) - Live view of all three BullMQ queues - Shows waiting, active, delayed, completed, and failed counts - Lists active, waiting, and recent failed jobs with payload and failure reason - **Retry failed** button per queue to requeue all failed jobs ### Hedge Funds (`/admin/funds`) - Create a named hedge fund with an initial starting balance - Add or remove managers (by username) per fund - Delete a fund (removes all its positions and trade history) --- ## Hedge Funds Multiple players can collaborate via a **Hedge Fund** — a shared pool of capital with its own balance, positions, and trade history. ### How it works 1. An admin creates a fund at `/admin/funds` (providing a name and initial balance). 2. The admin adds one or more players as **managers** of the fund. 3. A manager visits `/fund/[slug]` to see the fund's portfolio. 4. From there they can click any held position (or browse all stocks) — the link includes `?fund=[slug]`, putting the hashtag trade page in **Fund Mode**. 5. In Fund Mode a banner confirms which fund the trade will be on behalf of. All buys and sells deduct from / credit to the fund's balance, not the manager's. 6. The manager can return to their `/profile` at any time to trade under their own account. ### Fund page (`/fund/[slug]`) - Public — anyone can view the fund name, balance, positions, managers, and trade history. - Managers see a management panel with quick links to trade each held position in Fund Mode. ### Key rules - A fund account cannot sign in directly — it is a shadow account controlled via the manager interface. - A user can manage multiple funds simultaneously. - Funds do not earn research points or play the daily lottery. --- ## Password Reset Flow Since there is no email system, password resets are handled manually by an admin: 1. Admin navigates to `/admin/users`, finds the user, opens the edit modal. 2. Clicks **"Generate reset link"**. 3. Copies the displayed URL and sends it to the user via Discord/chat/etc. 4. User visits the URL at `/auth/reset-password?token=`, sets a new password. 5. The token is marked used and expires after 2 hours. A new link invalidates any previous unused token for that user. --- ## Deployment The CI pipeline is defined in `.gitea/workflows/rebuild-prod.yaml`. On push to `main` it: 1. Decodes the `PROD_ENV` secret (base64-encoded `.env`) and writes `.env`. 2. Builds the Docker image and pushes to the registry. 3. Fetches the existing Portainer stack, preserves its env vars, and redeploys with the new image. The `prod-compose.yml` defines four services: | Service | Notes | |---|---| | `app` | Web server on port 3000. Runs `prisma db push` before starting. | | `worker` | Same image, `CMD: npm run worker:prod`. Handles all background jobs. | | `postgres` | Postgres 16 Alpine with a named volume. Health-checked. | | `redis` | Redis 7 Alpine with AOF persistence. | ### Creating the `PROD_ENV` secret ```bash # Generate a base64-encoded copy of your production .env file: base64 -w 0 .env # Paste the result as the PROD_ENV secret in Gitea. ``` ### Generating `NEXTAUTH_SECRET` ```bash openssl rand -base64 32 ``` --- ## Database Schema Summary | Model | Key fields | |---|---| | `User` | `username`, `passwordHash`, `balance`, `researchPoints`, `isAdmin` | | `PasswordReset` | `token`, `userId`, `expiresAt`, `used` | | `Hashtag` | `tag` (lowercase), `displayTag`, `currentPrice`, `isActive`, `zeroCount`, `lastUpdated` | | `PriceHistory` | `hashtagId`, `price`, `postsPerHour`, `recordedAt` | | `Position` | `userId`, `hashtagId`, `positionType` (LONG/SHORT), `shares`, `avgBuyPrice` | | `Trade` | `userId`, `hashtagId`, `type`, `shares`, `price`, `total`, `profit` | --- ## Useful Scripts ```bash npm run dev # Next.js dev server (hot reload) npm run build # Production build npm run start # Start production web server npm run worker:dev # Worker with hot reload (tsx watch) npm run worker:prod # Worker for production npm run db:push # Apply schema changes without migrations npm run db:seed # Seed the admin user npm run db:studio # Open Prisma Studio (GUI) ``` --- ## Feature Roadmap The items below are planned improvements roughly ordered by user value. They are good candidates for the next development phase. --- ### Other Ideas / Nice-to-Haves - **Email integration**: SMTP-based password reset and optional trade confirmation emails. - **Multi-instance support**: fallback to another instance if the primary instance is unavailable or throttles API calls. - **Mobile-optimised trade panel**: the current layout works but a dedicated bottom-sheet on mobile would improve UX. - **Price alerts**: users subscribe to a hashtag at a threshold price; a notification appears in the UI (or email if integrated) when it crosses that level. - **Dark/light theme toggle**: currently dark-only.