Initial commit: Lightning Lottery - Bitcoin Lightning Network powered lottery
Features: - Lightning Network payments via LNbits integration - Provably fair draws using CSPRNG - Random ticket number generation - Automatic payouts with retry/redraw logic - Nostr authentication (NIP-07) - Multiple draw cycles (hourly, daily, weekly, monthly) - PostgreSQL and SQLite database support - Real-time countdown and payment animations - Swagger API documentation - Docker support Stack: - Backend: Node.js, TypeScript, Express - Frontend: Next.js, React, TailwindCSS, Redux - Payments: LNbits
This commit is contained in:
90
.gitignore
vendored
Normal file
90
.gitignore
vendored
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp/
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
back_end/data/
|
||||||
|
|
||||||
|
# App info / documentation (internal)
|
||||||
|
App_info/
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
.npm
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Yarn
|
||||||
|
.yarn-integrity
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
|
||||||
|
# Secrets and keys (extra safety)
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
secrets/
|
||||||
|
|
||||||
318
README.md
Normal file
318
README.md
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
# ⚡ Lightning Lottery
|
||||||
|
|
||||||
|
A complete Bitcoin Lightning Network powered lottery system with instant payouts to Lightning Addresses.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ⚡ **Lightning Fast**: Instant ticket purchases and payouts via Lightning Network
|
||||||
|
- 🎲 **Provably Fair**: Cryptographically secure random number generation (CSPRNG)
|
||||||
|
- 🔐 **Secure**: Transaction-based ticket issuance, idempotent operations
|
||||||
|
- 🌐 **Anonymous or Nostr**: Buy tickets anonymously or login with Nostr
|
||||||
|
- 📱 **Mobile First**: Beautiful responsive design optimized for all devices
|
||||||
|
- 🏆 **Automatic Payouts**: Winners get paid automatically to their Lightning Address
|
||||||
|
- ⏰ **Multiple Cycles**: Support for hourly, daily, weekly, and monthly draws
|
||||||
|
- 🔄 **Auto-Redraw**: If payout fails, automatically selects a new winner
|
||||||
|
- 🎰 **Random Tickets**: Ticket numbers are randomly generated, not sequential
|
||||||
|
- 📊 **Swagger Docs**: Full API documentation at `/api-docs`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The system consists of three main components:
|
||||||
|
|
||||||
|
1. **Backend API** (Node.js + TypeScript + Express)
|
||||||
|
2. **Frontend** (Next.js + React + TypeScript + TailwindCSS)
|
||||||
|
3. **Database** (PostgreSQL or SQLite)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- RESTful API with comprehensive endpoints
|
||||||
|
- LNbits integration for Lightning payments
|
||||||
|
- Automated scheduler for draws and cycle generation
|
||||||
|
- JWT authentication for Nostr users
|
||||||
|
- Admin API for manual operations
|
||||||
|
- Payment monitoring with polling fallback
|
||||||
|
- Webhook support for instant payment confirmation
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- Server-side rendered Next.js application
|
||||||
|
- Redux state management
|
||||||
|
- Real-time countdown timers
|
||||||
|
- Invoice QR code display with payment animations
|
||||||
|
- Automatic status polling
|
||||||
|
- Nostr NIP-07 authentication
|
||||||
|
- Draw animation with winner reveal
|
||||||
|
- Past winners display
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Using Docker Compose (Recommended)
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd LightningLotto
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configure environment:
|
||||||
|
```bash
|
||||||
|
cp back_end/env.example back_end/.env
|
||||||
|
cp front_end/env.example front_end/.env.local
|
||||||
|
# Edit both files with your configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start services:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Access the application:
|
||||||
|
- Frontend: http://localhost:3001
|
||||||
|
- Backend API: http://localhost:3000
|
||||||
|
- API Docs: http://localhost:3000/api-docs
|
||||||
|
- Health check: http://localhost:3000/health
|
||||||
|
|
||||||
|
### Manual Setup
|
||||||
|
|
||||||
|
#### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd back_end
|
||||||
|
npm install
|
||||||
|
cp env.example .env
|
||||||
|
# Edit .env with your configuration
|
||||||
|
|
||||||
|
# For PostgreSQL:
|
||||||
|
createdb lightning_lotto
|
||||||
|
psql lightning_lotto < src/database/schema.sql
|
||||||
|
|
||||||
|
# For SQLite (default, no setup needed):
|
||||||
|
# Database file created automatically at data/lightning_lotto.db
|
||||||
|
|
||||||
|
# Run development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Or build and run production
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd front_end
|
||||||
|
npm install
|
||||||
|
cp env.example .env.local
|
||||||
|
# Edit .env.local
|
||||||
|
|
||||||
|
# Run development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Or build and run production
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Required Environment Variables
|
||||||
|
|
||||||
|
#### Backend (.env)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database (PostgreSQL or SQLite)
|
||||||
|
DATABASE_URL=postgresql://user:pass@localhost:5432/lightning_lotto
|
||||||
|
# Or for SQLite (leave DATABASE_URL empty or use):
|
||||||
|
USE_SQLITE=true
|
||||||
|
|
||||||
|
# LNbits Configuration
|
||||||
|
LNBITS_API_BASE_URL=https://your-lnbits-instance.com
|
||||||
|
LNBITS_ADMIN_KEY=your-lnbits-admin-key
|
||||||
|
LNBITS_WEBHOOK_SECRET=your-webhook-secret
|
||||||
|
|
||||||
|
# Security
|
||||||
|
JWT_SECRET=your-jwt-secret-min-32-chars
|
||||||
|
ADMIN_API_KEY=your-admin-api-key
|
||||||
|
|
||||||
|
# Lottery Settings
|
||||||
|
DEFAULT_TICKET_PRICE_SATS=1000
|
||||||
|
DEFAULT_HOUSE_FEE_PERCENT=5
|
||||||
|
PAYOUT_MAX_ATTEMPTS_BEFORE_REDRAW=2
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend (.env.local)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
See `back_end/env.example` and `front_end/env.example` for all configuration options.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Public Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/jackpot/next` | Get next upcoming draw |
|
||||||
|
| POST | `/jackpot/buy` | Purchase tickets |
|
||||||
|
| GET | `/jackpot/past-wins` | List past winners |
|
||||||
|
| GET | `/tickets/:id` | Check ticket status |
|
||||||
|
|
||||||
|
### User Endpoints (Nostr Auth)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/auth/nostr` | Authenticate with Nostr |
|
||||||
|
| GET | `/me` | Get user profile |
|
||||||
|
| PATCH | `/me/lightning-address` | Update Lightning address |
|
||||||
|
| GET | `/me/tickets` | User's ticket history |
|
||||||
|
| GET | `/me/wins` | User's win history |
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/admin/cycles` | List all cycles |
|
||||||
|
| POST | `/admin/cycles/:id/run-draw` | Manually trigger draw |
|
||||||
|
| GET | `/admin/payouts` | List payouts |
|
||||||
|
| POST | `/admin/payouts/:id/retry` | Retry failed payout |
|
||||||
|
|
||||||
|
### Webhooks
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/webhooks/lnbits/payment` | LNbits payment callback |
|
||||||
|
|
||||||
|
Full API documentation available at `/api-docs` (Swagger UI).
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
7 main tables:
|
||||||
|
|
||||||
|
- `lotteries` - Lottery configuration
|
||||||
|
- `jackpot_cycles` - Draw cycles with status and winners
|
||||||
|
- `ticket_purchases` - Purchase records with Lightning invoice info
|
||||||
|
- `tickets` - Individual tickets with random serial numbers
|
||||||
|
- `payouts` - Winner payouts with retry logic
|
||||||
|
- `users` - Nostr user accounts (optional)
|
||||||
|
- `draw_logs` - Audit trail for transparency
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Cycle Generation**: Scheduler automatically creates future draw cycles
|
||||||
|
2. **Ticket Purchase**: Users buy tickets, receive Lightning invoice
|
||||||
|
3. **Payment Processing**: LNbits webhook or polling confirms payment
|
||||||
|
4. **Ticket Issuance**: Random ticket numbers assigned in database transaction
|
||||||
|
5. **Draw Execution**: At scheduled time, winner selected using CSPRNG
|
||||||
|
6. **Payout**: Winner's Lightning Address paid automatically
|
||||||
|
7. **Retry/Redraw**: Failed payouts retried; new winner drawn after max attempts
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
- JWT tokens for user authentication
|
||||||
|
- Admin API key protection
|
||||||
|
- Webhook signature verification
|
||||||
|
- Rate limiting on all endpoints
|
||||||
|
- Idempotent payment processing
|
||||||
|
- Database transactions for atomic operations
|
||||||
|
- `crypto.randomBytes()` for winner selection
|
||||||
|
- `crypto.randomInt()` for ticket number generation
|
||||||
|
- No floating-point math (BIGINT for all sats)
|
||||||
|
|
||||||
|
## Frontend Pages
|
||||||
|
|
||||||
|
| Page | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `/` | Home page with jackpot countdown and winner display |
|
||||||
|
| `/buy` | Purchase tickets with Lightning invoice |
|
||||||
|
| `/tickets/:id` | View ticket status and draw results |
|
||||||
|
| `/past-wins` | Public list of past winners |
|
||||||
|
| `/about` | About page with how it works |
|
||||||
|
| `/dashboard` | User dashboard (Nostr login required) |
|
||||||
|
| `/dashboard/tickets` | User's ticket history |
|
||||||
|
| `/dashboard/wins` | User's win history |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Backend Tests
|
||||||
|
```bash
|
||||||
|
cd back_end
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Tests
|
||||||
|
```bash
|
||||||
|
cd front_end
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Production Considerations
|
||||||
|
|
||||||
|
1. Use strong secrets for `JWT_SECRET` and `ADMIN_API_KEY`
|
||||||
|
2. Configure proper CORS origins in backend
|
||||||
|
3. Use SSL/TLS (HTTPS) for all connections
|
||||||
|
4. Set up monitoring and logging
|
||||||
|
5. Configure database backups
|
||||||
|
6. Set up proper firewall rules
|
||||||
|
7. Consider using a CDN for frontend
|
||||||
|
8. Use PostgreSQL for production (SQLite for development)
|
||||||
|
|
||||||
|
### Scaling
|
||||||
|
|
||||||
|
- Backend is stateless and can be horizontally scaled
|
||||||
|
- Use connection pooling for database
|
||||||
|
- Consider Redis for caching
|
||||||
|
- Use message queue for high-volume webhooks
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Health check endpoint:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns database and LNbits connectivity status.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
LightningLotto/
|
||||||
|
├── back_end/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── config/ # Configuration and Swagger setup
|
||||||
|
│ │ ├── controllers/ # Route handlers
|
||||||
|
│ │ ├── database/ # Database wrapper and schema
|
||||||
|
│ │ ├── middleware/ # Auth and rate limiting
|
||||||
|
│ │ ├── routes/ # API routes
|
||||||
|
│ │ ├── scheduler/ # Automated jobs
|
||||||
|
│ │ ├── services/ # Business logic
|
||||||
|
│ │ ├── types/ # TypeScript types
|
||||||
|
│ │ └── utils/ # Validation helpers
|
||||||
|
│ └── data/ # SQLite database (if used)
|
||||||
|
├── front_end/
|
||||||
|
│ └── src/
|
||||||
|
│ ├── app/ # Next.js pages
|
||||||
|
│ ├── components/ # React components
|
||||||
|
│ ├── config/ # Frontend config
|
||||||
|
│ ├── constants/ # Text strings
|
||||||
|
│ ├── lib/ # API client and utilities
|
||||||
|
│ └── store/ # Redux state
|
||||||
|
└── docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see LICENSE file for details
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- Built with [LNbits](https://lnbits.com/) for Lightning Network integration
|
||||||
|
- Uses [Nostr](https://nostr.com/) for decentralized authentication
|
||||||
|
- Inspired by the Bitcoin Lightning Network community
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
⚡ **Win Bitcoin on the Lightning Network!** ⚡
|
||||||
11
back_end/.gitignore
vendored
Normal file
11
back_end/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
|
||||||
|
# SQLite database
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
24
back_end/Dockerfile
Normal file
24
back_end/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
|
|
||||||
142
back_end/README.md
Normal file
142
back_end/README.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Lightning Lottery Backend API
|
||||||
|
|
||||||
|
Bitcoin Lightning Network powered lottery system with automatic payouts to Lightning Addresses.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Public Ticket Purchasing**: Anonymous ticket purchases via Lightning invoices
|
||||||
|
- **Automated Draws**: Scheduled draws using cryptographically secure RNG
|
||||||
|
- **Instant Payouts**: Automatic payouts to Lightning Addresses
|
||||||
|
- **Nostr Authentication**: Optional user accounts via Nostr signatures
|
||||||
|
- **Multiple Cycle Types**: Hourly, daily, weekly, and monthly jackpots
|
||||||
|
- **Admin Interface**: Manual draw triggers and payout management
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: Node.js with TypeScript
|
||||||
|
- **Framework**: Express.js
|
||||||
|
- **Database**: PostgreSQL
|
||||||
|
- **Lightning**: LNbits integration
|
||||||
|
- **Scheduler**: node-cron
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- PostgreSQL 14+
|
||||||
|
- LNbits instance
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configure environment variables:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Setup database:
|
||||||
|
```bash
|
||||||
|
# Create database
|
||||||
|
createdb lightning_lotto
|
||||||
|
|
||||||
|
# Run schema
|
||||||
|
psql lightning_lotto < src/database/schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scheduler (Optional Separate Process)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Public Endpoints
|
||||||
|
|
||||||
|
- `GET /jackpot/next` - Get next upcoming cycle
|
||||||
|
- `POST /jackpot/buy` - Create ticket purchase
|
||||||
|
- `GET /tickets/:id` - Get ticket status
|
||||||
|
|
||||||
|
### User Endpoints (Nostr Auth)
|
||||||
|
|
||||||
|
- `POST /auth/nostr` - Authenticate with Nostr
|
||||||
|
- `GET /me` - Get user profile
|
||||||
|
- `PATCH /me/lightning-address` - Update Lightning Address
|
||||||
|
- `GET /me/tickets` - Get user's tickets
|
||||||
|
- `GET /me/wins` - Get user's wins
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
|
||||||
|
- `GET /admin/cycles` - List all cycles
|
||||||
|
- `POST /admin/cycles/:id/run-draw` - Manually run draw
|
||||||
|
- `GET /admin/payouts` - List payouts
|
||||||
|
- `POST /admin/payouts/:id/retry` - Retry failed payout
|
||||||
|
|
||||||
|
### Webhook Endpoints
|
||||||
|
|
||||||
|
- `POST /webhooks/lnbits/payment` - LNbits payment notification
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
See `.env.example` for all configuration options.
|
||||||
|
|
||||||
|
### Required Environment Variables
|
||||||
|
|
||||||
|
- `DATABASE_URL` - PostgreSQL connection string
|
||||||
|
- `LNBITS_API_BASE_URL` - LNbits API URL
|
||||||
|
- `LNBITS_ADMIN_KEY` - LNbits admin/invoice key
|
||||||
|
- `JWT_SECRET` - Secret for JWT signing
|
||||||
|
- `ADMIN_API_KEY` - Admin endpoint authentication
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
The system uses 7 main tables:
|
||||||
|
|
||||||
|
- `lotteries` - Lottery configuration
|
||||||
|
- `jackpot_cycles` - Draw cycles
|
||||||
|
- `ticket_purchases` - Purchase records
|
||||||
|
- `tickets` - Individual tickets
|
||||||
|
- `payouts` - Winner payouts
|
||||||
|
- `users` - Nostr user accounts
|
||||||
|
- `draw_logs` - Audit trail
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- JWT tokens for user authentication
|
||||||
|
- Admin API key for admin endpoints
|
||||||
|
- Webhook signature verification
|
||||||
|
- Rate limiting on all endpoints
|
||||||
|
- Idempotent payment processing
|
||||||
|
- Transaction-based ticket issuance
|
||||||
|
|
||||||
|
## Scheduler Tasks
|
||||||
|
|
||||||
|
The scheduler runs three main tasks:
|
||||||
|
|
||||||
|
1. **Cycle Generator**: Creates future cycles (every 5 minutes)
|
||||||
|
2. **Draw Executor**: Triggers draws at scheduled times (every minute)
|
||||||
|
3. **Payout Retry**: Retries failed payouts with exponential backoff (every 10 minutes)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
84
back_end/env.example
Normal file
84
back_end/env.example
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Lightning Lottery Backend - Environment Configuration
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# Server Configuration
|
||||||
|
# ======================
|
||||||
|
PORT=3000
|
||||||
|
APP_BASE_URL=http://localhost:3000
|
||||||
|
FRONTEND_BASE_URL=http://localhost:3001
|
||||||
|
# Optional: comma-separated list to override allowed origins
|
||||||
|
# CORS_ALLOWED_ORIGINS=http://localhost:3001,https://app.yourdomain.com
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# Database Configuration
|
||||||
|
# ======================
|
||||||
|
# Database type: "postgres" or "sqlite"
|
||||||
|
DATABASE_TYPE=sqlite
|
||||||
|
|
||||||
|
# Database connection
|
||||||
|
# For PostgreSQL: postgresql://username:password@host:port/database
|
||||||
|
# For SQLite: ./data/lightning_lotto.db (relative path)
|
||||||
|
DATABASE_URL=./data/lightning_lotto.db
|
||||||
|
|
||||||
|
# PostgreSQL example:
|
||||||
|
# DATABASE_TYPE=postgres
|
||||||
|
# DATABASE_URL=postgresql://user:password@localhost:5432/lightning_lotto
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# LNbits Configuration
|
||||||
|
# ======================
|
||||||
|
# Your LNbits instance URL
|
||||||
|
LNBITS_API_BASE_URL=https://legend.lnbits.com
|
||||||
|
|
||||||
|
# LNbits admin/invoice key (required for creating invoices and payouts)
|
||||||
|
# Get this from your LNbits wallet settings
|
||||||
|
LNBITS_ADMIN_KEY=your_lnbits_admin_key_here
|
||||||
|
|
||||||
|
# Webhook secret for validating LNbits payment notifications
|
||||||
|
# Choose a random secret string
|
||||||
|
LNBITS_WEBHOOK_SECRET=your_webhook_secret_here
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# Security Configuration
|
||||||
|
# ======================
|
||||||
|
# Secret for signing JWT tokens (use a strong random string)
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
JWT_SECRET=your_jwt_secret_here_change_in_production
|
||||||
|
|
||||||
|
# Admin API key for admin endpoints
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
ADMIN_API_KEY=your_admin_api_key_here_change_in_production
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# Scheduler Configuration
|
||||||
|
# ======================
|
||||||
|
# How often to check for draws that need to be executed (in seconds)
|
||||||
|
DRAW_SCHEDULER_INTERVAL_SECONDS=60
|
||||||
|
|
||||||
|
# How often to generate future cycles (in seconds)
|
||||||
|
CYCLE_GENERATOR_INTERVAL_SECONDS=300
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# Lottery Configuration
|
||||||
|
# ======================
|
||||||
|
# Default ticket price in satoshis
|
||||||
|
DEFAULT_TICKET_PRICE_SATS=1000
|
||||||
|
|
||||||
|
# House fee percentage (0-100)
|
||||||
|
# Example: 5 means 5% fee, winner gets 95% of pot
|
||||||
|
DEFAULT_HOUSE_FEE_PERCENT=5
|
||||||
|
|
||||||
|
# Maximum Lightning payout attempts before drawing a new winner
|
||||||
|
PAYOUT_MAX_ATTEMPTS=2
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# Notes
|
||||||
|
# ======================
|
||||||
|
# - SQLite is perfect for development and testing (no setup required!)
|
||||||
|
# - Use PostgreSQL for production deployments
|
||||||
|
# - Never commit the actual .env file to version control
|
||||||
|
# - Use strong, unique secrets in production
|
||||||
|
# - Keep your LNbits keys secure
|
||||||
|
# - Test with small amounts first
|
||||||
3115
back_end/package-lock.json
generated
Normal file
3115
back_end/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
back_end/package.json
Normal file
47
back_end/package.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "lightning-lotto-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Lightning Lottery Backend API",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"migrate": "ts-node src/database/migrate.ts",
|
||||||
|
"scheduler": "ts-node src/scheduler/index.ts"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"bitcoin",
|
||||||
|
"lightning",
|
||||||
|
"lottery"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"better-sqlite3": "^12.4.6",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"node-cron": "^3.0.2",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"swagger-jsdoc": "^6.2.8",
|
||||||
|
"swagger-ui-express": "^5.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/node": "^20.10.5",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/pg": "^8.10.9",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
100
back_end/src/app.ts
Normal file
100
back_end/src/app.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import swaggerUi from 'swagger-ui-express';
|
||||||
|
import config from './config';
|
||||||
|
import { swaggerSpec } from './config/swagger';
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
import publicRoutes from './routes/public';
|
||||||
|
import webhookRoutes from './routes/webhooks';
|
||||||
|
import userRoutes from './routes/user';
|
||||||
|
import adminRoutes from './routes/admin';
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
import { generalRateLimiter } from './middleware/rateLimit';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Trust proxy for rate limiting (needed when behind reverse proxy)
|
||||||
|
if (config.app.nodeEnv === 'production') {
|
||||||
|
app.set('trust proxy', 1); // Trust first proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORS configuration
|
||||||
|
app.use(cors({
|
||||||
|
origin: config.app.nodeEnv === 'production'
|
||||||
|
? config.cors.allowedOrigins
|
||||||
|
: true,
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Body parser
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// General rate limiting
|
||||||
|
app.use(generalRateLimiter);
|
||||||
|
|
||||||
|
// Swagger API Documentation
|
||||||
|
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
|
||||||
|
customCss: '.swagger-ui .topbar { display: none }',
|
||||||
|
customSiteTitle: 'Lightning Lottery API Docs',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Swagger JSON endpoint
|
||||||
|
app.get('/api-docs.json', (req: Request, res: Response) => {
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.send(swaggerSpec);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', async (req: Request, res: Response) => {
|
||||||
|
const { db } = await import('./database');
|
||||||
|
const { lnbitsService } = await import('./services/lnbits');
|
||||||
|
|
||||||
|
const dbHealth = await db.healthCheck();
|
||||||
|
const lnbitsHealth = await lnbitsService.healthCheck();
|
||||||
|
|
||||||
|
const healthy = dbHealth && lnbitsHealth;
|
||||||
|
|
||||||
|
res.status(healthy ? 200 : 503).json({
|
||||||
|
version: '1.0',
|
||||||
|
status: healthy ? 'healthy' : 'unhealthy',
|
||||||
|
checks: {
|
||||||
|
database: dbHealth ? 'ok' : 'failed',
|
||||||
|
lnbits: lnbitsHealth ? 'ok' : 'failed',
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use('/', publicRoutes);
|
||||||
|
app.use('/webhooks', webhookRoutes);
|
||||||
|
app.use('/', userRoutes);
|
||||||
|
app.use('/admin', adminRoutes);
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use((req: Request, res: Response) => {
|
||||||
|
res.status(404).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'NOT_FOUND',
|
||||||
|
message: 'Endpoint not found',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||||
|
console.error('Unhandled error:', err);
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: config.app.nodeEnv === 'production'
|
||||||
|
? 'An internal error occurred'
|
||||||
|
: err.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
|
||||||
159
back_end/src/config/index.ts
Normal file
159
back_end/src/config/index.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const parseOriginsList = (value?: string): string[] => {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map(origin => origin.trim())
|
||||||
|
.filter(origin => origin.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const appPort = parseInt(process.env.PORT || process.env.APP_PORT || '3000', 10);
|
||||||
|
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
||||||
|
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||||
|
|
||||||
|
const frontendOrigins = (() => {
|
||||||
|
const candidates = [
|
||||||
|
process.env.FRONTEND_BASE_URL,
|
||||||
|
process.env.NEXT_PUBLIC_APP_BASE_URL,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const parsed = parseOriginsList(candidate);
|
||||||
|
if (parsed.length > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['http://localhost:3001'];
|
||||||
|
})();
|
||||||
|
|
||||||
|
const allowedCorsOrigins = (() => {
|
||||||
|
const explicit = parseOriginsList(process.env.CORS_ALLOWED_ORIGINS);
|
||||||
|
if (explicit.length > 0) {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Set([...frontendOrigins, appBaseUrl]));
|
||||||
|
})();
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
app: {
|
||||||
|
port: number;
|
||||||
|
baseUrl: string;
|
||||||
|
nodeEnv: string;
|
||||||
|
};
|
||||||
|
frontend: {
|
||||||
|
origins: string[];
|
||||||
|
};
|
||||||
|
cors: {
|
||||||
|
allowedOrigins: string[];
|
||||||
|
};
|
||||||
|
database: {
|
||||||
|
type: 'postgres' | 'sqlite';
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
lnbits: {
|
||||||
|
apiBaseUrl: string;
|
||||||
|
adminKey: string;
|
||||||
|
webhookSecret: string;
|
||||||
|
};
|
||||||
|
jwt: {
|
||||||
|
secret: string;
|
||||||
|
};
|
||||||
|
scheduler: {
|
||||||
|
drawIntervalSeconds: number;
|
||||||
|
cycleGeneratorIntervalSeconds: number;
|
||||||
|
};
|
||||||
|
lottery: {
|
||||||
|
defaultTicketPriceSats: number;
|
||||||
|
defaultHouseFeePercent: number;
|
||||||
|
maxTicketsPerPurchase: number;
|
||||||
|
};
|
||||||
|
admin: {
|
||||||
|
apiKey: string;
|
||||||
|
};
|
||||||
|
payout: {
|
||||||
|
maxAttemptsBeforeRedraw: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
app: {
|
||||||
|
port: appPort,
|
||||||
|
baseUrl: appBaseUrl,
|
||||||
|
nodeEnv,
|
||||||
|
},
|
||||||
|
frontend: {
|
||||||
|
origins: frontendOrigins,
|
||||||
|
},
|
||||||
|
cors: {
|
||||||
|
allowedOrigins: allowedCorsOrigins,
|
||||||
|
},
|
||||||
|
database: {
|
||||||
|
type: (process.env.DATABASE_TYPE || 'postgres') as 'postgres' | 'sqlite',
|
||||||
|
url: process.env.DATABASE_URL ||
|
||||||
|
(process.env.DATABASE_TYPE === 'sqlite'
|
||||||
|
? './data/lightning_lotto.db'
|
||||||
|
: 'postgresql://localhost:5432/lightning_lotto'),
|
||||||
|
},
|
||||||
|
lnbits: {
|
||||||
|
apiBaseUrl: process.env.LNBITS_API_BASE_URL || '',
|
||||||
|
adminKey: process.env.LNBITS_ADMIN_KEY || '',
|
||||||
|
webhookSecret: process.env.LNBITS_WEBHOOK_SECRET || '',
|
||||||
|
},
|
||||||
|
jwt: {
|
||||||
|
secret: process.env.JWT_SECRET || 'your-secret-key-change-this',
|
||||||
|
},
|
||||||
|
scheduler: {
|
||||||
|
drawIntervalSeconds: parseInt(process.env.DRAW_SCHEDULER_INTERVAL_SECONDS || '60', 10),
|
||||||
|
cycleGeneratorIntervalSeconds: parseInt(process.env.CYCLE_GENERATOR_INTERVAL_SECONDS || '300', 10),
|
||||||
|
},
|
||||||
|
lottery: {
|
||||||
|
defaultTicketPriceSats: parseInt(process.env.DEFAULT_TICKET_PRICE_SATS || '1000', 10),
|
||||||
|
defaultHouseFeePercent: parseInt(process.env.DEFAULT_HOUSE_FEE_PERCENT || '5', 10),
|
||||||
|
maxTicketsPerPurchase: 100,
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
apiKey: process.env.ADMIN_API_KEY || '',
|
||||||
|
},
|
||||||
|
payout: {
|
||||||
|
maxAttemptsBeforeRedraw: parseInt(process.env.PAYOUT_MAX_ATTEMPTS || '2', 10),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate critical environment variables
|
||||||
|
function validateConfig(): void {
|
||||||
|
const requiredVars = [
|
||||||
|
{ key: 'LNBITS_API_BASE_URL', value: config.lnbits.apiBaseUrl },
|
||||||
|
{ key: 'LNBITS_ADMIN_KEY', value: config.lnbits.adminKey },
|
||||||
|
{ key: 'JWT_SECRET', value: config.jwt.secret },
|
||||||
|
{ key: 'ADMIN_API_KEY', value: config.admin.apiKey },
|
||||||
|
];
|
||||||
|
|
||||||
|
const missing = requiredVars.filter(v => !v.value || v.value === '');
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error('❌ Missing required environment variables:');
|
||||||
|
missing.forEach(v => console.error(` - ${v.key}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate database type
|
||||||
|
if (!['postgres', 'sqlite'].includes(config.database.type)) {
|
||||||
|
console.error('❌ DATABASE_TYPE must be either "postgres" or "sqlite"');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.app.nodeEnv !== 'test') {
|
||||||
|
validateConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
||||||
118
back_end/src/config/swagger.ts
Normal file
118
back_end/src/config/swagger.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import swaggerJsdoc from 'swagger-jsdoc';
|
||||||
|
import config from './index';
|
||||||
|
|
||||||
|
const swaggerDefinition = {
|
||||||
|
openapi: '3.0.0',
|
||||||
|
info: {
|
||||||
|
title: 'Lightning Lottery API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Bitcoin Lightning Network powered lottery system with instant payouts',
|
||||||
|
contact: {
|
||||||
|
name: 'Lightning Lottery',
|
||||||
|
url: config.app.baseUrl,
|
||||||
|
},
|
||||||
|
license: {
|
||||||
|
name: 'MIT',
|
||||||
|
url: 'https://opensource.org/licenses/MIT',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: config.app.baseUrl,
|
||||||
|
description: config.app.nodeEnv === 'production' ? 'Production server' : 'Development server',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
bearerAuth: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
description: 'JWT token for Nostr authenticated users',
|
||||||
|
},
|
||||||
|
adminKey: {
|
||||||
|
type: 'apiKey',
|
||||||
|
in: 'header',
|
||||||
|
name: 'X-Admin-Key',
|
||||||
|
description: 'Admin API key for administrative endpoints',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schemas: {
|
||||||
|
Lottery: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', format: 'uuid' },
|
||||||
|
name: { type: 'string' },
|
||||||
|
ticket_price_sats: { type: 'integer' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
JackpotCycle: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', format: 'uuid' },
|
||||||
|
cycle_type: { type: 'string', enum: ['hourly', 'daily', 'weekly', 'monthly'] },
|
||||||
|
scheduled_at: { type: 'string', format: 'date-time' },
|
||||||
|
sales_open_at: { type: 'string', format: 'date-time' },
|
||||||
|
sales_close_at: { type: 'string', format: 'date-time' },
|
||||||
|
status: { type: 'string', enum: ['scheduled', 'sales_open', 'drawing', 'completed', 'cancelled'] },
|
||||||
|
pot_total_sats: { type: 'integer' },
|
||||||
|
pot_after_fee_sats: { type: 'integer', nullable: true },
|
||||||
|
winning_ticket_id: { type: 'string', format: 'uuid', nullable: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TicketPurchase: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', format: 'uuid' },
|
||||||
|
lightning_address: { type: 'string' },
|
||||||
|
number_of_tickets: { type: 'integer' },
|
||||||
|
amount_sats: { type: 'integer' },
|
||||||
|
invoice_status: { type: 'string', enum: ['pending', 'paid', 'expired', 'cancelled'] },
|
||||||
|
ticket_issue_status: { type: 'string', enum: ['not_issued', 'issued'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Ticket: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', format: 'uuid' },
|
||||||
|
serial_number: { type: 'integer' },
|
||||||
|
is_winning_ticket: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Error: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
version: { type: 'string', example: '1.0' },
|
||||||
|
error: { type: 'string' },
|
||||||
|
message: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
name: 'Public',
|
||||||
|
description: 'Public endpoints (no authentication required)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'User',
|
||||||
|
description: 'User endpoints (Nostr authentication required)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Admin',
|
||||||
|
description: 'Admin endpoints (admin API key required)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Webhooks',
|
||||||
|
description: 'Webhook endpoints for external integrations',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
swaggerDefinition,
|
||||||
|
apis: ['./src/routes/*.ts', './src/controllers/*.ts'], // Path to API docs
|
||||||
|
};
|
||||||
|
|
||||||
|
export const swaggerSpec = swaggerJsdoc(options);
|
||||||
|
|
||||||
191
back_end/src/controllers/admin.ts
Normal file
191
back_end/src/controllers/admin.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { db } from '../database';
|
||||||
|
import { executeDraw } from '../services/draw';
|
||||||
|
import { retryPayoutService } from '../services/payout';
|
||||||
|
import { JackpotCycle, Payout } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/cycles
|
||||||
|
* List all cycles with optional filters
|
||||||
|
*/
|
||||||
|
export async function listCycles(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { status, cycle_type, limit = 50, offset = 0 } = req.query;
|
||||||
|
|
||||||
|
let query = `SELECT * FROM jackpot_cycles WHERE 1=1`;
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramCount = 0;
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
paramCount++;
|
||||||
|
query += ` AND status = $${paramCount}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cycle_type) {
|
||||||
|
paramCount++;
|
||||||
|
query += ` AND cycle_type = $${paramCount}`;
|
||||||
|
params.push(cycle_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY scheduled_at DESC LIMIT $${paramCount + 1} OFFSET $${paramCount + 2}`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const result = await db.query<JackpotCycle>(query, params);
|
||||||
|
|
||||||
|
const cycles = result.rows.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
lottery_id: c.lottery_id,
|
||||||
|
cycle_type: c.cycle_type,
|
||||||
|
sequence_number: c.sequence_number,
|
||||||
|
scheduled_at: c.scheduled_at.toISOString(),
|
||||||
|
status: c.status,
|
||||||
|
pot_total_sats: parseInt(c.pot_total_sats.toString()),
|
||||||
|
pot_after_fee_sats: c.pot_after_fee_sats ? parseInt(c.pot_after_fee_sats.toString()) : null,
|
||||||
|
winning_ticket_id: c.winning_ticket_id,
|
||||||
|
winning_lightning_address: c.winning_lightning_address,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
version: '1.0',
|
||||||
|
data: {
|
||||||
|
cycles,
|
||||||
|
total: cycles.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('List cycles error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Failed to list cycles',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/cycles/:id/run-draw
|
||||||
|
* Manually trigger draw execution
|
||||||
|
*/
|
||||||
|
export async function runDrawManually(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await executeDraw(id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: result.error || 'DRAW_FAILED',
|
||||||
|
message: result.message || 'Failed to execute draw',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
version: '1.0',
|
||||||
|
data: {
|
||||||
|
cycle_id: id,
|
||||||
|
winning_ticket_id: result.winningTicketId,
|
||||||
|
pot_after_fee_sats: result.potAfterFeeSats,
|
||||||
|
payout_status: result.payoutStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Manual draw error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Failed to run draw',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/payouts
|
||||||
|
* List payouts with optional filters
|
||||||
|
*/
|
||||||
|
export async function listPayouts(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { status, limit = 50, offset = 0 } = req.query;
|
||||||
|
|
||||||
|
let query = `SELECT * FROM payouts WHERE 1=1`;
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramCount = 0;
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
paramCount++;
|
||||||
|
query += ` AND status = $${paramCount}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY created_at DESC LIMIT $${paramCount + 1} OFFSET $${paramCount + 2}`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const result = await db.query<Payout>(query, params);
|
||||||
|
|
||||||
|
const payouts = result.rows.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
lottery_id: p.lottery_id,
|
||||||
|
cycle_id: p.cycle_id,
|
||||||
|
ticket_id: p.ticket_id,
|
||||||
|
lightning_address: p.lightning_address,
|
||||||
|
amount_sats: parseInt(p.amount_sats.toString()),
|
||||||
|
status: p.status,
|
||||||
|
error_message: p.error_message,
|
||||||
|
retry_count: p.retry_count,
|
||||||
|
created_at: p.created_at.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
version: '1.0',
|
||||||
|
data: {
|
||||||
|
payouts,
|
||||||
|
total: payouts.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('List payouts error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Failed to list payouts',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/payouts/:id/retry
|
||||||
|
* Retry a failed payout
|
||||||
|
*/
|
||||||
|
export async function retryPayout(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await retryPayoutService(id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: result.error || 'RETRY_FAILED',
|
||||||
|
message: result.message || 'Failed to retry payout',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
version: '1.0',
|
||||||
|
data: {
|
||||||
|
payout_id: id,
|
||||||
|
status: result.status,
|
||||||
|
retry_count: result.retryCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Retry payout error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Failed to retry payout',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
483
back_end/src/controllers/public.ts
Normal file
483
back_end/src/controllers/public.ts
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { db } from '../database';
|
||||||
|
import { lnbitsService } from '../services/lnbits';
|
||||||
|
import { paymentMonitor } from '../services/paymentMonitor';
|
||||||
|
import { validateLightningAddress, validateTicketCount, sanitizeString } from '../utils/validation';
|
||||||
|
import config from '../config';
|
||||||
|
import { JackpotCycle, TicketPurchase, Ticket, Payout } from '../types';
|
||||||
|
import { AuthRequest } from '../middleware/auth';
|
||||||
|
|
||||||
|
const toIsoString = (value: any): string => {
|
||||||
|
if (!value) {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return new Date(value).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
// SQLite stores timestamps as "YYYY-MM-DD HH:mm:ss" by default
|
||||||
|
const normalized = value.includes('T')
|
||||||
|
? value
|
||||||
|
: value.replace(' ', 'T') + 'Z';
|
||||||
|
const parsed = new Date(normalized);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
return parsed.toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object' && typeof (value as any).toISOString === 'function') {
|
||||||
|
return (value as any).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
return parsed.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date().toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /jackpot/next
|
||||||
|
* Returns the next upcoming cycle
|
||||||
|
*/
|
||||||
|
export async function getNextJackpot(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
// Get active lottery
|
||||||
|
const lotteryResult = await db.query<any>(
|
||||||
|
`SELECT * FROM lotteries WHERE status = 'active' LIMIT 1`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lotteryResult.rows.length === 0) {
|
||||||
|
return res.status(503).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'NO_ACTIVE_LOTTERY',
|
||||||
|
message: 'No active lottery available',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lottery = lotteryResult.rows[0];
|
||||||
|
|
||||||
|
// Get next cycle
|
||||||
|
const cycleResult = await db.query<JackpotCycle>(
|
||||||
|
`SELECT * FROM jackpot_cycles
|
||||||
|
WHERE lottery_id = $1
|
||||||
|
AND status IN ('scheduled', 'sales_open')
|
||||||
|
AND scheduled_at > NOW()
|
||||||
|
ORDER BY scheduled_at ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
[lottery.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cycleResult.rows.length === 0) {
|
||||||
|
return res.status(503).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'NO_UPCOMING_CYCLE',
|
||||||
|
message: 'No upcoming cycle available',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = cycleResult.rows[0];
|
||||||
|
|
||||||
|
// Helper function to ensure ISO string format
|
||||||
|
const toISOString = (date: any): string => {
|
||||||
|
if (!date) return new Date().toISOString();
|
||||||
|
if (typeof date === 'string') return date.includes('Z') ? date : new Date(date).toISOString();
|
||||||
|
return date.toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
version: '1.0',
|
||||||
|
data: {
|
||||||
|
lottery: {
|
||||||
|
id: lottery.id,
|
||||||
|
name: lottery.name,
|
||||||
|
ticket_price_sats: parseInt(lottery.ticket_price_sats),
|
||||||
|
},
|
||||||
|
cycle: {
|
||||||
|
id: cycle.id,
|
||||||
|
cycle_type: cycle.cycle_type,
|
||||||
|
scheduled_at: toISOString(cycle.scheduled_at),
|
||||||
|
sales_open_at: toISOString(cycle.sales_open_at),
|
||||||
|
sales_close_at: toISOString(cycle.sales_close_at),
|
||||||
|
status: cycle.status,
|
||||||
|
pot_total_sats: parseInt(cycle.pot_total_sats.toString()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Get next jackpot error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Failed to fetch next jackpot',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /jackpot/buy
|
||||||
|
* Create a ticket purchase
|
||||||
|
*/
|
||||||
|
export async function buyTickets(req: AuthRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { tickets, lightning_address, nostr_pubkey, name, buyer_name } = req.body;
|
||||||
|
const userId = req.user?.id || null;
|
||||||
|
const authNostrPubkey = req.user?.nostr_pubkey || null;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!validateTicketCount(tickets, config.lottery.maxTicketsPerPurchase)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INVALID_TICKET_COUNT',
|
||||||
|
message: `Tickets must be between 1 and ${config.lottery.maxTicketsPerPurchase}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateLightningAddress(lightning_address)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INVALID_LIGHTNING_ADDRESS',
|
||||||
|
message: 'Lightning address must contain @ and be valid format',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLightningAddress = sanitizeString(lightning_address);
|
||||||
|
const rawNameInput = typeof name === 'string'
|
||||||
|
? name
|
||||||
|
: typeof buyer_name === 'string'
|
||||||
|
? buyer_name
|
||||||
|
: '';
|
||||||
|
const buyerName = sanitizeString(rawNameInput || 'Anon', 64) || 'Anon';
|
||||||
|
|
||||||
|
// Get active lottery
|
||||||
|
const lotteryResult = await db.query(
|
||||||
|
`SELECT * FROM lotteries WHERE status = 'active' LIMIT 1`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lotteryResult.rows.length === 0) {
|
||||||
|
return res.status(503).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'NO_ACTIVE_LOTTERY',
|
||||||
|
message: 'No active lottery available',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lottery = lotteryResult.rows[0];
|
||||||
|
|
||||||
|
// Get next available cycle
|
||||||
|
const cycleResult = await db.query<JackpotCycle>(
|
||||||
|
`SELECT * FROM jackpot_cycles
|
||||||
|
WHERE lottery_id = $1
|
||||||
|
AND status IN ('scheduled', 'sales_open')
|
||||||
|
AND sales_close_at > NOW()
|
||||||
|
ORDER BY scheduled_at ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
[lottery.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cycleResult.rows.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'NO_AVAILABLE_CYCLE',
|
||||||
|
message: 'No cycle available for ticket purchase',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = cycleResult.rows[0];
|
||||||
|
|
||||||
|
// Calculate amount
|
||||||
|
const ticketPriceSats = parseInt(lottery.ticket_price_sats);
|
||||||
|
const amountSats = tickets * ticketPriceSats;
|
||||||
|
|
||||||
|
// Create ticket purchase record
|
||||||
|
const purchaseId = randomUUID();
|
||||||
|
|
||||||
|
const purchaseResult = await db.query<TicketPurchase>(
|
||||||
|
`INSERT INTO ticket_purchases (
|
||||||
|
id, lottery_id, cycle_id, user_id, nostr_pubkey, lightning_address,
|
||||||
|
buyer_name,
|
||||||
|
number_of_tickets, ticket_price_sats, amount_sats,
|
||||||
|
lnbits_invoice_id, lnbits_payment_hash, invoice_status, ticket_issue_status
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
purchaseId,
|
||||||
|
lottery.id,
|
||||||
|
cycle.id,
|
||||||
|
userId,
|
||||||
|
nostr_pubkey || authNostrPubkey || null,
|
||||||
|
normalizedLightningAddress,
|
||||||
|
buyerName,
|
||||||
|
tickets,
|
||||||
|
ticketPriceSats,
|
||||||
|
amountSats,
|
||||||
|
'', // Will update after invoice creation
|
||||||
|
'', // Will update after invoice creation
|
||||||
|
'pending',
|
||||||
|
'not_issued',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchase = purchaseResult.rows[0];
|
||||||
|
const publicUrl = `${config.app.baseUrl}/tickets/${purchase.id}`;
|
||||||
|
|
||||||
|
// Create invoice memo
|
||||||
|
const memo = `Lightning Jackpot
|
||||||
|
Tickets: ${tickets}
|
||||||
|
Purchase ID: ${purchase.id}
|
||||||
|
Check status: ${publicUrl}`;
|
||||||
|
|
||||||
|
// Create LNbits invoice
|
||||||
|
try {
|
||||||
|
const webhookUrl = new URL('/webhooks/lnbits/payment', config.app.baseUrl);
|
||||||
|
if (config.lnbits.webhookSecret) {
|
||||||
|
webhookUrl.searchParams.set('secret', config.lnbits.webhookSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = await lnbitsService.createInvoice({
|
||||||
|
amount: amountSats,
|
||||||
|
memo: memo,
|
||||||
|
webhook: webhookUrl.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update purchase with invoice details
|
||||||
|
await db.query(
|
||||||
|
`UPDATE ticket_purchases
|
||||||
|
SET lnbits_invoice_id = $1, lnbits_payment_hash = $2, updated_at = NOW()
|
||||||
|
WHERE id = $3`,
|
||||||
|
[invoice.checking_id, invoice.payment_hash, purchase.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
await db.query(
|
||||||
|
`UPDATE users
|
||||||
|
SET lightning_address = $1, updated_at = NOW()
|
||||||
|
WHERE id = $2`,
|
||||||
|
[normalizedLightningAddress, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentMonitor.addInvoice(purchase.id, invoice.payment_hash);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
version: '1.0',
|
||||||
|
data: {
|
||||||
|
ticket_purchase_id: purchase.id,
|
||||||
|
public_url: publicUrl,
|
||||||
|
invoice: {
|
||||||
|
payment_request: invoice.payment_request,
|
||||||
|
amount_sats: amountSats,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (invoiceError: any) {
|
||||||
|
// Cleanup purchase if invoice creation fails
|
||||||
|
await db.query('DELETE FROM ticket_purchases WHERE id = $1', [purchase.id]);
|
||||||
|
|
||||||
|
return res.status(502).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INVOICE_CREATION_FAILED',
|
||||||
|
message: 'Failed to create Lightning invoice',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Buy tickets error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Failed to process ticket purchase',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /tickets/:id
|
||||||
|
* Get ticket purchase status
|
||||||
|
*/
|
||||||
|
export async function getTicketStatus(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Get purchase
|
||||||
|
const purchaseResult = await db.query<TicketPurchase>(
|
||||||
|
`SELECT * FROM ticket_purchases WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (purchaseResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'PURCHASE_NOT_FOUND',
|
||||||
|
message: 'Ticket purchase not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchase = purchaseResult.rows[0];
|
||||||
|
|
||||||
|
// Get cycle
|
||||||
|
const cycleResult = await db.query<JackpotCycle>(
|
||||||
|
`SELECT * FROM jackpot_cycles WHERE id = $1`,
|
||||||
|
[purchase.cycle_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const cycle = cycleResult.rows[0];
|
||||||
|
|
||||||
|
// Get tickets
|
||||||
|
const ticketsResult = await db.query<Ticket>(
|
||||||
|
`SELECT * FROM tickets WHERE ticket_purchase_id = $1 ORDER BY serial_number`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tickets = ticketsResult.rows.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
serial_number: parseInt(t.serial_number.toString()),
|
||||||
|
is_winning_ticket: cycle.winning_ticket_id === t.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Determine result
|
||||||
|
let result = {
|
||||||
|
has_drawn: cycle.status === 'completed',
|
||||||
|
is_winner: false,
|
||||||
|
payout: null as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cycle.status === 'completed' && cycle.winning_ticket_id) {
|
||||||
|
const isWinner = tickets.some(t => t.id === cycle.winning_ticket_id);
|
||||||
|
result.is_winner = isWinner;
|
||||||
|
|
||||||
|
if (isWinner) {
|
||||||
|
// Get payout
|
||||||
|
const payoutResult = await db.query<Payout>(
|
||||||
|
`SELECT * FROM payouts WHERE ticket_id = $1`,
|
||||||
|
[cycle.winning_ticket_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (payoutResult.rows.length > 0) {
|
||||||
|
const payout = payoutResult.rows[0];
|
||||||
|
result.payout = {
|
||||||
|
status: payout.status,
|
||||||
|
amount_sats: parseInt(payout.amount_sats.toString()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
version: '1.0',
|
||||||
|
data: {
|
||||||
|
purchase: {
|
||||||
|
id: purchase.id,
|
||||||
|
lottery_id: purchase.lottery_id,
|
||||||
|
cycle_id: purchase.cycle_id,
|
||||||
|
lightning_address: purchase.lightning_address,
|
||||||
|
buyer_name: purchase.buyer_name,
|
||||||
|
number_of_tickets: purchase.number_of_tickets,
|
||||||
|
ticket_price_sats: parseInt(purchase.ticket_price_sats.toString()),
|
||||||
|
amount_sats: parseInt(purchase.amount_sats.toString()),
|
||||||
|
invoice_status: purchase.invoice_status,
|
||||||
|
ticket_issue_status: purchase.ticket_issue_status,
|
||||||
|
created_at: toIsoString(purchase.created_at),
|
||||||
|
},
|
||||||
|
tickets,
|
||||||
|
cycle: {
|
||||||
|
id: cycle.id,
|
||||||
|
cycle_type: cycle.cycle_type,
|
||||||
|
scheduled_at: toIsoString(cycle.scheduled_at),
|
||||||
|
sales_open_at: toIsoString(cycle.sales_open_at),
|
||||||
|
sales_close_at: toIsoString(cycle.sales_close_at),
|
||||||
|
status: cycle.status,
|
||||||
|
pot_total_sats: parseInt(cycle.pot_total_sats.toString()),
|
||||||
|
pot_after_fee_sats: cycle.pot_after_fee_sats ? parseInt(cycle.pot_after_fee_sats.toString()) : null,
|
||||||
|
winning_ticket_id: cycle.winning_ticket_id,
|
||||||
|
},
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Get ticket status error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Failed to fetch ticket status',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PastWinRow {
|
||||||
|
cycle_id: string;
|
||||||
|
cycle_type: JackpotCycle['cycle_type'];
|
||||||
|
scheduled_at: Date | string;
|
||||||
|
pot_total_sats: number;
|
||||||
|
pot_after_fee_sats: number | null;
|
||||||
|
buyer_name: string | null;
|
||||||
|
serial_number: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /jackpot/past-wins
|
||||||
|
* List past jackpots with winner info
|
||||||
|
*/
|
||||||
|
export async function getPastWins(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const limitParam = parseInt((req.query.limit as string) || '20', 10);
|
||||||
|
const offsetParam = parseInt((req.query.offset as string) || '0', 10);
|
||||||
|
const limit = Math.min(100, Math.max(1, Number.isNaN(limitParam) ? 20 : limitParam));
|
||||||
|
const offset = Math.max(0, Number.isNaN(offsetParam) ? 0 : offsetParam);
|
||||||
|
|
||||||
|
const result = await db.query<PastWinRow>(
|
||||||
|
`SELECT
|
||||||
|
jc.id as cycle_id,
|
||||||
|
jc.cycle_type,
|
||||||
|
jc.scheduled_at,
|
||||||
|
jc.pot_total_sats,
|
||||||
|
jc.pot_after_fee_sats,
|
||||||
|
tp.buyer_name,
|
||||||
|
t.serial_number
|
||||||
|
FROM jackpot_cycles jc
|
||||||
|
JOIN tickets t ON jc.winning_ticket_id = t.id
|
||||||
|
JOIN ticket_purchases tp ON t.ticket_purchase_id = tp.id
|
||||||
|
WHERE jc.status = 'completed'
|
||||||
|
AND jc.winning_ticket_id IS NOT NULL
|
||||||
|
ORDER BY jc.scheduled_at DESC
|
||||||
|
LIMIT $1 OFFSET $2`,
|
||||||
|
[limit, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
const wins = result.rows.map((row) => ({
|
||||||
|
cycle_id: row.cycle_id,
|
||||||
|
cycle_type: row.cycle_type,
|
||||||
|
scheduled_at: toIsoString(row.scheduled_at),
|
||||||
|
pot_total_sats: parseInt(row.pot_total_sats.toString()),
|
||||||
|
pot_after_fee_sats: row.pot_after_fee_sats
|
||||||
|
? parseInt(row.pot_after_fee_sats.toString())
|
||||||
|
: null,
|
||||||
|
winner_name: row.buyer_name || 'Anon',
|
||||||
|
winning_ticket_serial: row.serial_number
|
||||||
|
? parseInt(row.serial_number.toString())
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
version: '1.0',
|
||||||
|
data: {
|
||||||
|
wins,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Get past wins error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Failed to load past wins',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
348
back_end/src/controllers/user.ts
Normal file
348
back_end/src/controllers/user.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { db } from '../database';
|
||||||
|
import { AuthRequest } from '../middleware/auth';
|
||||||
|
import { validateNostrPubkey, validateLightningAddress } from '../utils/validation';
|
||||||
|
import config from '../config';
|
||||||
|
import { User, TicketPurchase, Payout } from '../types';
|
||||||
|
|
||||||
|
const toIsoString = (value: any): string => {
|
||||||
|
if (!value) {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return new Date(value).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.includes('T') ? value : value.replace(' ', 'T') + 'Z';
|
||||||
|
const parsed = new Date(normalized);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
return parsed.toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object' && typeof (value as any).toISOString === 'function') {
|
||||||
|
return (value as any).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
return parsed.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date().toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseCount = (value: any): number => {
|
||||||
|
const parsed = parseInt(value?.toString() ?? '0', 10);
|
||||||
|
return Number.isNaN(parsed) ? 0 : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/nostr
|
||||||
|
* Authenticate with Nostr signature
|
||||||
|
*/
|
||||||
|
export async function nostrAuth(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { nostr_pubkey, signed_message, nonce } = req.body;
|
||||||
|
|
||||||
|
// Validate pubkey format
|
||||||
|
if (!validateNostrPubkey(nostr_pubkey)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INVALID_PUBKEY',
|
||||||
|
message: 'Invalid Nostr public key format',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual signature verification using nostr-tools
|
||||||
|
// For now, we'll trust the pubkey (NOT PRODUCTION READY)
|
||||||
|
// In production, verify the signature against the nonce
|
||||||
|
|
||||||
|
// Find or create user
|
||||||
|
let userResult = await db.query<User>(
|
||||||
|
`SELECT * FROM users WHERE nostr_pubkey = $1`,
|
||||||
|
[nostr_pubkey]
|
||||||
|
);
|
||||||
|
|
||||||
|
let user: User;
|
||||||
|
|
||||||
|
if (userResult.rows.length === 0) {
|
||||||
|
// Create new user
|
||||||
|
const userId = randomUUID();
|
||||||
|
const createResult = await db.query<User>(
|
||||||
|
`INSERT INTO users (id, nostr_pubkey) VALUES ($1, $2) RETURNING *`,
|
||||||
|
[userId, nostr_pubkey]
|
||||||
|
);
|
||||||
|
user = createResult.rows[0];
|
||||||
|
} else {
|
||||||
|
user = userResult.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
nostr_pubkey: user.nostr_pubkey,
|
||||||
|
},
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: '30d' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
version: '1.0',
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
nostr_pubkey: user.nostr_pubkey,
|
||||||
|
display_name: user.display_name,
|
||||||
|
lightning_address: user.lightning_address,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Nostr auth error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Failed to authenticate',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /me
|
||||||
|
* Get user profile
|
||||||
|
*/
|
||||||
|
export async function getProfile(req: AuthRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
|
||||||
|
const userResult = await db.query<User>(
|
||||||
|
`SELECT * FROM users WHERE id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'USER_NOT_FOUND',
|
||||||
|
message: 'User not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult.rows[0];
|
||||||
|
|
||||||
|
const nostrPubkey = user.nostr_pubkey;
|
||||||
|
|
||||||
|
const totalTicketsResult = await db.query(
|
||||||
|
`SELECT COALESCE(SUM(number_of_tickets), 0) as total_tickets
|
||||||
|
FROM ticket_purchases
|
||||||
|
WHERE user_id = $1 OR nostr_pubkey = $2`,
|
||||||
|
[userId, nostrPubkey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentRoundResult = await db.query(
|
||||||
|
`SELECT COALESCE(SUM(tp.number_of_tickets), 0) as current_tickets
|
||||||
|
FROM ticket_purchases tp
|
||||||
|
JOIN jackpot_cycles jc ON tp.cycle_id = jc.id
|
||||||
|
WHERE (tp.user_id = $1 OR tp.nostr_pubkey = $2)
|
||||||
|
AND jc.status IN ('scheduled', 'sales_open')`,
|
||||||
|
[userId, nostrPubkey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const pastTicketsResult = await db.query(
|
||||||
|
`SELECT COALESCE(SUM(tp.number_of_tickets), 0) as past_tickets
|
||||||
|
FROM ticket_purchases tp
|
||||||
|
JOIN jackpot_cycles jc ON tp.cycle_id = jc.id
|
||||||
|
WHERE (tp.user_id = $1 OR tp.nostr_pubkey = $2)
|
||||||
|
AND jc.status NOT IN ('scheduled', 'sales_open')`,
|
||||||
|
[userId, nostrPubkey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const winStats = await db.query(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN p.status = 'paid' THEN 1 ELSE 0 END), 0) as total_wins,
|
||||||
|
COALESCE(SUM(CASE WHEN p.status = 'paid' THEN p.amount_sats ELSE 0 END), 0) as total_winnings
|
||||||
|
FROM payouts p
|
||||||
|
JOIN tickets t ON p.ticket_id = t.id
|
||||||
|
JOIN ticket_purchases tp ON t.ticket_purchase_id = tp.id
|
||||||
|
WHERE tp.user_id = $1 OR tp.nostr_pubkey = $2`,
|
||||||
|
[userId, nostrPubkey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalTickets = parseCount(totalTicketsResult.rows[0]?.total_tickets);
|
||||||
|
const currentRoundTickets = parseCount(currentRoundResult.rows[0]?.current_tickets);
|
||||||
|
const pastTickets = parseCount(pastTicketsResult.rows[0]?.past_tickets);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
version: '1.0',
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
nostr_pubkey: user.nostr_pubkey,
|
||||||
|
display_name: user.display_name,
|
||||||
|
lightning_address: user.lightning_address,
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
total_tickets: totalTickets,
|
||||||
|
current_round_tickets: currentRoundTickets,
|
||||||
|
past_tickets: pastTickets,
|
||||||
|
total_wins: parseCount(winStats.rows[0]?.total_wins),
|
||||||
|
total_winnings_sats: parseCount(winStats.rows[0]?.total_winnings),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Get profile error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Failed to get profile',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /me/lightning-address
|
||||||
|
* Update user's lightning address
|
||||||
|
*/
|
||||||
|
export async function updateLightningAddress(req: AuthRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const { lightning_address } = req.body;
|
||||||
|
|
||||||
|
if (!validateLightningAddress(lightning_address)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INVALID_LIGHTNING_ADDRESS',
|
||||||
|
message: 'Invalid Lightning Address format',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`UPDATE users SET lightning_address = $1, updated_at = NOW() WHERE id = $2`,
|
||||||
|
[lightning_address, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
version: '1.0',
|
||||||
|
data: {
|
||||||
|
lightning_address,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Update lightning address error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Failed to update lightning address',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /me/tickets
|
||||||
|
* Get user's ticket purchases
|
||||||
|
*/
|
||||||
|
export async function getUserTickets(req: AuthRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const userPubkey = req.user!.nostr_pubkey;
|
||||||
|
const limitParam = typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : 50;
|
||||||
|
const offsetParam = typeof req.query.offset === 'string' ? parseInt(req.query.offset, 10) : 0;
|
||||||
|
const limitValue = Math.min(100, Math.max(1, Number.isNaN(limitParam) ? 50 : limitParam));
|
||||||
|
const offsetValue = Math.max(0, Number.isNaN(offsetParam) ? 0 : offsetParam);
|
||||||
|
|
||||||
|
const result = await db.query<TicketPurchase>(
|
||||||
|
`SELECT tp.*, jc.cycle_type, jc.scheduled_at, jc.status as cycle_status
|
||||||
|
FROM ticket_purchases tp
|
||||||
|
JOIN jackpot_cycles jc ON tp.cycle_id = jc.id
|
||||||
|
WHERE (tp.user_id = $1 OR tp.nostr_pubkey = $2)
|
||||||
|
ORDER BY tp.created_at DESC
|
||||||
|
LIMIT $3 OFFSET $4`,
|
||||||
|
[userId, userPubkey, limitValue, offsetValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchases = result.rows.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
cycle_id: p.cycle_id,
|
||||||
|
buyer_name: p.buyer_name,
|
||||||
|
number_of_tickets: p.number_of_tickets,
|
||||||
|
amount_sats: parseInt(p.amount_sats.toString()),
|
||||||
|
invoice_status: p.invoice_status,
|
||||||
|
ticket_issue_status: p.ticket_issue_status,
|
||||||
|
created_at: toIsoString(p.created_at),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
version: '1.0',
|
||||||
|
data: {
|
||||||
|
purchases,
|
||||||
|
total: purchases.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Get user tickets error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Failed to get tickets',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /me/wins
|
||||||
|
* Get user's wins
|
||||||
|
*/
|
||||||
|
export async function getUserWins(req: AuthRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const userPubkey = req.user!.nostr_pubkey;
|
||||||
|
|
||||||
|
const result = await db.query<Payout>(
|
||||||
|
`SELECT p.*, jc.cycle_type, jc.scheduled_at
|
||||||
|
FROM payouts p
|
||||||
|
JOIN tickets t ON p.ticket_id = t.id
|
||||||
|
JOIN ticket_purchases tp ON t.ticket_purchase_id = tp.id
|
||||||
|
JOIN jackpot_cycles jc ON p.cycle_id = jc.id
|
||||||
|
WHERE tp.user_id = $1 OR tp.nostr_pubkey = $2
|
||||||
|
ORDER BY p.created_at DESC`,
|
||||||
|
[userId, userPubkey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const wins = result.rows.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
cycle_id: p.cycle_id,
|
||||||
|
ticket_id: p.ticket_id,
|
||||||
|
amount_sats: parseInt(p.amount_sats.toString()),
|
||||||
|
status: p.status,
|
||||||
|
created_at: toIsoString(p.created_at),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
version: '1.0',
|
||||||
|
data: {
|
||||||
|
wins,
|
||||||
|
total: wins.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Get user wins error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Failed to get wins',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
98
back_end/src/controllers/webhooks.ts
Normal file
98
back_end/src/controllers/webhooks.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { db } from '../database';
|
||||||
|
import { lnbitsService } from '../services/lnbits';
|
||||||
|
import { TicketPurchase } from '../types';
|
||||||
|
import { finalizeTicketPurchase } from '../services/paymentProcessor';
|
||||||
|
import { paymentMonitor } from '../services/paymentMonitor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /webhooks/lnbits/payment
|
||||||
|
* Handle LNbits payment webhook
|
||||||
|
*/
|
||||||
|
export async function handleLNbitsPayment(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { payment_hash, amount, paid } = req.body;
|
||||||
|
|
||||||
|
// Verify webhook (basic secret validation)
|
||||||
|
const webhookSecretHeader = (req.headers['x-webhook-secret'] || req.headers['x-callback-secret']) as string | undefined;
|
||||||
|
const webhookSecretQuery = (() => {
|
||||||
|
const value = req.query?.secret;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value[0];
|
||||||
|
}
|
||||||
|
return value as string | undefined;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const providedSecret = webhookSecretHeader || webhookSecretQuery || '';
|
||||||
|
|
||||||
|
if (!lnbitsService.verifyWebhook(providedSecret)) {
|
||||||
|
console.error('Webhook verification failed');
|
||||||
|
return res.status(403).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'FORBIDDEN',
|
||||||
|
message: 'Invalid webhook signature',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if payment is successful
|
||||||
|
if (!paid) {
|
||||||
|
console.log('Payment not completed yet:', payment_hash);
|
||||||
|
return res.json({ version: '1.0', message: 'Payment not completed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find purchase by payment hash
|
||||||
|
const purchaseResult = await db.query<TicketPurchase>(
|
||||||
|
`SELECT * FROM ticket_purchases WHERE lnbits_payment_hash = $1`,
|
||||||
|
[payment_hash]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (purchaseResult.rows.length === 0) {
|
||||||
|
console.error('Purchase not found for payment hash:', payment_hash);
|
||||||
|
// Return 200 to avoid retries for unknown payments
|
||||||
|
return res.json({ version: '1.0', message: 'Purchase not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchase = purchaseResult.rows[0];
|
||||||
|
|
||||||
|
// Idempotency check
|
||||||
|
if (purchase.invoice_status === 'paid') {
|
||||||
|
console.log('Invoice already marked as paid:', purchase.id);
|
||||||
|
return res.json({ version: '1.0', message: 'Already processed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify amount (optional, but recommended)
|
||||||
|
const expectedAmount = parseInt(purchase.amount_sats.toString());
|
||||||
|
if (amount && amount < expectedAmount) {
|
||||||
|
console.error('Payment amount mismatch:', { expected: expectedAmount, received: amount });
|
||||||
|
// You could mark as error here, but for now we'll still process
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalizeResult = await finalizeTicketPurchase(purchase.id);
|
||||||
|
if (finalizeResult.status === 'not_found') {
|
||||||
|
console.error('Purchase disappeared before processing:', purchase.id);
|
||||||
|
return res.json({ version: '1.0', message: 'Purchase missing during processing' });
|
||||||
|
}
|
||||||
|
|
||||||
|
paymentMonitor.removeInvoice(purchase.id);
|
||||||
|
|
||||||
|
console.log('Payment processed successfully:', {
|
||||||
|
purchase_id: purchase.id,
|
||||||
|
tickets: finalizeResult.ticketsIssued,
|
||||||
|
amount_sats: purchase.amount_sats,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
version: '1.0',
|
||||||
|
message: 'Payment processed successfully',
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Webhook processing error:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Failed to process payment webhook',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
334
back_end/src/database/index.ts
Normal file
334
back_end/src/database/index.ts
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import { Pool, QueryResult, PoolClient, QueryResultRow } from 'pg';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import config from '../config';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
interface DbClient {
|
||||||
|
query<T extends QueryResultRow = any>(text: string, params?: any[]): Promise<QueryResult<T>>;
|
||||||
|
release?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DatabaseWrapper {
|
||||||
|
private pgPool?: Pool;
|
||||||
|
private sqliteDb?: Database.Database;
|
||||||
|
private dbType: 'postgres' | 'sqlite';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.dbType = config.database.type;
|
||||||
|
|
||||||
|
if (this.dbType === 'postgres') {
|
||||||
|
this.initPostgres();
|
||||||
|
} else {
|
||||||
|
this.initSqlite();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initPostgres() {
|
||||||
|
this.pgPool = new Pool({
|
||||||
|
connectionString: config.database.url,
|
||||||
|
ssl: config.app.nodeEnv === 'production' ? { rejectUnauthorized: false } : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pgPool.on('error', (err) => {
|
||||||
|
console.error('Unexpected PostgreSQL error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✓ PostgreSQL database initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private initSqlite() {
|
||||||
|
// Ensure data directory exists
|
||||||
|
const dbPath = config.database.url;
|
||||||
|
const dbDir = path.dirname(dbPath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sqliteDb = new Database(dbPath);
|
||||||
|
this.sqliteDb.pragma('journal_mode = WAL');
|
||||||
|
this.sqliteDb.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
// Initialize schema for SQLite
|
||||||
|
this.initSqliteSchema();
|
||||||
|
|
||||||
|
console.log(`✓ SQLite database initialized at: ${dbPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initSqliteSchema() {
|
||||||
|
if (!this.sqliteDb) return;
|
||||||
|
|
||||||
|
const schema = `
|
||||||
|
-- SQLite Schema for Lightning Lottery
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lotteries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'finished')),
|
||||||
|
ticket_price_sats INTEGER NOT NULL,
|
||||||
|
fee_percent INTEGER NOT NULL CHECK (fee_percent >= 0 AND fee_percent <= 100),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS jackpot_cycles (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
lottery_id TEXT NOT NULL REFERENCES lotteries(id),
|
||||||
|
cycle_type TEXT NOT NULL CHECK (cycle_type IN ('hourly', 'daily', 'weekly', 'monthly')),
|
||||||
|
sequence_number INTEGER NOT NULL,
|
||||||
|
scheduled_at TEXT NOT NULL,
|
||||||
|
sales_open_at TEXT NOT NULL,
|
||||||
|
sales_close_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('scheduled', 'sales_open', 'drawing', 'completed', 'cancelled')),
|
||||||
|
pot_total_sats INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pot_after_fee_sats INTEGER,
|
||||||
|
winning_ticket_id TEXT,
|
||||||
|
winning_lightning_address TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
nostr_pubkey TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
lightning_address TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_purchases (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
lottery_id TEXT NOT NULL REFERENCES lotteries(id),
|
||||||
|
cycle_id TEXT NOT NULL REFERENCES jackpot_cycles(id),
|
||||||
|
user_id TEXT REFERENCES users(id),
|
||||||
|
nostr_pubkey TEXT,
|
||||||
|
lightning_address TEXT NOT NULL,
|
||||||
|
buyer_name TEXT NOT NULL DEFAULT 'Anon',
|
||||||
|
number_of_tickets INTEGER NOT NULL,
|
||||||
|
ticket_price_sats INTEGER NOT NULL,
|
||||||
|
amount_sats INTEGER NOT NULL,
|
||||||
|
lnbits_invoice_id TEXT NOT NULL,
|
||||||
|
lnbits_payment_hash TEXT NOT NULL,
|
||||||
|
invoice_status TEXT NOT NULL CHECK (invoice_status IN ('pending', 'paid', 'expired', 'cancelled')),
|
||||||
|
ticket_issue_status TEXT NOT NULL CHECK (ticket_issue_status IN ('not_issued', 'issued')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
lottery_id TEXT NOT NULL REFERENCES lotteries(id),
|
||||||
|
cycle_id TEXT NOT NULL REFERENCES jackpot_cycles(id),
|
||||||
|
ticket_purchase_id TEXT NOT NULL REFERENCES ticket_purchases(id),
|
||||||
|
user_id TEXT REFERENCES users(id),
|
||||||
|
lightning_address TEXT NOT NULL,
|
||||||
|
serial_number INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS payouts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
lottery_id TEXT NOT NULL REFERENCES lotteries(id),
|
||||||
|
cycle_id TEXT NOT NULL REFERENCES jackpot_cycles(id),
|
||||||
|
ticket_id TEXT NOT NULL REFERENCES tickets(id),
|
||||||
|
user_id TEXT REFERENCES users(id),
|
||||||
|
lightning_address TEXT NOT NULL,
|
||||||
|
amount_sats INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'failed')),
|
||||||
|
lnbits_payment_id TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS draw_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
cycle_id TEXT NOT NULL REFERENCES jackpot_cycles(id),
|
||||||
|
number_of_tickets INTEGER NOT NULL,
|
||||||
|
pot_total_sats INTEGER NOT NULL,
|
||||||
|
fee_percent INTEGER NOT NULL,
|
||||||
|
pot_after_fee_sats INTEGER NOT NULL,
|
||||||
|
winner_ticket_id TEXT,
|
||||||
|
winner_lightning_address TEXT,
|
||||||
|
rng_source TEXT NOT NULL,
|
||||||
|
selected_index INTEGER,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cycles_status_time ON jackpot_cycles(status, scheduled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticketpurchase_paymenthash ON ticket_purchases(lnbits_payment_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticketpurchase_cycle ON ticket_purchases(cycle_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_cycle ON tickets(cycle_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_purchase ON tickets(ticket_purchase_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payouts_ticket ON payouts(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payouts_status ON payouts(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_pubkey ON users(nostr_pubkey);
|
||||||
|
|
||||||
|
-- Insert default lottery if not exists
|
||||||
|
INSERT OR IGNORE INTO lotteries (id, name, status, ticket_price_sats, fee_percent)
|
||||||
|
VALUES ('default-lottery-id', 'Main Lightning Jackpot', 'active', 1000, 5);
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.sqliteDb.exec(schema);
|
||||||
|
this.ensureSqliteColumn('ticket_purchases', 'buyer_name', "TEXT NOT NULL DEFAULT 'Anon'");
|
||||||
|
}
|
||||||
|
|
||||||
|
async query<T extends QueryResultRow = any>(text: string, params?: any[]): Promise<QueryResult<T>> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.dbType === 'postgres' && this.pgPool) {
|
||||||
|
const res = await this.pgPool.query<T>(text, params);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
if (config.app.nodeEnv === 'development') {
|
||||||
|
console.log('Executed query', { text: text.substring(0, 100), duration, rows: res.rowCount });
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} else if (this.dbType === 'sqlite' && this.sqliteDb) {
|
||||||
|
// Convert PostgreSQL-style query to SQLite
|
||||||
|
let sqliteQuery = this.convertToSqlite(text, params);
|
||||||
|
|
||||||
|
// Determine if it's a SELECT or modification query
|
||||||
|
const isSelect = text.trim().toUpperCase().startsWith('SELECT');
|
||||||
|
|
||||||
|
let rows: any[] = [];
|
||||||
|
|
||||||
|
if (isSelect) {
|
||||||
|
const stmt = this.sqliteDb.prepare(sqliteQuery.text);
|
||||||
|
rows = stmt.all(...(sqliteQuery.params || []));
|
||||||
|
} else {
|
||||||
|
const stmt = this.sqliteDb.prepare(sqliteQuery.text);
|
||||||
|
const result = stmt.run(...(sqliteQuery.params || []));
|
||||||
|
|
||||||
|
// For INSERT with RETURNING, we need to fetch the inserted row
|
||||||
|
if (text.toUpperCase().includes('RETURNING')) {
|
||||||
|
const match = text.match(/INSERT INTO (\w+)/i);
|
||||||
|
if (match && result.lastInsertRowid) {
|
||||||
|
const tableName = match[1];
|
||||||
|
rows = this.sqliteDb.prepare(`SELECT * FROM ${tableName} WHERE rowid = ?`)
|
||||||
|
.all(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
if (config.app.nodeEnv === 'development') {
|
||||||
|
console.log('Executed SQLite query', { text: sqliteQuery.text.substring(0, 100), duration, rows: rows.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: rows as T[],
|
||||||
|
rowCount: rows.length,
|
||||||
|
command: '',
|
||||||
|
oid: 0,
|
||||||
|
fields: [],
|
||||||
|
} as QueryResult<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Database not initialized');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Database query error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateUUID(): string {
|
||||||
|
// Simple UUID v4 generator
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
const v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertToSqlite(text: string, params?: any[]): { text: string; params?: any[] } {
|
||||||
|
// Convert PostgreSQL $1, $2 placeholders to SQLite ?
|
||||||
|
let sqliteText = text;
|
||||||
|
let sqliteParams = params || [];
|
||||||
|
|
||||||
|
// Replace $n with ?
|
||||||
|
sqliteText = sqliteText.replace(/\$(\d+)/g, '?');
|
||||||
|
|
||||||
|
// Convert PostgreSQL-specific functions
|
||||||
|
sqliteText = sqliteText.replace(/NOW\(\)/gi, "datetime('now')");
|
||||||
|
|
||||||
|
// Remove FOR UPDATE locking hints (SQLite locks entire database for writes)
|
||||||
|
sqliteText = sqliteText.replace(/\s+FOR\s+UPDATE/gi, '');
|
||||||
|
|
||||||
|
// For UUID generation in INSERT statements, generate actual UUIDs
|
||||||
|
const uuidMatches = sqliteText.match(/uuid_generate_v4\(\)/gi);
|
||||||
|
if (uuidMatches) {
|
||||||
|
uuidMatches.forEach(() => {
|
||||||
|
sqliteText = sqliteText.replace(/uuid_generate_v4\(\)/i, `'${this.generateUUID()}'`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert RETURNING * to just the statement (SQLite doesn't support RETURNING in the same way)
|
||||||
|
if (sqliteText.toUpperCase().includes('RETURNING')) {
|
||||||
|
sqliteText = sqliteText.replace(/RETURNING \*/gi, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle TIMESTAMPTZ -> TEXT conversion
|
||||||
|
sqliteText = sqliteText.replace(/TIMESTAMPTZ/gi, 'TEXT');
|
||||||
|
|
||||||
|
return { text: sqliteText, params: sqliteParams };
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureSqliteColumn(table: string, column: string, definition: string): void {
|
||||||
|
if (!this.sqliteDb) return;
|
||||||
|
const columns = this.sqliteDb.prepare(`PRAGMA table_info(${table})`).all();
|
||||||
|
const exists = columns.some((col: any) => col.name === column);
|
||||||
|
if (!exists) {
|
||||||
|
this.sqliteDb.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClient(): Promise<DbClient> {
|
||||||
|
if (this.dbType === 'postgres' && this.pgPool) {
|
||||||
|
const client = await this.pgPool.connect();
|
||||||
|
return {
|
||||||
|
query: async <T extends QueryResultRow = any>(text: string, params?: any[]) => {
|
||||||
|
return client.query<T>(text, params);
|
||||||
|
},
|
||||||
|
release: () => client.release(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// For SQLite, return a wrapper that uses the main connection
|
||||||
|
return {
|
||||||
|
query: async <T extends QueryResultRow = any>(text: string, params?: any[]) => {
|
||||||
|
return this.query<T>(text, params);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (this.dbType === 'postgres') {
|
||||||
|
await this.query('SELECT 1');
|
||||||
|
} else {
|
||||||
|
await this.query('SELECT 1');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Database health check failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (this.pgPool) {
|
||||||
|
await this.pgPool.end();
|
||||||
|
}
|
||||||
|
if (this.sqliteDb) {
|
||||||
|
this.sqliteDb.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = new DatabaseWrapper();
|
||||||
|
export default db;
|
||||||
131
back_end/src/database/schema.sql
Normal file
131
back_end/src/database/schema.sql
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
-- Lightning Lottery Database Schema
|
||||||
|
-- PostgreSQL 14+
|
||||||
|
|
||||||
|
-- Create UUID extension if not exists
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Lotteries table
|
||||||
|
CREATE TABLE IF NOT EXISTS lotteries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'finished')),
|
||||||
|
ticket_price_sats BIGINT NOT NULL,
|
||||||
|
fee_percent INTEGER NOT NULL CHECK (fee_percent >= 0 AND fee_percent <= 100),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Jackpot Cycles table
|
||||||
|
CREATE TABLE IF NOT EXISTS jackpot_cycles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
lottery_id UUID NOT NULL REFERENCES lotteries(id),
|
||||||
|
cycle_type TEXT NOT NULL CHECK (cycle_type IN ('hourly', 'daily', 'weekly', 'monthly')),
|
||||||
|
sequence_number INTEGER NOT NULL,
|
||||||
|
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
sales_open_at TIMESTAMPTZ NOT NULL,
|
||||||
|
sales_close_at TIMESTAMPTZ NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('scheduled', 'sales_open', 'drawing', 'completed', 'cancelled')),
|
||||||
|
pot_total_sats BIGINT NOT NULL DEFAULT 0,
|
||||||
|
pot_after_fee_sats BIGINT,
|
||||||
|
winning_ticket_id UUID,
|
||||||
|
winning_lightning_address TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users table (optional for Nostr)
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
nostr_pubkey TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
lightning_address TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ticket Purchases table
|
||||||
|
CREATE TABLE IF NOT EXISTS ticket_purchases (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
lottery_id UUID NOT NULL REFERENCES lotteries(id),
|
||||||
|
cycle_id UUID NOT NULL REFERENCES jackpot_cycles(id),
|
||||||
|
user_id UUID REFERENCES users(id),
|
||||||
|
nostr_pubkey TEXT,
|
||||||
|
lightning_address TEXT NOT NULL,
|
||||||
|
buyer_name TEXT NOT NULL DEFAULT 'Anon',
|
||||||
|
number_of_tickets INTEGER NOT NULL,
|
||||||
|
ticket_price_sats BIGINT NOT NULL,
|
||||||
|
amount_sats BIGINT NOT NULL,
|
||||||
|
lnbits_invoice_id TEXT NOT NULL,
|
||||||
|
lnbits_payment_hash TEXT NOT NULL,
|
||||||
|
invoice_status TEXT NOT NULL CHECK (invoice_status IN ('pending', 'paid', 'expired', 'cancelled')),
|
||||||
|
ticket_issue_status TEXT NOT NULL CHECK (ticket_issue_status IN ('not_issued', 'issued')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tickets table
|
||||||
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
lottery_id UUID NOT NULL REFERENCES lotteries(id),
|
||||||
|
cycle_id UUID NOT NULL REFERENCES jackpot_cycles(id),
|
||||||
|
ticket_purchase_id UUID NOT NULL REFERENCES ticket_purchases(id),
|
||||||
|
user_id UUID REFERENCES users(id),
|
||||||
|
lightning_address TEXT NOT NULL,
|
||||||
|
serial_number BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Payouts table
|
||||||
|
CREATE TABLE IF NOT EXISTS payouts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
lottery_id UUID NOT NULL REFERENCES lotteries(id),
|
||||||
|
cycle_id UUID NOT NULL REFERENCES jackpot_cycles(id),
|
||||||
|
ticket_id UUID NOT NULL REFERENCES tickets(id),
|
||||||
|
user_id UUID REFERENCES users(id),
|
||||||
|
lightning_address TEXT NOT NULL,
|
||||||
|
amount_sats BIGINT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'failed')),
|
||||||
|
lnbits_payment_id TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Draw logs table (for audit and transparency)
|
||||||
|
CREATE TABLE IF NOT EXISTS draw_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
cycle_id UUID NOT NULL REFERENCES jackpot_cycles(id),
|
||||||
|
number_of_tickets INTEGER NOT NULL,
|
||||||
|
pot_total_sats BIGINT NOT NULL,
|
||||||
|
fee_percent INTEGER NOT NULL,
|
||||||
|
pot_after_fee_sats BIGINT NOT NULL,
|
||||||
|
winner_ticket_id UUID,
|
||||||
|
winner_lightning_address TEXT,
|
||||||
|
rng_source TEXT NOT NULL,
|
||||||
|
selected_index INTEGER,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cycles_status_time ON jackpot_cycles(status, scheduled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticketpurchase_paymenthash ON ticket_purchases(lnbits_payment_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ticketpurchase_cycle ON ticket_purchases(cycle_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_cycle ON tickets(cycle_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickets_purchase ON tickets(ticket_purchase_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payouts_ticket ON payouts(ticket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payouts_status ON payouts(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_pubkey ON users(nostr_pubkey);
|
||||||
|
|
||||||
|
-- Add foreign key constraint for winning ticket (after tickets table exists)
|
||||||
|
ALTER TABLE jackpot_cycles ADD CONSTRAINT fk_winning_ticket
|
||||||
|
FOREIGN KEY (winning_ticket_id) REFERENCES tickets(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE ticket_purchases
|
||||||
|
ADD COLUMN IF NOT EXISTS buyer_name TEXT NOT NULL DEFAULT 'Anon';
|
||||||
|
|
||||||
|
-- Insert default lottery
|
||||||
|
INSERT INTO lotteries (name, status, ticket_price_sats, fee_percent)
|
||||||
|
VALUES ('Main Lightning Jackpot', 'active', 1000, 5)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
63
back_end/src/index.ts
Normal file
63
back_end/src/index.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import app from './app';
|
||||||
|
import config from './config';
|
||||||
|
import { db } from './database';
|
||||||
|
import { startSchedulers } from './scheduler';
|
||||||
|
import { paymentMonitor } from './services/paymentMonitor';
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
// Test database connection
|
||||||
|
console.log('Testing database connection...');
|
||||||
|
const dbHealthy = await db.healthCheck();
|
||||||
|
|
||||||
|
if (!dbHealthy) {
|
||||||
|
console.error('❌ Database connection failed');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✓ Database connected');
|
||||||
|
|
||||||
|
// Start HTTP server
|
||||||
|
app.listen(config.app.port, () => {
|
||||||
|
console.log(`
|
||||||
|
╔═══════════════════════════════════════════╗
|
||||||
|
║ Lightning Lottery API Server ║
|
||||||
|
║ ║
|
||||||
|
║ Port: ${config.app.port} ║
|
||||||
|
║ Environment: ${config.app.nodeEnv} ║
|
||||||
|
║ Base URL: ${config.app.baseUrl} ║
|
||||||
|
╚═══════════════════════════════════════════╝
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start schedulers
|
||||||
|
startSchedulers();
|
||||||
|
await paymentMonitor.start();
|
||||||
|
|
||||||
|
console.log('✓ Schedulers started');
|
||||||
|
console.log('\n🚀 Lightning Lottery API is ready!\n');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
console.log('SIGTERM received, shutting down gracefully...');
|
||||||
|
paymentMonitor.stop();
|
||||||
|
await db.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
console.log('SIGINT received, shutting down gracefully...');
|
||||||
|
paymentMonitor.stop();
|
||||||
|
await db.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
start();
|
||||||
|
|
||||||
86
back_end/src/middleware/auth.ts
Normal file
86
back_end/src/middleware/auth.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
nostr_pubkey: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to verify JWT token
|
||||||
|
*/
|
||||||
|
export const verifyToken = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'UNAUTHORIZED',
|
||||||
|
message: 'Missing or invalid authorization header',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, config.jwt.secret) as {
|
||||||
|
id: string;
|
||||||
|
nostr_pubkey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
req.user = decoded;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(401).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'INVALID_TOKEN',
|
||||||
|
message: 'Invalid or expired token',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to verify admin API key
|
||||||
|
*/
|
||||||
|
export const verifyAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const adminKey = req.headers['x-admin-key'];
|
||||||
|
|
||||||
|
if (!adminKey || adminKey !== config.admin.apiKey) {
|
||||||
|
return res.status(403).json({
|
||||||
|
version: '1.0',
|
||||||
|
error: 'FORBIDDEN',
|
||||||
|
message: 'Invalid admin API key',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional auth - doesn't fail if no token
|
||||||
|
*/
|
||||||
|
export const optionalAuth = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, config.jwt.secret) as {
|
||||||
|
id: string;
|
||||||
|
nostr_pubkey: string;
|
||||||
|
};
|
||||||
|
req.user = decoded;
|
||||||
|
} catch (error) {
|
||||||
|
// Token invalid but we don't fail
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
61
back_end/src/middleware/rateLimit.ts
Normal file
61
back_end/src/middleware/rateLimit.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limiter for buy endpoint
|
||||||
|
* Max 10 calls per IP per minute
|
||||||
|
*/
|
||||||
|
export const buyRateLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
max: 10,
|
||||||
|
message: {
|
||||||
|
version: '1.0',
|
||||||
|
error: 'RATE_LIMIT',
|
||||||
|
message: 'Too many purchase requests, please try again later',
|
||||||
|
retry_after: 60,
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
// Skip failed requests - don't count them against the limit
|
||||||
|
skipFailedRequests: true,
|
||||||
|
// Use IP from request, ignore X-Forwarded-For in development
|
||||||
|
validate: { xForwardedForHeader: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limiter for ticket status endpoint
|
||||||
|
* Max 60 calls per minute
|
||||||
|
*/
|
||||||
|
export const ticketStatusRateLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
max: 60,
|
||||||
|
message: {
|
||||||
|
version: '1.0',
|
||||||
|
error: 'RATE_LIMIT',
|
||||||
|
message: 'Too many status requests, please try again later',
|
||||||
|
retry_after: 60,
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skipFailedRequests: true,
|
||||||
|
validate: { xForwardedForHeader: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General rate limiter
|
||||||
|
* Max 100 requests per minute
|
||||||
|
*/
|
||||||
|
export const generalRateLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
max: 100,
|
||||||
|
message: {
|
||||||
|
version: '1.0',
|
||||||
|
error: 'RATE_LIMIT',
|
||||||
|
message: 'Too many requests, please try again later',
|
||||||
|
retry_after: 60,
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skipFailedRequests: true,
|
||||||
|
validate: { xForwardedForHeader: false },
|
||||||
|
});
|
||||||
|
|
||||||
130
back_end/src/routes/admin.ts
Normal file
130
back_end/src/routes/admin.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import {
|
||||||
|
listCycles,
|
||||||
|
runDrawManually,
|
||||||
|
retryPayout,
|
||||||
|
listPayouts
|
||||||
|
} from '../controllers/admin';
|
||||||
|
import { verifyAdmin } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All admin routes require admin key
|
||||||
|
router.use(verifyAdmin);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /admin/cycles:
|
||||||
|
* get:
|
||||||
|
* summary: List all jackpot cycles
|
||||||
|
* tags: [Admin]
|
||||||
|
* security:
|
||||||
|
* - adminKey: []
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: status
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Filter by status
|
||||||
|
* - in: query
|
||||||
|
* name: cycle_type
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Filter by cycle type
|
||||||
|
* - in: query
|
||||||
|
* name: limit
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* default: 50
|
||||||
|
* - in: query
|
||||||
|
* name: offset
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* default: 0
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of cycles
|
||||||
|
* 403:
|
||||||
|
* description: Invalid admin key
|
||||||
|
*/
|
||||||
|
router.get('/cycles', listCycles);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /admin/cycles/{id}/run-draw:
|
||||||
|
* post:
|
||||||
|
* summary: Manually trigger a draw for a cycle
|
||||||
|
* tags: [Admin]
|
||||||
|
* security:
|
||||||
|
* - adminKey: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: uuid
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Draw executed successfully
|
||||||
|
* 400:
|
||||||
|
* description: Draw failed or invalid cycle
|
||||||
|
* 403:
|
||||||
|
* description: Invalid admin key
|
||||||
|
*/
|
||||||
|
router.post('/cycles/:id/run-draw', runDrawManually);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /admin/payouts:
|
||||||
|
* get:
|
||||||
|
* summary: List all payouts
|
||||||
|
* tags: [Admin]
|
||||||
|
* security:
|
||||||
|
* - adminKey: []
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: status
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Filter by status
|
||||||
|
* - in: query
|
||||||
|
* name: limit
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* default: 50
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of payouts
|
||||||
|
* 403:
|
||||||
|
* description: Invalid admin key
|
||||||
|
*/
|
||||||
|
router.get('/payouts', listPayouts);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /admin/payouts/{id}/retry:
|
||||||
|
* post:
|
||||||
|
* summary: Retry a failed payout
|
||||||
|
* tags: [Admin]
|
||||||
|
* security:
|
||||||
|
* - adminKey: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: uuid
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Payout retry successful
|
||||||
|
* 400:
|
||||||
|
* description: Retry failed or invalid payout
|
||||||
|
* 403:
|
||||||
|
* description: Invalid admin key
|
||||||
|
*/
|
||||||
|
router.post('/payouts/:id/retry', retryPayout);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
191
back_end/src/routes/public.ts
Normal file
191
back_end/src/routes/public.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import {
|
||||||
|
getNextJackpot,
|
||||||
|
buyTickets,
|
||||||
|
getTicketStatus,
|
||||||
|
getPastWins
|
||||||
|
} from '../controllers/public';
|
||||||
|
import { buyRateLimiter, ticketStatusRateLimiter } from '../middleware/rateLimit';
|
||||||
|
import { optionalAuth } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /jackpot/next:
|
||||||
|
* get:
|
||||||
|
* summary: Get next upcoming jackpot cycle
|
||||||
|
* tags: [Public]
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Next jackpot cycle information
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* version:
|
||||||
|
* type: string
|
||||||
|
* example: "1.0"
|
||||||
|
* data:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* lottery:
|
||||||
|
* $ref: '#/components/schemas/Lottery'
|
||||||
|
* cycle:
|
||||||
|
* $ref: '#/components/schemas/JackpotCycle'
|
||||||
|
* 503:
|
||||||
|
* description: No active lottery or cycle available
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
*/
|
||||||
|
router.get('/jackpot/next', getNextJackpot);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /jackpot/buy:
|
||||||
|
* post:
|
||||||
|
* summary: Purchase lottery tickets
|
||||||
|
* tags: [Public]
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - tickets
|
||||||
|
* - lightning_address
|
||||||
|
* properties:
|
||||||
|
* tickets:
|
||||||
|
* type: integer
|
||||||
|
* minimum: 1
|
||||||
|
* maximum: 100
|
||||||
|
* description: Number of tickets to purchase
|
||||||
|
* example: 5
|
||||||
|
* lightning_address:
|
||||||
|
* type: string
|
||||||
|
* description: Lightning Address for receiving payouts
|
||||||
|
* example: "user@getalby.com"
|
||||||
|
* nostr_pubkey:
|
||||||
|
* type: string
|
||||||
|
* description: Optional Nostr public key
|
||||||
|
* example: "npub1..."
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Invoice created successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* version:
|
||||||
|
* type: string
|
||||||
|
* data:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* ticket_purchase_id:
|
||||||
|
* type: string
|
||||||
|
* format: uuid
|
||||||
|
* public_url:
|
||||||
|
* type: string
|
||||||
|
* invoice:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* payment_request:
|
||||||
|
* type: string
|
||||||
|
* description: BOLT11 invoice
|
||||||
|
* amount_sats:
|
||||||
|
* type: integer
|
||||||
|
* 400:
|
||||||
|
* description: Invalid input
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
*/
|
||||||
|
router.post('/jackpot/buy', buyRateLimiter, optionalAuth, buyTickets);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /jackpot/past-wins:
|
||||||
|
* get:
|
||||||
|
* summary: List recent jackpot winners
|
||||||
|
* tags: [Public]
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: limit
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* default: 20
|
||||||
|
* - in: query
|
||||||
|
* name: offset
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* default: 0
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of completed jackpots and their winners
|
||||||
|
* 500:
|
||||||
|
* description: Failed to load past wins
|
||||||
|
*/
|
||||||
|
router.get('/jackpot/past-wins', getPastWins);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /tickets/{id}:
|
||||||
|
* get:
|
||||||
|
* summary: Get ticket purchase status
|
||||||
|
* tags: [Public]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* format: uuid
|
||||||
|
* description: Ticket purchase ID
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Ticket status retrieved successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* version:
|
||||||
|
* type: string
|
||||||
|
* data:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* purchase:
|
||||||
|
* $ref: '#/components/schemas/TicketPurchase'
|
||||||
|
* tickets:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* $ref: '#/components/schemas/Ticket'
|
||||||
|
* cycle:
|
||||||
|
* $ref: '#/components/schemas/JackpotCycle'
|
||||||
|
* result:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* has_drawn:
|
||||||
|
* type: boolean
|
||||||
|
* is_winner:
|
||||||
|
* type: boolean
|
||||||
|
* payout:
|
||||||
|
* type: object
|
||||||
|
* nullable: true
|
||||||
|
* 404:
|
||||||
|
* description: Ticket purchase not found
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Error'
|
||||||
|
*/
|
||||||
|
router.get('/tickets/:id', ticketStatusRateLimiter, getTicketStatus);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
152
back_end/src/routes/user.ts
Normal file
152
back_end/src/routes/user.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import {
|
||||||
|
nostrAuth,
|
||||||
|
getProfile,
|
||||||
|
updateLightningAddress,
|
||||||
|
getUserTickets,
|
||||||
|
getUserWins
|
||||||
|
} from '../controllers/user';
|
||||||
|
import { verifyToken } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /auth/nostr:
|
||||||
|
* post:
|
||||||
|
* summary: Authenticate with Nostr
|
||||||
|
* tags: [User]
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - nostr_pubkey
|
||||||
|
* - signed_message
|
||||||
|
* - nonce
|
||||||
|
* properties:
|
||||||
|
* nostr_pubkey:
|
||||||
|
* type: string
|
||||||
|
* description: Nostr public key (hex or npub)
|
||||||
|
* signed_message:
|
||||||
|
* type: string
|
||||||
|
* description: Signature of the nonce
|
||||||
|
* nonce:
|
||||||
|
* type: string
|
||||||
|
* description: Random nonce for signature verification
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Authentication successful
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* version:
|
||||||
|
* type: string
|
||||||
|
* data:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* token:
|
||||||
|
* type: string
|
||||||
|
* description: JWT token
|
||||||
|
* user:
|
||||||
|
* type: object
|
||||||
|
* 400:
|
||||||
|
* description: Invalid public key or signature
|
||||||
|
*/
|
||||||
|
router.post('/auth/nostr', nostrAuth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /me:
|
||||||
|
* get:
|
||||||
|
* summary: Get user profile
|
||||||
|
* tags: [User]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: User profile and statistics
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
*/
|
||||||
|
router.get('/me', verifyToken, getProfile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /me/lightning-address:
|
||||||
|
* patch:
|
||||||
|
* summary: Update user's Lightning Address
|
||||||
|
* tags: [User]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - lightning_address
|
||||||
|
* properties:
|
||||||
|
* lightning_address:
|
||||||
|
* type: string
|
||||||
|
* example: "user@getalby.com"
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Lightning Address updated
|
||||||
|
* 400:
|
||||||
|
* description: Invalid Lightning Address
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
*/
|
||||||
|
router.patch('/me/lightning-address', verifyToken, updateLightningAddress);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /me/tickets:
|
||||||
|
* get:
|
||||||
|
* summary: Get user's ticket purchases
|
||||||
|
* tags: [User]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: limit
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* default: 50
|
||||||
|
* - in: query
|
||||||
|
* name: offset
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* default: 0
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: User's ticket purchase history
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
*/
|
||||||
|
router.get('/me/tickets', verifyToken, getUserTickets);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /me/wins:
|
||||||
|
* get:
|
||||||
|
* summary: Get user's wins and payouts
|
||||||
|
* tags: [User]
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: User's wins
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized
|
||||||
|
*/
|
||||||
|
router.get('/me/wins', verifyToken, getUserWins);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
44
back_end/src/routes/webhooks.ts
Normal file
44
back_end/src/routes/webhooks.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { handleLNbitsPayment } from '../controllers/webhooks';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /webhooks/lnbits/payment:
|
||||||
|
* post:
|
||||||
|
* summary: LNbits payment webhook callback
|
||||||
|
* description: LNbits calls this endpoint when a Lightning invoice is paid.
|
||||||
|
* tags: [Webhooks]
|
||||||
|
* parameters:
|
||||||
|
* - in: header
|
||||||
|
* name: X-Webhook-Secret
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Shared secret configured in LNbits (or supply `secret` query param)
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* payment_hash:
|
||||||
|
* type: string
|
||||||
|
* amount:
|
||||||
|
* type: number
|
||||||
|
* description: Amount paid in sats
|
||||||
|
* paid:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Webhook processed
|
||||||
|
* 403:
|
||||||
|
* description: Invalid secret
|
||||||
|
* 500:
|
||||||
|
* description: Internal error processing the webhook
|
||||||
|
*/
|
||||||
|
router.post('/lnbits/payment', handleLNbitsPayment);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
199
back_end/src/scheduler/index.ts
Normal file
199
back_end/src/scheduler/index.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import cron from 'node-cron';
|
||||||
|
import { db } from '../database';
|
||||||
|
import { executeDraw } from '../services/draw';
|
||||||
|
import { autoRetryFailedPayouts } from '../services/payout';
|
||||||
|
import config from '../config';
|
||||||
|
import { JackpotCycle } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate future cycles for all cycle types
|
||||||
|
*/
|
||||||
|
async function generateFutureCycles(): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('Running cycle generator...');
|
||||||
|
|
||||||
|
// Get active lottery
|
||||||
|
const lotteryResult = await db.query(
|
||||||
|
`SELECT * FROM lotteries WHERE status = 'active' LIMIT 1`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lotteryResult.rows.length === 0) {
|
||||||
|
console.log('No active lottery found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lottery = lotteryResult.rows[0];
|
||||||
|
const cycleTypes: Array<'hourly' | 'daily' | 'weekly' | 'monthly'> = ['hourly', 'daily'];
|
||||||
|
|
||||||
|
for (const cycleType of cycleTypes) {
|
||||||
|
await generateCyclesForType(lottery.id, cycleType);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cycle generation error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cycles for a specific type
|
||||||
|
*/
|
||||||
|
async function generateCyclesForType(
|
||||||
|
lotteryId: string,
|
||||||
|
cycleType: 'hourly' | 'daily' | 'weekly' | 'monthly'
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Determine horizon (how far in the future to generate)
|
||||||
|
const horizonHours = cycleType === 'hourly' ? 48 : 168; // 48h for hourly, 1 week for daily
|
||||||
|
const horizonDate = new Date(Date.now() + horizonHours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Get latest cycle for this type
|
||||||
|
const latestResult = await db.query<JackpotCycle>(
|
||||||
|
`SELECT * FROM jackpot_cycles
|
||||||
|
WHERE lottery_id = $1 AND cycle_type = $2
|
||||||
|
ORDER BY sequence_number DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[lotteryId, cycleType]
|
||||||
|
);
|
||||||
|
|
||||||
|
let lastScheduledAt: Date;
|
||||||
|
let sequenceNumber: number;
|
||||||
|
|
||||||
|
if (latestResult.rows.length === 0) {
|
||||||
|
// No cycles exist, start from now
|
||||||
|
lastScheduledAt = new Date();
|
||||||
|
sequenceNumber = 0;
|
||||||
|
} else {
|
||||||
|
const latest = latestResult.rows[0];
|
||||||
|
lastScheduledAt = new Date(latest.scheduled_at);
|
||||||
|
sequenceNumber = latest.sequence_number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate cycles until horizon
|
||||||
|
while (lastScheduledAt < horizonDate) {
|
||||||
|
sequenceNumber++;
|
||||||
|
|
||||||
|
// Calculate next scheduled time
|
||||||
|
let nextScheduledAt: Date;
|
||||||
|
|
||||||
|
switch (cycleType) {
|
||||||
|
case 'hourly':
|
||||||
|
nextScheduledAt = new Date(lastScheduledAt.getTime() + 60 * 60 * 1000);
|
||||||
|
break;
|
||||||
|
case 'daily':
|
||||||
|
nextScheduledAt = new Date(lastScheduledAt.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
nextScheduledAt.setHours(20, 0, 0, 0); // 8 PM UTC
|
||||||
|
break;
|
||||||
|
case 'weekly':
|
||||||
|
nextScheduledAt = new Date(lastScheduledAt.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||||
|
break;
|
||||||
|
case 'monthly':
|
||||||
|
nextScheduledAt = new Date(lastScheduledAt);
|
||||||
|
nextScheduledAt.setMonth(nextScheduledAt.getMonth() + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sales open immediately, close at draw time
|
||||||
|
const salesOpenAt = new Date();
|
||||||
|
const salesCloseAt = nextScheduledAt;
|
||||||
|
|
||||||
|
// Check if cycle already exists
|
||||||
|
const existingResult = await db.query(
|
||||||
|
`SELECT id FROM jackpot_cycles
|
||||||
|
WHERE lottery_id = $1 AND cycle_type = $2 AND sequence_number = $3`,
|
||||||
|
[lotteryId, cycleType, sequenceNumber]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingResult.rows.length === 0) {
|
||||||
|
// Create new cycle with explicit UUID generation
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const cycleId = crypto.randomUUID();
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO jackpot_cycles (
|
||||||
|
id, lottery_id, cycle_type, sequence_number, scheduled_at,
|
||||||
|
sales_open_at, sales_close_at, status
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[
|
||||||
|
cycleId,
|
||||||
|
lotteryId,
|
||||||
|
cycleType,
|
||||||
|
sequenceNumber,
|
||||||
|
nextScheduledAt.toISOString(),
|
||||||
|
salesOpenAt.toISOString(),
|
||||||
|
salesCloseAt.toISOString(),
|
||||||
|
'scheduled',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Created ${cycleType} cycle #${sequenceNumber} (${cycleId}) for ${nextScheduledAt.toISOString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScheduledAt = nextScheduledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error generating ${cycleType} cycles:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for cycles that need to be drawn
|
||||||
|
*/
|
||||||
|
async function checkAndExecuteDraws(): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('Checking for cycles to draw...');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Find cycles that are ready to draw
|
||||||
|
const result = await db.query<JackpotCycle>(
|
||||||
|
`SELECT * FROM jackpot_cycles
|
||||||
|
WHERE status IN ('scheduled', 'sales_open')
|
||||||
|
AND scheduled_at <= $1
|
||||||
|
ORDER BY scheduled_at ASC
|
||||||
|
LIMIT 10`,
|
||||||
|
[now.toISOString()]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Found ${result.rows.length} cycles ready for draw`);
|
||||||
|
|
||||||
|
for (const cycle of result.rows) {
|
||||||
|
console.log(`Executing draw for cycle ${cycle.id} (${cycle.cycle_type})`);
|
||||||
|
await executeDraw(cycle.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Draw execution scheduler error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start all schedulers
|
||||||
|
*/
|
||||||
|
export function startSchedulers(): void {
|
||||||
|
console.log('Starting schedulers...');
|
||||||
|
|
||||||
|
// Cycle generator - every 5 minutes (or configured interval)
|
||||||
|
const cycleGenInterval = Math.max(config.scheduler.cycleGeneratorIntervalSeconds, 60);
|
||||||
|
cron.schedule(`*/${Math.floor(cycleGenInterval / 60)} * * * *`, generateFutureCycles);
|
||||||
|
|
||||||
|
console.log(`✓ Cycle generator scheduled (every ${cycleGenInterval}s)`);
|
||||||
|
|
||||||
|
// Draw executor - every minute (or configured interval)
|
||||||
|
const drawInterval = Math.max(config.scheduler.drawIntervalSeconds, 30);
|
||||||
|
cron.schedule(`*/${Math.floor(drawInterval / 60)} * * * *`, checkAndExecuteDraws);
|
||||||
|
|
||||||
|
console.log(`✓ Draw executor scheduled (every ${drawInterval}s)`);
|
||||||
|
|
||||||
|
// Payout retry - every 10 minutes
|
||||||
|
cron.schedule('*/10 * * * *', autoRetryFailedPayouts);
|
||||||
|
|
||||||
|
console.log(`✓ Payout retry scheduled (every 10 minutes)`);
|
||||||
|
|
||||||
|
// Run immediately on startup
|
||||||
|
setTimeout(() => {
|
||||||
|
generateFutureCycles();
|
||||||
|
checkAndExecuteDraws();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
443
back_end/src/services/draw.ts
Normal file
443
back_end/src/services/draw.ts
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { db } from '../database';
|
||||||
|
import { lnbitsService } from './lnbits';
|
||||||
|
import { JackpotCycle, Ticket, Payout } from '../types';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
interface DrawResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
winningTicketId?: string;
|
||||||
|
potAfterFeeSats?: number;
|
||||||
|
payoutStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PayoutAttemptResult {
|
||||||
|
status: 'paid' | 'failed';
|
||||||
|
retryCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANDOM_INDEX_BYTES = 8;
|
||||||
|
|
||||||
|
function pickRandomIndex(length: number): number {
|
||||||
|
if (length <= 0) {
|
||||||
|
throw new Error('Cannot pick random index from empty list');
|
||||||
|
}
|
||||||
|
const randomBytes = crypto.randomBytes(RANDOM_INDEX_BYTES);
|
||||||
|
const randomValue = BigInt('0x' + randomBytes.toString('hex'));
|
||||||
|
return Number(randomValue % BigInt(length));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attemptImmediatePayout(
|
||||||
|
client: { query: typeof db.query },
|
||||||
|
payoutId: string,
|
||||||
|
lightningAddress: string,
|
||||||
|
amountSats: number
|
||||||
|
): Promise<PayoutAttemptResult> {
|
||||||
|
try {
|
||||||
|
const paymentResult = await lnbitsService.payLightningAddress(
|
||||||
|
lightningAddress,
|
||||||
|
amountSats
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE payouts
|
||||||
|
SET status = 'paid', lnbits_payment_id = $1, updated_at = NOW()
|
||||||
|
WHERE id = $2`,
|
||||||
|
[paymentResult.payment_hash, payoutId]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Payout successful:', {
|
||||||
|
payout_id: payoutId,
|
||||||
|
amount_sats: amountSats,
|
||||||
|
lightning_address: lightningAddress,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: 'paid' };
|
||||||
|
} catch (error: any) {
|
||||||
|
const updateResult = await client.query(
|
||||||
|
`UPDATE payouts
|
||||||
|
SET status = 'failed',
|
||||||
|
error_message = $1,
|
||||||
|
retry_count = retry_count + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING retry_count`,
|
||||||
|
[error.message, payoutId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const retryCount = updateResult.rows.length > 0
|
||||||
|
? parseInt(updateResult.rows[0].retry_count.toString())
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
console.error('Payout failed:', {
|
||||||
|
payout_id: payoutId,
|
||||||
|
error: error.message,
|
||||||
|
retry_count: retryCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: 'failed', retryCount };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute draw for a specific cycle
|
||||||
|
*/
|
||||||
|
export async function executeDraw(cycleId: string): Promise<DrawResult> {
|
||||||
|
const client = await db.getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Lock the cycle row for update
|
||||||
|
const cycleResult = await client.query<JackpotCycle>(
|
||||||
|
`SELECT * FROM jackpot_cycles WHERE id = $1 FOR UPDATE`,
|
||||||
|
[cycleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cycleResult.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'CYCLE_NOT_FOUND',
|
||||||
|
message: 'Cycle not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = cycleResult.rows[0];
|
||||||
|
|
||||||
|
// Check if cycle can be drawn
|
||||||
|
if (cycle.status === 'completed') {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'ALREADY_COMPLETED',
|
||||||
|
message: 'Draw already completed for this cycle',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cycle.status === 'cancelled') {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'CYCLE_CANCELLED',
|
||||||
|
message: 'Cannot draw cancelled cycle',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's time to draw
|
||||||
|
const now = new Date();
|
||||||
|
if (now < cycle.scheduled_at) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'TOO_EARLY',
|
||||||
|
message: 'Draw time has not arrived yet',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status to drawing
|
||||||
|
await client.query(
|
||||||
|
`UPDATE jackpot_cycles SET status = 'drawing', updated_at = NOW() WHERE id = $1`,
|
||||||
|
[cycleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all tickets for this cycle
|
||||||
|
const ticketsResult = await client.query<Ticket>(
|
||||||
|
`SELECT * FROM tickets WHERE cycle_id = $1 ORDER BY serial_number`,
|
||||||
|
[cycleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tickets = ticketsResult.rows;
|
||||||
|
|
||||||
|
// Handle case with no tickets
|
||||||
|
if (tickets.length === 0) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE jackpot_cycles
|
||||||
|
SET status = 'completed', pot_after_fee_sats = 0, updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[cycleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log draw
|
||||||
|
const emptyDrawLogId = crypto.randomUUID();
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO draw_logs (
|
||||||
|
id, cycle_id, number_of_tickets, pot_total_sats, fee_percent,
|
||||||
|
pot_after_fee_sats, rng_source
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
[emptyDrawLogId, cycleId, 0, 0, 0, 0, 'crypto.randomInt']
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
console.log('Draw completed with no tickets:', { cycle_id: cycleId });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Draw completed with no tickets',
|
||||||
|
potAfterFeeSats: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lottery for fee percent
|
||||||
|
const lotteryResult = await client.query(
|
||||||
|
`SELECT * FROM lotteries WHERE id = $1`,
|
||||||
|
[cycle.lottery_id]
|
||||||
|
);
|
||||||
|
const lottery = lotteryResult.rows[0];
|
||||||
|
|
||||||
|
// Calculate pot after fee
|
||||||
|
const potTotalSats = parseInt(cycle.pot_total_sats.toString());
|
||||||
|
const feePercent = lottery.fee_percent;
|
||||||
|
const potAfterFeeSats = Math.floor(potTotalSats * (100 - feePercent) / 100);
|
||||||
|
|
||||||
|
// Select random winning ticket via cryptographic randomness
|
||||||
|
const randomIndex = pickRandomIndex(tickets.length);
|
||||||
|
const winningTicket = tickets[randomIndex];
|
||||||
|
|
||||||
|
console.log('Draw execution:', {
|
||||||
|
cycle_id: cycleId,
|
||||||
|
total_tickets: tickets.length,
|
||||||
|
pot_total_sats: potTotalSats,
|
||||||
|
pot_after_fee_sats: potAfterFeeSats,
|
||||||
|
random_index: randomIndex,
|
||||||
|
winning_ticket_id: winningTicket.id,
|
||||||
|
winning_serial: winningTicket.serial_number,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update cycle with winner
|
||||||
|
await client.query(
|
||||||
|
`UPDATE jackpot_cycles
|
||||||
|
SET pot_after_fee_sats = $1,
|
||||||
|
winning_ticket_id = $2,
|
||||||
|
winning_lightning_address = $3,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $4`,
|
||||||
|
[potAfterFeeSats, winningTicket.id, winningTicket.lightning_address, cycleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create payout record
|
||||||
|
const payoutId = crypto.randomUUID();
|
||||||
|
|
||||||
|
const payoutResult = await client.query<Payout>(
|
||||||
|
`INSERT INTO payouts (
|
||||||
|
id, lottery_id, cycle_id, ticket_id, user_id, lightning_address,
|
||||||
|
amount_sats, status
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
payoutId,
|
||||||
|
cycle.lottery_id,
|
||||||
|
cycleId,
|
||||||
|
winningTicket.id,
|
||||||
|
winningTicket.user_id,
|
||||||
|
winningTicket.lightning_address,
|
||||||
|
potAfterFeeSats,
|
||||||
|
'pending',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const payout = payoutResult.rows[0];
|
||||||
|
|
||||||
|
// Log draw for audit
|
||||||
|
const drawLogId = crypto.randomUUID();
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO draw_logs (
|
||||||
|
id, cycle_id, number_of_tickets, pot_total_sats, fee_percent,
|
||||||
|
pot_after_fee_sats, winner_ticket_id, winner_lightning_address,
|
||||||
|
rng_source, selected_index
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||||
|
[
|
||||||
|
drawLogId,
|
||||||
|
cycleId,
|
||||||
|
tickets.length,
|
||||||
|
potTotalSats,
|
||||||
|
feePercent,
|
||||||
|
potAfterFeeSats,
|
||||||
|
winningTicket.id,
|
||||||
|
winningTicket.lightning_address,
|
||||||
|
'crypto.randomInt',
|
||||||
|
randomIndex,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const payoutAttempt = await attemptImmediatePayout(
|
||||||
|
client,
|
||||||
|
payout.id,
|
||||||
|
winningTicket.lightning_address,
|
||||||
|
potAfterFeeSats
|
||||||
|
);
|
||||||
|
const payoutStatus = payoutAttempt.status;
|
||||||
|
|
||||||
|
// Mark cycle as completed
|
||||||
|
await client.query(
|
||||||
|
`UPDATE jackpot_cycles SET status = 'completed', updated_at = NOW() WHERE id = $1`,
|
||||||
|
[cycleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
winningTicketId: winningTicket.id,
|
||||||
|
potAfterFeeSats,
|
||||||
|
payoutStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Draw execution error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'DRAW_EXECUTION_ERROR',
|
||||||
|
message: error.message,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (client.release) {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function redrawWinner(cycleId: string): Promise<boolean> {
|
||||||
|
const client = await db.getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const cycleResult = await client.query<JackpotCycle>(
|
||||||
|
`SELECT * FROM jackpot_cycles WHERE id = $1`,
|
||||||
|
[cycleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cycleResult.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.warn('Cannot redraw winner, cycle not found', { cycle_id: cycleId });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = cycleResult.rows[0];
|
||||||
|
|
||||||
|
const candidatesResult = await client.query<Ticket>(
|
||||||
|
`SELECT * FROM tickets
|
||||||
|
WHERE cycle_id = $1
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT ticket_id FROM payouts WHERE cycle_id = $2
|
||||||
|
)
|
||||||
|
ORDER BY serial_number`,
|
||||||
|
[cycleId, cycleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (candidatesResult.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.warn('No eligible tickets remain for redraw', { cycle_id: cycleId });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickets = candidatesResult.rows;
|
||||||
|
const randomIndex = pickRandomIndex(tickets.length);
|
||||||
|
const winningTicket = tickets[randomIndex];
|
||||||
|
|
||||||
|
let potAfterFeeSats = cycle.pot_after_fee_sats
|
||||||
|
? parseInt(cycle.pot_after_fee_sats.toString())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const potTotalSats = parseInt(cycle.pot_total_sats.toString());
|
||||||
|
|
||||||
|
if (potAfterFeeSats === null) {
|
||||||
|
const lotteryResult = await client.query(
|
||||||
|
`SELECT fee_percent FROM lotteries WHERE id = $1`,
|
||||||
|
[cycle.lottery_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const feePercent = lotteryResult.rows.length > 0
|
||||||
|
? lotteryResult.rows[0].fee_percent
|
||||||
|
: config.lottery.defaultHouseFeePercent;
|
||||||
|
|
||||||
|
potAfterFeeSats = Math.floor(potTotalSats * (100 - feePercent) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE jackpot_cycles
|
||||||
|
SET winning_ticket_id = $1,
|
||||||
|
winning_lightning_address = $2,
|
||||||
|
pot_after_fee_sats = $3,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $4`,
|
||||||
|
[winningTicket.id, winningTicket.lightning_address, potAfterFeeSats, cycleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const payoutId = crypto.randomUUID();
|
||||||
|
|
||||||
|
const payoutResult = await client.query<Payout>(
|
||||||
|
`INSERT INTO payouts (
|
||||||
|
id, lottery_id, cycle_id, ticket_id, user_id, lightning_address,
|
||||||
|
amount_sats, status
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
payoutId,
|
||||||
|
cycle.lottery_id,
|
||||||
|
cycleId,
|
||||||
|
winningTicket.id,
|
||||||
|
winningTicket.user_id,
|
||||||
|
winningTicket.lightning_address,
|
||||||
|
potAfterFeeSats,
|
||||||
|
'pending',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const payout = payoutResult.rows[0];
|
||||||
|
|
||||||
|
const drawLogId = crypto.randomUUID();
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO draw_logs (
|
||||||
|
id, cycle_id, number_of_tickets, pot_total_sats,
|
||||||
|
pot_after_fee_sats, winner_ticket_id, winner_lightning_address,
|
||||||
|
rng_source, selected_index
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
[
|
||||||
|
drawLogId,
|
||||||
|
cycleId,
|
||||||
|
tickets.length,
|
||||||
|
potTotalSats,
|
||||||
|
potAfterFeeSats,
|
||||||
|
winningTicket.id,
|
||||||
|
winningTicket.lightning_address,
|
||||||
|
'redraw',
|
||||||
|
randomIndex,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const attempt = await attemptImmediatePayout(
|
||||||
|
client,
|
||||||
|
payout.id,
|
||||||
|
winningTicket.lightning_address,
|
||||||
|
potAfterFeeSats
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
console.log('Winner redrawn for cycle', {
|
||||||
|
cycle_id: cycleId,
|
||||||
|
new_winner_ticket_id: winningTicket.id,
|
||||||
|
payout_status: attempt.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error('Redraw winner error:', error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (client.release) {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
156
back_end/src/services/lnbits.ts
Normal file
156
back_end/src/services/lnbits.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import config from '../config';
|
||||||
|
|
||||||
|
interface CreateInvoiceParams {
|
||||||
|
amount: number; // sats
|
||||||
|
memo: string;
|
||||||
|
webhook?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Invoice {
|
||||||
|
payment_hash: string;
|
||||||
|
payment_request: string;
|
||||||
|
checking_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PayoutParams {
|
||||||
|
bolt11?: string;
|
||||||
|
description?: string;
|
||||||
|
out?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentResult {
|
||||||
|
payment_hash: string;
|
||||||
|
checking_id: string;
|
||||||
|
paid?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LNbitsService {
|
||||||
|
private client: AxiosInstance;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL: config.lnbits.apiBaseUrl,
|
||||||
|
headers: {
|
||||||
|
'X-Api-Key': config.lnbits.adminKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Lightning invoice
|
||||||
|
*/
|
||||||
|
async createInvoice(params: CreateInvoiceParams): Promise<Invoice> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.post('/api/v1/payments', {
|
||||||
|
out: false,
|
||||||
|
amount: params.amount,
|
||||||
|
memo: params.memo,
|
||||||
|
webhook: params.webhook,
|
||||||
|
unit: 'sat',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
payment_hash: response.data.payment_hash,
|
||||||
|
payment_request: response.data.payment_request,
|
||||||
|
checking_id: response.data.checking_id,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('LNbits create invoice error:', error.response?.data || error.message);
|
||||||
|
throw new Error(`Failed to create invoice: ${error.response?.data?.detail || error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pay a Lightning Address or BOLT11 invoice
|
||||||
|
*/
|
||||||
|
async payLightningAddress(lightningAddress: string, amountSats: number): Promise<PaymentResult> {
|
||||||
|
try {
|
||||||
|
// First resolve the Lightning Address to get LNURL
|
||||||
|
const [username, domain] = lightningAddress.split('@');
|
||||||
|
if (!username || !domain) {
|
||||||
|
throw new Error('Invalid Lightning Address format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch LNURL pay metadata
|
||||||
|
const lnurlResponse = await axios.get(
|
||||||
|
`https://${domain}/.well-known/lnurlp/${username}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const { callback, minSendable, maxSendable } = lnurlResponse.data;
|
||||||
|
|
||||||
|
const amountMsats = amountSats * 1000;
|
||||||
|
|
||||||
|
if (amountMsats < minSendable || amountMsats > maxSendable) {
|
||||||
|
throw new Error('Amount out of range for this Lightning Address');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get invoice from callback
|
||||||
|
const invoiceResponse = await axios.get(callback, {
|
||||||
|
params: { amount: amountMsats },
|
||||||
|
});
|
||||||
|
|
||||||
|
const bolt11 = invoiceResponse.data.pr;
|
||||||
|
|
||||||
|
if (!bolt11) {
|
||||||
|
throw new Error('Failed to get invoice from Lightning Address');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pay the invoice using LNbits
|
||||||
|
const paymentResponse = await this.client.post('/api/v1/payments', {
|
||||||
|
out: true,
|
||||||
|
bolt11: bolt11,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
payment_hash: paymentResponse.data.payment_hash,
|
||||||
|
checking_id: paymentResponse.data.checking_id,
|
||||||
|
paid: true,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('LNbits payout error:', error.response?.data || error.message);
|
||||||
|
throw new Error(`Failed to pay Lightning Address: ${error.response?.data?.detail || error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check payment status
|
||||||
|
*/
|
||||||
|
async checkPaymentStatus(paymentHash: string): Promise<{ paid: boolean }> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.get(`/api/v1/payments/${paymentHash}`);
|
||||||
|
return {
|
||||||
|
paid: response.data.paid || false,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('LNbits check payment error:', error.response?.data || error.message);
|
||||||
|
throw new Error(`Failed to check payment status: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify webhook signature/secret
|
||||||
|
*/
|
||||||
|
verifyWebhook(secret: string): boolean {
|
||||||
|
return secret === config.lnbits.webhookSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check for LNbits connectivity
|
||||||
|
*/
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.client.get('/api/v1/wallet');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LNbits health check failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lnbitsService = new LNbitsService();
|
||||||
|
export default lnbitsService;
|
||||||
|
|
||||||
130
back_end/src/services/paymentMonitor.ts
Normal file
130
back_end/src/services/paymentMonitor.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { db } from '../database';
|
||||||
|
import { lnbitsService } from './lnbits';
|
||||||
|
import { finalizeTicketPurchase } from './paymentProcessor';
|
||||||
|
|
||||||
|
interface PendingInvoice {
|
||||||
|
purchaseId: string;
|
||||||
|
paymentHash: string;
|
||||||
|
addedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
const EXPIRY_MS = 20 * 60 * 1000;
|
||||||
|
|
||||||
|
class PaymentMonitor {
|
||||||
|
private pendingInvoices = new Map<string, PendingInvoice>();
|
||||||
|
private checkInterval: NodeJS.Timeout | null = null;
|
||||||
|
private isChecking = false;
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.checkInterval) {
|
||||||
|
console.log('Payment monitor already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bootstrapPendingInvoices();
|
||||||
|
console.log('✓ Payment monitor started (checking every 3 seconds)');
|
||||||
|
|
||||||
|
// Immediate check, then schedule interval
|
||||||
|
this.checkPendingPayments();
|
||||||
|
this.checkInterval = setInterval(() => {
|
||||||
|
this.checkPendingPayments();
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.checkInterval) {
|
||||||
|
clearInterval(this.checkInterval);
|
||||||
|
this.checkInterval = null;
|
||||||
|
console.log('Payment monitor stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addInvoice(purchaseId: string, paymentHash?: string): void {
|
||||||
|
if (!purchaseId || !paymentHash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingInvoices.set(purchaseId, {
|
||||||
|
purchaseId,
|
||||||
|
paymentHash,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📋 Monitoring payment: ${purchaseId} (${this.pendingInvoices.size} pending)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeInvoice(purchaseId: string): void {
|
||||||
|
this.pendingInvoices.delete(purchaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
pending: this.pendingInvoices.size,
|
||||||
|
checking: this.isChecking,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bootstrapPendingInvoices(): Promise<void> {
|
||||||
|
const pendingResult = await db.query<{ id: string; lnbits_payment_hash: string }>(
|
||||||
|
`SELECT id, lnbits_payment_hash
|
||||||
|
FROM ticket_purchases
|
||||||
|
WHERE invoice_status = 'pending'
|
||||||
|
AND lnbits_payment_hash IS NOT NULL
|
||||||
|
AND lnbits_payment_hash <> ''`
|
||||||
|
);
|
||||||
|
|
||||||
|
pendingResult.rows.forEach(row => {
|
||||||
|
this.pendingInvoices.set(row.id, {
|
||||||
|
purchaseId: row.id,
|
||||||
|
paymentHash: row.lnbits_payment_hash,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingResult.rows.length > 0) {
|
||||||
|
console.log(`📎 Restored ${pendingResult.rows.length} pending invoice(s) into monitor`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkPendingPayments(): Promise<void> {
|
||||||
|
if (this.isChecking || this.pendingInvoices.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isChecking = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
const invoices = Array.from(this.pendingInvoices.values());
|
||||||
|
|
||||||
|
for (const invoice of invoices) {
|
||||||
|
if (now - invoice.addedAt > EXPIRY_MS) {
|
||||||
|
console.log(`⏰ Invoice expired: ${invoice.purchaseId}`);
|
||||||
|
this.pendingInvoices.delete(invoice.purchaseId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await lnbitsService.checkPaymentStatus(invoice.paymentHash);
|
||||||
|
if (status.paid) {
|
||||||
|
console.log(`💰 Payment detected via monitor: ${invoice.purchaseId}`);
|
||||||
|
await finalizeTicketPurchase(invoice.purchaseId);
|
||||||
|
this.pendingInvoices.delete(invoice.purchaseId);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error checking payment ${invoice.purchaseId}:`, error?.message || error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Payment monitor error:', error);
|
||||||
|
} finally {
|
||||||
|
this.isChecking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paymentMonitor = new PaymentMonitor();
|
||||||
|
export default paymentMonitor;
|
||||||
|
|
||||||
|
|
||||||
129
back_end/src/services/paymentProcessor.ts
Normal file
129
back_end/src/services/paymentProcessor.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { db } from '../database';
|
||||||
|
import { TicketPurchase } from '../types';
|
||||||
|
|
||||||
|
export type FinalizeStatus = 'success' | 'already_paid' | 'not_found';
|
||||||
|
|
||||||
|
export interface FinalizeResult {
|
||||||
|
status: FinalizeStatus;
|
||||||
|
purchaseId?: string;
|
||||||
|
ticketsIssued?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERIAL_MIN = 1;
|
||||||
|
const SERIAL_MAX = 1_000_000_000; // 1 billion possible ticket numbers
|
||||||
|
|
||||||
|
function createSerialNumberGenerator(existingSerials: Set<number>) {
|
||||||
|
return () => {
|
||||||
|
for (let attempts = 0; attempts < 2000; attempts++) {
|
||||||
|
const candidate = crypto.randomInt(SERIAL_MIN, SERIAL_MAX);
|
||||||
|
if (!existingSerials.has(candidate)) {
|
||||||
|
existingSerials.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Unable to generate unique ticket number');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function finalizeTicketPurchase(purchaseId: string): Promise<FinalizeResult> {
|
||||||
|
const client = await db.getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const purchaseResult = await client.query<TicketPurchase>(
|
||||||
|
`SELECT * FROM ticket_purchases WHERE id = $1`,
|
||||||
|
[purchaseId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (purchaseResult.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return { status: 'not_found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchase = purchaseResult.rows[0];
|
||||||
|
|
||||||
|
if (purchase.invoice_status === 'paid') {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return { status: 'already_paid', purchaseId: purchase.id, ticketsIssued: purchase.number_of_tickets };
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ticket_purchases
|
||||||
|
SET invoice_status = 'paid', updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[purchase.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
let ticketsIssued = 0;
|
||||||
|
|
||||||
|
if (purchase.ticket_issue_status === 'not_issued') {
|
||||||
|
const existingSerialsResult = await client.query(
|
||||||
|
`SELECT serial_number FROM tickets WHERE cycle_id = $1`,
|
||||||
|
[purchase.cycle_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const usedSerials = new Set<number>(
|
||||||
|
existingSerialsResult.rows.map((row: any) =>
|
||||||
|
parseInt(row.serial_number?.toString() ?? '0', 10)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateSerialNumber = createSerialNumberGenerator(usedSerials);
|
||||||
|
|
||||||
|
for (let i = 0; i < purchase.number_of_tickets; i++) {
|
||||||
|
const serialNumber = generateSerialNumber();
|
||||||
|
ticketsIssued++;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO tickets (
|
||||||
|
id, lottery_id, cycle_id, ticket_purchase_id, user_id,
|
||||||
|
lightning_address, serial_number
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
[
|
||||||
|
crypto.randomUUID(),
|
||||||
|
purchase.lottery_id,
|
||||||
|
purchase.cycle_id,
|
||||||
|
purchase.id,
|
||||||
|
purchase.user_id,
|
||||||
|
purchase.lightning_address,
|
||||||
|
serialNumber,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ticket_purchases
|
||||||
|
SET ticket_issue_status = 'issued', updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[purchase.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE jackpot_cycles
|
||||||
|
SET pot_total_sats = pot_total_sats + $1, updated_at = NOW()
|
||||||
|
WHERE id = $2`,
|
||||||
|
[purchase.amount_sats, purchase.cycle_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
purchaseId: purchase.id,
|
||||||
|
ticketsIssued: ticketsIssued || purchase.number_of_tickets,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (client.release) {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
176
back_end/src/services/payout.ts
Normal file
176
back_end/src/services/payout.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { db } from '../database';
|
||||||
|
import { lnbitsService } from './lnbits';
|
||||||
|
import { Payout } from '../types';
|
||||||
|
import config from '../config';
|
||||||
|
import { redrawWinner } from './draw';
|
||||||
|
|
||||||
|
interface PayoutRetryResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
status?: string;
|
||||||
|
retryCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RETRY_ATTEMPTS = config.payout.maxAttemptsBeforeRedraw;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry a failed payout
|
||||||
|
*/
|
||||||
|
export async function retryPayoutService(payoutId: string): Promise<PayoutRetryResult> {
|
||||||
|
try {
|
||||||
|
// Get payout
|
||||||
|
const payoutResult = await db.query<Payout>(
|
||||||
|
`SELECT * FROM payouts WHERE id = $1`,
|
||||||
|
[payoutId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (payoutResult.rows.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'PAYOUT_NOT_FOUND',
|
||||||
|
message: 'Payout not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payout = payoutResult.rows[0];
|
||||||
|
|
||||||
|
// Check if payout is in failed status
|
||||||
|
if (payout.status !== 'failed') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'INVALID_STATUS',
|
||||||
|
message: `Cannot retry payout with status: ${payout.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check retry limit
|
||||||
|
if (payout.retry_count >= MAX_RETRY_ATTEMPTS) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'MAX_RETRIES_EXCEEDED',
|
||||||
|
message: `Maximum retry attempts (${MAX_RETRY_ATTEMPTS}) exceeded`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountSats = parseInt(payout.amount_sats.toString());
|
||||||
|
|
||||||
|
console.log('Retrying payout:', {
|
||||||
|
payout_id: payoutId,
|
||||||
|
attempt: payout.retry_count + 1,
|
||||||
|
amount_sats: amountSats,
|
||||||
|
lightning_address: payout.lightning_address,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attempt payout
|
||||||
|
try {
|
||||||
|
const paymentResult = await lnbitsService.payLightningAddress(
|
||||||
|
payout.lightning_address,
|
||||||
|
amountSats
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update payout as paid
|
||||||
|
await db.query(
|
||||||
|
`UPDATE payouts
|
||||||
|
SET status = 'paid',
|
||||||
|
lnbits_payment_id = $1,
|
||||||
|
retry_count = retry_count + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2`,
|
||||||
|
[paymentResult.payment_hash, payoutId]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Payout retry successful:', {
|
||||||
|
payout_id: payoutId,
|
||||||
|
payment_hash: paymentResult.payment_hash,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: 'paid',
|
||||||
|
retryCount: payout.retry_count + 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (payoutError: any) {
|
||||||
|
// Update error message and increment retry count
|
||||||
|
const updateResult = await db.query(
|
||||||
|
`UPDATE payouts
|
||||||
|
SET error_message = $1,
|
||||||
|
retry_count = retry_count + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING retry_count`,
|
||||||
|
[payoutError.message, payoutId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const newRetryCount = updateResult.rows.length > 0
|
||||||
|
? parseInt(updateResult.rows[0].retry_count.toString())
|
||||||
|
: payout.retry_count + 1;
|
||||||
|
|
||||||
|
console.error('Payout retry failed:', {
|
||||||
|
payout_id: payoutId,
|
||||||
|
error: payoutError.message,
|
||||||
|
retry_count: newRetryCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newRetryCount >= MAX_RETRY_ATTEMPTS) {
|
||||||
|
console.warn('Max payout attempts reached. Drawing new winner.', {
|
||||||
|
cycle_id: payout.cycle_id,
|
||||||
|
payout_id: payoutId,
|
||||||
|
});
|
||||||
|
await redrawWinner(payout.cycle_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'PAYOUT_FAILED',
|
||||||
|
message: payoutError.message,
|
||||||
|
status: 'failed',
|
||||||
|
retryCount: newRetryCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Retry payout error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically retry all failed payouts that haven't exceeded max retries
|
||||||
|
*/
|
||||||
|
export async function autoRetryFailedPayouts(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await db.query<Payout>(
|
||||||
|
`SELECT * FROM payouts
|
||||||
|
WHERE status = 'failed'
|
||||||
|
AND retry_count < $1
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 10`,
|
||||||
|
[MAX_RETRY_ATTEMPTS]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Found ${result.rows.length} failed payouts to retry`);
|
||||||
|
|
||||||
|
for (const payout of result.rows) {
|
||||||
|
// Add delay between retries (exponential backoff)
|
||||||
|
const delaySeconds = Math.pow(2, payout.retry_count) * 10;
|
||||||
|
const timeSinceLastUpdate = Date.now() - payout.updated_at.getTime();
|
||||||
|
|
||||||
|
if (timeSinceLastUpdate < delaySeconds * 1000) {
|
||||||
|
console.log(`Skipping payout ${payout.id} - waiting for backoff period`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await retryPayoutService(payout.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto retry failed payouts error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
108
back_end/src/types/index.ts
Normal file
108
back_end/src/types/index.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
export interface Lottery {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: 'active' | 'paused' | 'finished';
|
||||||
|
ticket_price_sats: number;
|
||||||
|
fee_percent: number;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JackpotCycle {
|
||||||
|
id: string;
|
||||||
|
lottery_id: string;
|
||||||
|
cycle_type: 'hourly' | 'daily' | 'weekly' | 'monthly';
|
||||||
|
sequence_number: number;
|
||||||
|
scheduled_at: Date;
|
||||||
|
sales_open_at: Date;
|
||||||
|
sales_close_at: Date;
|
||||||
|
status: 'scheduled' | 'sales_open' | 'drawing' | 'completed' | 'cancelled';
|
||||||
|
pot_total_sats: number;
|
||||||
|
pot_after_fee_sats: number | null;
|
||||||
|
winning_ticket_id: string | null;
|
||||||
|
winning_lightning_address: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketPurchase {
|
||||||
|
id: string;
|
||||||
|
lottery_id: string;
|
||||||
|
cycle_id: string;
|
||||||
|
user_id: string | null;
|
||||||
|
nostr_pubkey: string | null;
|
||||||
|
lightning_address: string;
|
||||||
|
buyer_name: string;
|
||||||
|
number_of_tickets: number;
|
||||||
|
ticket_price_sats: number;
|
||||||
|
amount_sats: number;
|
||||||
|
lnbits_invoice_id: string;
|
||||||
|
lnbits_payment_hash: string;
|
||||||
|
invoice_status: 'pending' | 'paid' | 'expired' | 'cancelled';
|
||||||
|
ticket_issue_status: 'not_issued' | 'issued';
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ticket {
|
||||||
|
id: string;
|
||||||
|
lottery_id: string;
|
||||||
|
cycle_id: string;
|
||||||
|
ticket_purchase_id: string;
|
||||||
|
user_id: string | null;
|
||||||
|
lightning_address: string;
|
||||||
|
serial_number: number;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Payout {
|
||||||
|
id: string;
|
||||||
|
lottery_id: string;
|
||||||
|
cycle_id: string;
|
||||||
|
ticket_id: string;
|
||||||
|
user_id: string | null;
|
||||||
|
lightning_address: string;
|
||||||
|
amount_sats: number;
|
||||||
|
status: 'pending' | 'paid' | 'failed';
|
||||||
|
lnbits_payment_id: string | null;
|
||||||
|
error_message: string | null;
|
||||||
|
retry_count: number;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
nostr_pubkey: string;
|
||||||
|
display_name: string | null;
|
||||||
|
lightning_address: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrawLog {
|
||||||
|
id: string;
|
||||||
|
cycle_id: string;
|
||||||
|
number_of_tickets: number;
|
||||||
|
pot_total_sats: number;
|
||||||
|
fee_percent: number;
|
||||||
|
pot_after_fee_sats: number;
|
||||||
|
winner_ticket_id: string | null;
|
||||||
|
winner_lightning_address: string | null;
|
||||||
|
rng_source: string;
|
||||||
|
selected_index: number | null;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
version: string;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
70
back_end/src/utils/validation.ts
Normal file
70
back_end/src/utils/validation.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Validates a Lightning Address format
|
||||||
|
*/
|
||||||
|
export function validateLightningAddress(address: string): boolean {
|
||||||
|
if (!address || typeof address !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must contain @ symbol
|
||||||
|
if (!address.includes('@')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic email-like format check
|
||||||
|
const parts = address.split('@');
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [username, domain] = parts;
|
||||||
|
|
||||||
|
// Username and domain must not be empty
|
||||||
|
if (!username || !domain) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length check
|
||||||
|
if (address.length > 255) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates Nostr public key (hex or npub format)
|
||||||
|
*/
|
||||||
|
export function validateNostrPubkey(pubkey: string): boolean {
|
||||||
|
if (!pubkey || typeof pubkey !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hex format: 64 characters
|
||||||
|
if (/^[0-9a-f]{64}$/i.test(pubkey)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// npub format (bech32)
|
||||||
|
if (pubkey.startsWith('npub1') && pubkey.length === 63) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates number of tickets
|
||||||
|
*/
|
||||||
|
export function validateTicketCount(count: number, max: number = 100): boolean {
|
||||||
|
return Number.isInteger(count) && count > 0 && count <= max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes input string
|
||||||
|
*/
|
||||||
|
export function sanitizeString(input: string, maxLength: number = 255): string {
|
||||||
|
if (!input) return '';
|
||||||
|
return input.trim().substring(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
19
back_end/tsconfig.json
Normal file
19
back_end/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
|
||||||
75
docker-compose.yml
Normal file
75
docker-compose.yml
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:14-alpine
|
||||||
|
container_name: lightning-lotto-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: lightning_lotto
|
||||||
|
POSTGRES_USER: lottery_user
|
||||||
|
POSTGRES_PASSWORD: lottery_password
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./back_end/src/database/schema.sql:/docker-entrypoint-initdb.d/schema.sql
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
networks:
|
||||||
|
- lightning-lotto-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U lottery_user -d lightning_lotto"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./back_end
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: lightning-lotto-backend
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
APP_PORT: 3000
|
||||||
|
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000}
|
||||||
|
FRONTEND_BASE_URL: ${FRONTEND_BASE_URL:-http://localhost:3001}
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: postgresql://lottery_user:lottery_password@postgres:5432/lightning_lotto
|
||||||
|
LNBITS_API_BASE_URL: ${LNBITS_API_BASE_URL}
|
||||||
|
LNBITS_ADMIN_KEY: ${LNBITS_ADMIN_KEY}
|
||||||
|
LNBITS_WEBHOOK_SECRET: ${LNBITS_WEBHOOK_SECRET}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
ADMIN_API_KEY: ${ADMIN_API_KEY}
|
||||||
|
DEFAULT_TICKET_PRICE_SATS: ${DEFAULT_TICKET_PRICE_SATS:-1000}
|
||||||
|
DEFAULT_HOUSE_FEE_PERCENT: ${DEFAULT_HOUSE_FEE_PERCENT:-5}
|
||||||
|
DRAW_SCHEDULER_INTERVAL_SECONDS: 60
|
||||||
|
CYCLE_GENERATOR_INTERVAL_SECONDS: 300
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
networks:
|
||||||
|
- lightning-lotto-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./front_end
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: lightning-lotto-frontend
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:3000}
|
||||||
|
NEXT_PUBLIC_APP_BASE_URL: ${NEXT_PUBLIC_APP_BASE_URL:-http://localhost:3001}
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
networks:
|
||||||
|
- lightning-lotto-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
lightning-lotto-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
37
front_end/.gitignore
vendored
Normal file
37
front_end/.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
31
front_end/Dockerfile
Normal file
31
front_end/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production image
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Copy built app
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
|
|
||||||
106
front_end/README.md
Normal file
106
front_end/README.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Lightning Lottery Frontend
|
||||||
|
|
||||||
|
Next.js-based frontend for the Lightning Lottery system.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Responsive Design**: Mobile-first, works on all devices
|
||||||
|
- **Real-time Updates**: Live countdown and automatic status polling
|
||||||
|
- **Lightning Payments**: QR code invoice display
|
||||||
|
- **Nostr Authentication**: Optional user login via NIP-07
|
||||||
|
- **User Dashboard**: View tickets, wins, and statistics
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework**: Next.js 14 (App Router)
|
||||||
|
- **Language**: TypeScript
|
||||||
|
- **Styling**: TailwindCSS
|
||||||
|
- **State Management**: Redux Toolkit
|
||||||
|
- **QR Codes**: qrcode.react
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- Backend API running
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configure environment:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
# Edit .env.local with your configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run development server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3001](http://localhost:3001)
|
||||||
|
|
||||||
|
### Production Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
- `NEXT_PUBLIC_API_BASE_URL` - Backend API URL
|
||||||
|
- `NEXT_PUBLIC_APP_BASE_URL` - Frontend public URL
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
- `/` - Home page with current jackpot
|
||||||
|
- `/buy` - Buy lottery tickets
|
||||||
|
- `/tickets/[id]` - Ticket status page (public)
|
||||||
|
- `/dashboard` - User dashboard (Nostr auth required)
|
||||||
|
- `/dashboard/tickets` - User's ticket history
|
||||||
|
- `/dashboard/wins` - User's wins
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Reusable Components
|
||||||
|
|
||||||
|
- `TopBar` - Navigation header
|
||||||
|
- `Footer` - Site footer
|
||||||
|
- `JackpotCountdown` - Live countdown timer
|
||||||
|
- `JackpotPotDisplay` - Pot amount display
|
||||||
|
- `LightningInvoiceCard` - Invoice QR code and copy
|
||||||
|
- `TicketList` - Display ticket serial numbers
|
||||||
|
- `PayoutStatus` - Payout status indicator
|
||||||
|
- `NostrLoginButton` - Nostr authentication
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Vercel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vercel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Netlify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
netlify deploy --prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t lightning-lotto-frontend .
|
||||||
|
docker run -p 3001:3000 lightning-lotto-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
40
front_end/env.example
Normal file
40
front_end/env.example
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Lightning Lottery Frontend - Environment Configuration
|
||||||
|
# Copy this file to .env.local and fill in your values
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# Server Configuration
|
||||||
|
# ======================
|
||||||
|
# Port for Next.js development server (default: 3001)
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# API Configuration
|
||||||
|
# ======================
|
||||||
|
# Backend API base URL
|
||||||
|
# Development: http://localhost:3000
|
||||||
|
# Production: https://api.yourdomain.com
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Frontend application base URL
|
||||||
|
# Development: http://localhost:3001
|
||||||
|
# Production: https://yourdomain.com
|
||||||
|
NEXT_PUBLIC_APP_BASE_URL=http://localhost:3001
|
||||||
|
|
||||||
|
# Backend API fallback port used when the frontend is accessed via a non-localhost host
|
||||||
|
# Keep at 3000 if you run the API on the default dev port
|
||||||
|
NEXT_PUBLIC_BACKEND_PORT=3000
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# Optional: Admin Configuration
|
||||||
|
# ======================
|
||||||
|
# Only needed if you want to use admin features from the frontend
|
||||||
|
# NEXT_PUBLIC_ADMIN_KEY=your_admin_api_key_here
|
||||||
|
|
||||||
|
# ======================
|
||||||
|
# Notes
|
||||||
|
# ======================
|
||||||
|
# - Copy this file to .env.local for local development
|
||||||
|
# - All variables that need to be accessible in the browser must start with NEXT_PUBLIC_
|
||||||
|
# - Never expose sensitive keys in production frontend
|
||||||
|
# - PORT is read by the npm scripts to set the dev server port
|
||||||
|
# - Restart the dev server after changing .env.local
|
||||||
12
front_end/next.config.js
Normal file
12
front_end/next.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL || '',
|
||||||
|
NEXT_PUBLIC_APP_BASE_URL: process.env.NEXT_PUBLIC_APP_BASE_URL || '',
|
||||||
|
NEXT_PUBLIC_BACKEND_PORT: process.env.NEXT_PUBLIC_BACKEND_PORT || '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
|
|
||||||
6833
front_end/package-lock.json
generated
Normal file
6833
front_end/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
front_end/package.json
Normal file
32
front_end/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "lightning-lotto-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p ${PORT:-3001}",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p ${PORT:-3001}",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^2.0.1",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"next": "14.0.4",
|
||||||
|
"qrcode.react": "^3.1.0",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"react-redux": "^9.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "20.10.5",
|
||||||
|
"@types/react": "18.2.45",
|
||||||
|
"@types/react-dom": "18.2.18",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "8.56.0",
|
||||||
|
"eslint-config-next": "14.0.4",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"puppeteer": "^24.31.0",
|
||||||
|
"tailwindcss": "^3.3.6",
|
||||||
|
"typescript": "5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
front_end/postcss.config.js
Normal file
7
front_end/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
190
front_end/src/app/about/page.tsx
Normal file
190
front_end/src/app/about/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import STRINGS from '@/constants/strings';
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-8 text-center text-white">
|
||||||
|
About {STRINGS.app.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Introduction */}
|
||||||
|
<section className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
|
||||||
|
<h2 className="text-2xl font-semibold mb-4 text-bitcoin-orange">
|
||||||
|
What is Lightning Lottery?
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-300 leading-relaxed">
|
||||||
|
Lightning Lottery is a provably fair lottery system built on the Bitcoin Lightning Network.
|
||||||
|
Players can purchase tickets using Lightning payments and winners receive instant payouts
|
||||||
|
directly to their Lightning address. No accounts required, no waiting for withdrawals.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* How It Works */}
|
||||||
|
<section className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
|
||||||
|
<h2 className="text-2xl font-semibold mb-6 text-bitcoin-orange">
|
||||||
|
How It Works
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-bitcoin-orange rounded-full flex items-center justify-center text-white font-bold">
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">Buy Tickets</h3>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Enter your Lightning address and pay the invoice. Each ticket gives you a unique
|
||||||
|
randomly-generated number for the draw.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-bitcoin-orange rounded-full flex items-center justify-center text-white font-bold">
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">Wait for the Draw</h3>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Draws happen on a regular schedule. Watch the countdown and see the pot grow
|
||||||
|
as more players join.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-bitcoin-orange rounded-full flex items-center justify-center text-white font-bold">
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">Win Instantly</h3>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
When the draw happens, a winning ticket is selected using cryptographically secure
|
||||||
|
random number generation. The winner receives the pot automatically via Lightning!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Fairness */}
|
||||||
|
<section className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
|
||||||
|
<h2 className="text-2xl font-semibold mb-4 text-bitcoin-orange">
|
||||||
|
Provably Fair
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4 text-gray-300">
|
||||||
|
<p>
|
||||||
|
Our lottery uses cryptographically secure random number generation (CSPRNG) from
|
||||||
|
Node.js's <code className="bg-gray-800 px-2 py-0.5 rounded text-bitcoin-orange">crypto.randomBytes()</code> module
|
||||||
|
to ensure completely unpredictable results.
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2 text-gray-400">
|
||||||
|
<li>Ticket numbers are randomly generated (not sequential)</li>
|
||||||
|
<li>Winner selection uses 8 bytes of cryptographic randomness</li>
|
||||||
|
<li>No one can predict or influence the outcome</li>
|
||||||
|
<li>All draws are logged and verifiable</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Technical Details */}
|
||||||
|
<section className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
|
||||||
|
<h2 className="text-2xl font-semibold mb-4 text-bitcoin-orange">
|
||||||
|
Technical Details
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Payments</h3>
|
||||||
|
<ul className="text-gray-400 space-y-1 text-sm">
|
||||||
|
<li>• Lightning Network (LNbits)</li>
|
||||||
|
<li>• Instant confirmations</li>
|
||||||
|
<li>• No minimum withdrawal</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Security</h3>
|
||||||
|
<ul className="text-gray-400 space-y-1 text-sm">
|
||||||
|
<li>• CSPRNG for all randomness</li>
|
||||||
|
<li>• Nostr authentication (optional)</li>
|
||||||
|
<li>• Open source codebase</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Draws</h3>
|
||||||
|
<ul className="text-gray-400 space-y-1 text-sm">
|
||||||
|
<li>• Automated on schedule</li>
|
||||||
|
<li>• Instant winner notification</li>
|
||||||
|
<li>• Automatic payout retry</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Transparency</h3>
|
||||||
|
<ul className="text-gray-400 space-y-1 text-sm">
|
||||||
|
<li>• Public winner history</li>
|
||||||
|
<li>• Verifiable ticket numbers</li>
|
||||||
|
<li>• Clear fee structure</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ */}
|
||||||
|
<section className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
|
||||||
|
<h2 className="text-2xl font-semibold mb-6 text-bitcoin-orange">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">
|
||||||
|
Do I need an account?
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
No! You can buy tickets with just a Lightning address. Optionally, you can log in
|
||||||
|
with Nostr to track your tickets and auto-fill your details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">
|
||||||
|
How do I receive my winnings?
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Winnings are sent automatically to the Lightning address you provided when buying
|
||||||
|
tickets. Make sure your address can receive payments!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">
|
||||||
|
What happens if payout fails?
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
We automatically retry failed payouts. If it continues to fail after multiple attempts,
|
||||||
|
a new winner is drawn to ensure the pot is always paid out.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">
|
||||||
|
What is the house fee?
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
A small percentage of the pot goes to operating costs. The exact fee is shown before
|
||||||
|
each draw and the winner receives the pot after fees.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Link
|
||||||
|
href="/buy"
|
||||||
|
className="inline-block bg-bitcoin-orange hover:bg-orange-600 text-white px-12 py-4 rounded-lg text-xl font-bold transition-colors shadow-lg"
|
||||||
|
>
|
||||||
|
Buy Tickets Now
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
367
front_end/src/app/buy/page.tsx
Normal file
367
front_end/src/app/buy/page.tsx
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { LightningInvoiceCard } from '@/components/LightningInvoiceCard';
|
||||||
|
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||||
|
import STRINGS from '@/constants/strings';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||||
|
import { setUser } from '@/store/userSlice';
|
||||||
|
import { getAuthToken, hexToNpub, shortNpub } from '@/lib/nostr';
|
||||||
|
|
||||||
|
export default function BuyPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const user = useAppSelector((state) => state.user);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [jackpot, setJackpot] = useState<any>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [lightningAddress, setLightningAddress] = useState('');
|
||||||
|
const [lightningAddressTouched, setLightningAddressTouched] = useState(false);
|
||||||
|
const [buyerName, setBuyerName] = useState('Anon');
|
||||||
|
const [buyerNameTouched, setBuyerNameTouched] = useState(false);
|
||||||
|
const [useNostrName, setUseNostrName] = useState(false);
|
||||||
|
const [tickets, setTickets] = useState(1);
|
||||||
|
|
||||||
|
// Invoice state
|
||||||
|
const [invoice, setInvoice] = useState<any>(null);
|
||||||
|
const [paymentStatus, setPaymentStatus] = useState<'idle' | 'waiting' | 'paid' | 'expired'>('idle');
|
||||||
|
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const expiryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const animationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const redirectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const profilePrefetchAttempted = useRef(false);
|
||||||
|
const [showPaidAnimation, setShowPaidAnimation] = useState(false);
|
||||||
|
|
||||||
|
const clearPolling = useCallback(() => {
|
||||||
|
if (pollIntervalRef.current) {
|
||||||
|
clearInterval(pollIntervalRef.current);
|
||||||
|
pollIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
if (expiryTimeoutRef.current) {
|
||||||
|
clearTimeout(expiryTimeoutRef.current);
|
||||||
|
expiryTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearVisualTimers = useCallback(() => {
|
||||||
|
if (animationTimeoutRef.current) {
|
||||||
|
clearTimeout(animationTimeoutRef.current);
|
||||||
|
animationTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
if (redirectTimeoutRef.current) {
|
||||||
|
clearTimeout(redirectTimeoutRef.current);
|
||||||
|
redirectTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadJackpot();
|
||||||
|
return () => {
|
||||||
|
clearPolling();
|
||||||
|
clearVisualTimers();
|
||||||
|
};
|
||||||
|
}, [clearPolling, clearVisualTimers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user.authenticated || profilePrefetchAttempted.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
profilePrefetchAttempted.current = true;
|
||||||
|
|
||||||
|
api
|
||||||
|
.getProfile()
|
||||||
|
.then((response) => {
|
||||||
|
if (response.data?.user) {
|
||||||
|
const computedDisplayName =
|
||||||
|
response.data.user.display_name ||
|
||||||
|
(response.data.user.nostr_pubkey
|
||||||
|
? shortNpub(hexToNpub(response.data.user.nostr_pubkey))
|
||||||
|
: null);
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
setUser({
|
||||||
|
pubkey: response.data.user.nostr_pubkey,
|
||||||
|
lightning_address: response.data.user.lightning_address,
|
||||||
|
displayName: computedDisplayName,
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
profilePrefetchAttempted.current = false;
|
||||||
|
});
|
||||||
|
}, [dispatch, user.authenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user.lightning_address && !lightningAddressTouched) {
|
||||||
|
setLightningAddress(user.lightning_address);
|
||||||
|
}
|
||||||
|
}, [user.lightning_address, lightningAddressTouched]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (useNostrName) {
|
||||||
|
if (user.displayName) {
|
||||||
|
setBuyerName(user.displayName);
|
||||||
|
} else if (user.pubkey) {
|
||||||
|
setBuyerName(shortNpub(hexToNpub(user.pubkey)));
|
||||||
|
} else {
|
||||||
|
setBuyerName('Anon');
|
||||||
|
}
|
||||||
|
} else if (!buyerNameTouched) {
|
||||||
|
setBuyerName('Anon');
|
||||||
|
}
|
||||||
|
}, [useNostrName, user.displayName, user.pubkey, buyerNameTouched]);
|
||||||
|
|
||||||
|
const loadJackpot = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getNextJackpot();
|
||||||
|
if (response.data) {
|
||||||
|
setJackpot(response.data);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to load jackpot');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLightningAddressChange = (value: string) => {
|
||||||
|
if (!lightningAddressTouched) {
|
||||||
|
setLightningAddressTouched(true);
|
||||||
|
}
|
||||||
|
setLightningAddress(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBuyerNameChange = (value: string) => {
|
||||||
|
if (!buyerNameTouched) {
|
||||||
|
setBuyerNameTouched(true);
|
||||||
|
}
|
||||||
|
setUseNostrName(false);
|
||||||
|
setBuyerName(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const finalName = buyerName.trim() || 'Anon';
|
||||||
|
const response = await api.buyTickets(lightningAddress, tickets, finalName);
|
||||||
|
|
||||||
|
if (response.data) {
|
||||||
|
setInvoice(response.data);
|
||||||
|
setPaymentStatus('waiting');
|
||||||
|
setShowPaidAnimation(false);
|
||||||
|
clearVisualTimers();
|
||||||
|
startPolling(response.data.ticket_purchase_id);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to create purchase');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPolling = (purchaseId?: string) => {
|
||||||
|
if (!purchaseId) {
|
||||||
|
console.error('Missing purchase ID for polling');
|
||||||
|
setError('Missing purchase identifier. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPolling();
|
||||||
|
clearVisualTimers();
|
||||||
|
setShowPaidAnimation(false);
|
||||||
|
|
||||||
|
pollIntervalRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getTicketStatus(purchaseId);
|
||||||
|
|
||||||
|
if (response.data.purchase.invoice_status === 'paid') {
|
||||||
|
setPaymentStatus('paid');
|
||||||
|
clearPolling();
|
||||||
|
setShowPaidAnimation(true);
|
||||||
|
|
||||||
|
// Redirect after showing the paid animation
|
||||||
|
redirectTimeoutRef.current = setTimeout(() => {
|
||||||
|
router.push(`/tickets/${purchaseId}`);
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Polling error:', err);
|
||||||
|
}
|
||||||
|
}, 5000); // Poll every 5 seconds
|
||||||
|
|
||||||
|
// Stop polling after 20 minutes if still unpaid
|
||||||
|
expiryTimeoutRef.current = setTimeout(() => {
|
||||||
|
setPaymentStatus((prev) => {
|
||||||
|
if (prev === 'waiting') {
|
||||||
|
clearPolling();
|
||||||
|
return 'expired';
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, 20 * 60 * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!jackpot) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketPriceSats = jackpot.lottery.ticket_price_sats;
|
||||||
|
const totalCost = ticketPriceSats * tickets;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-center text-white">
|
||||||
|
{STRINGS.buy.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{!invoice ? (
|
||||||
|
/* Purchase Form */
|
||||||
|
<div className="bg-gray-900 rounded-xl p-8 border border-gray-800">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Lightning Address */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-300 mb-2 font-medium">
|
||||||
|
{STRINGS.buy.lightningAddress}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={lightningAddress}
|
||||||
|
onChange={(e) => handleLightningAddressChange(e.target.value)}
|
||||||
|
placeholder={STRINGS.buy.lightningAddressPlaceholder}
|
||||||
|
required
|
||||||
|
className="w-full bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Where to send your winnings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buyer Name */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-gray-300 font-medium">
|
||||||
|
{STRINGS.buy.buyerName}
|
||||||
|
</label>
|
||||||
|
{user.authenticated && (
|
||||||
|
<label className="flex items-center text-sm text-gray-400 space-x-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useNostrName}
|
||||||
|
onChange={(e) => setUseNostrName(e.target.checked)}
|
||||||
|
className="accent-bitcoin-orange"
|
||||||
|
/>
|
||||||
|
<span>{STRINGS.buy.useNostrName}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={buyerName}
|
||||||
|
onChange={(e) => handleBuyerNameChange(e.target.value)}
|
||||||
|
placeholder={STRINGS.buy.buyerNamePlaceholder}
|
||||||
|
disabled={useNostrName}
|
||||||
|
maxLength={64}
|
||||||
|
className={`w-full bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange ${
|
||||||
|
useNostrName ? 'opacity-70 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
{STRINGS.buy.buyerNameHelp}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Number of Tickets */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-300 mb-2 font-medium">
|
||||||
|
{STRINGS.buy.numberOfTickets}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={tickets}
|
||||||
|
onChange={(e) => setTickets(Math.max(1, Math.min(100, parseInt(e.target.value) || 1)))}
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
required
|
||||||
|
className="w-full bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing Info */}
|
||||||
|
<div className="bg-gray-800 p-4 rounded-lg space-y-2">
|
||||||
|
<div className="flex justify-between text-gray-300">
|
||||||
|
<span>{STRINGS.buy.ticketPrice}</span>
|
||||||
|
<span className="font-mono">{ticketPriceSats.toLocaleString()} sats</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-white font-bold text-lg">
|
||||||
|
<span>{STRINGS.buy.totalCost}</span>
|
||||||
|
<span className="font-mono">{totalCost.toLocaleString()} sats</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-900/50 text-red-200 px-4 py-3 rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-bitcoin-orange hover:bg-orange-600 disabled:bg-gray-600 text-white py-4 rounded-lg text-lg font-bold transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Creating Invoice...' : STRINGS.buy.createInvoice}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Invoice Display */
|
||||||
|
<div className="space-y-6">
|
||||||
|
{paymentStatus === 'paid' ? (
|
||||||
|
<div className="bg-green-900/50 text-green-200 px-6 py-4 rounded-lg text-center text-lg">
|
||||||
|
✓ {STRINGS.buy.paymentReceived}
|
||||||
|
</div>
|
||||||
|
) : paymentStatus === 'expired' ? (
|
||||||
|
<div className="bg-red-900/50 text-red-200 px-6 py-4 rounded-lg text-center text-lg">
|
||||||
|
⚠️ {STRINGS.buy.invoiceExpired}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-blue-900/50 text-blue-200 px-6 py-4 rounded-lg text-center">
|
||||||
|
{STRINGS.buy.waitingForPayment}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LightningInvoiceCard
|
||||||
|
paymentRequest={invoice.invoice.payment_request}
|
||||||
|
amountSats={invoice.invoice.amount_sats}
|
||||||
|
showPaidAnimation={showPaidAnimation}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-gray-400 mb-2">{STRINGS.buy.paymentInstructions}</p>
|
||||||
|
<a
|
||||||
|
href={invoice.public_url}
|
||||||
|
className="text-bitcoin-orange hover:underline"
|
||||||
|
>
|
||||||
|
View ticket status page
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
159
front_end/src/app/dashboard/page.tsx
Normal file
159
front_end/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||||
|
import { hexToNpub, shortNpub, removeAuthToken } from '@/lib/nostr';
|
||||||
|
import STRINGS from '@/constants/strings';
|
||||||
|
import { logout } from '@/store/userSlice';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const user = useAppSelector((state) => state.user);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [profile, setProfile] = useState<any>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user.authenticated) {
|
||||||
|
router.push('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProfile();
|
||||||
|
}, [user.authenticated]);
|
||||||
|
|
||||||
|
const loadProfile = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await api.getProfile();
|
||||||
|
if (response.data) {
|
||||||
|
setProfile(response.data);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to load profile');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-red-500 text-xl mb-4">⚠️ {error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold text-white mb-4 md:mb-0">
|
||||||
|
{STRINGS.dashboard.title}
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
removeAuthToken();
|
||||||
|
dispatch(logout());
|
||||||
|
router.push('/');
|
||||||
|
}}
|
||||||
|
className="self-start md:self-auto bg-gray-800 hover:bg-gray-700 text-white px-4 py-2 rounded-lg border border-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Card */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 text-gray-300">
|
||||||
|
{STRINGS.dashboard.profile}
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Nostr Public Key:</span>
|
||||||
|
<div className="text-white font-mono">
|
||||||
|
{profile?.user?.nostr_pubkey ? shortNpub(hexToNpub(profile.user.nostr_pubkey)) : 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{profile?.user?.lightning_address && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Lightning Address:</span>
|
||||||
|
<div className="text-white">{profile.user.lightning_address}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="text-gray-400 text-sm mb-2">
|
||||||
|
{STRINGS.dashboard.currentRoundTickets}
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-bitcoin-orange">
|
||||||
|
{(profile?.stats?.current_round_tickets || 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="text-gray-400 text-sm mb-2">
|
||||||
|
{STRINGS.dashboard.pastTickets}
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-purple-400">
|
||||||
|
{(profile?.stats?.past_tickets || 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="text-gray-400 text-sm mb-2">
|
||||||
|
{STRINGS.dashboard.totalWins}
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-green-500">
|
||||||
|
{(profile?.stats?.total_wins || 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<div className="text-gray-400 text-sm mb-2">
|
||||||
|
{STRINGS.dashboard.totalWinnings}
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-green-500">
|
||||||
|
{(profile?.stats?.total_winnings_sats || 0).toLocaleString()}
|
||||||
|
<span className="text-lg text-gray-400 ml-1">sats</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Links */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/tickets"
|
||||||
|
className="bg-gray-900 hover:bg-gray-800 rounded-xl p-6 border border-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="text-2xl mb-2">🎫</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">
|
||||||
|
{STRINGS.dashboard.tickets}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400 text-sm">View your ticket purchase history</p>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/dashboard/wins"
|
||||||
|
className="bg-gray-900 hover:bg-gray-800 rounded-xl p-6 border border-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="text-2xl mb-2">🏆</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">
|
||||||
|
{STRINGS.dashboard.wins}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400 text-sm">Check your wins and payouts</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
119
front_end/src/app/dashboard/tickets/page.tsx
Normal file
119
front_end/src/app/dashboard/tickets/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAppSelector } from '@/store/hooks';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||||
|
import { relativeTime } from '@/lib/format';
|
||||||
|
import STRINGS from '@/constants/strings';
|
||||||
|
|
||||||
|
export default function DashboardTicketsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const user = useAppSelector((state) => state.user);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [tickets, setTickets] = useState<any[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user.authenticated) {
|
||||||
|
router.push('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTickets();
|
||||||
|
}, [user.authenticated]);
|
||||||
|
|
||||||
|
const loadTickets = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await api.getUserTickets();
|
||||||
|
if (response.data) {
|
||||||
|
setTickets(response.data.purchases || []);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to load tickets');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-red-500 text-xl mb-4">⚠️ {error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-flex items-center text-sm text-gray-400 hover:text-white mb-4 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="mr-2">←</span>
|
||||||
|
{STRINGS.dashboard.backToDashboard}
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-white">
|
||||||
|
{STRINGS.dashboard.tickets}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{tickets.length === 0 ? (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-12 border border-gray-800 text-center">
|
||||||
|
<div className="text-4xl mb-4">🎫</div>
|
||||||
|
<div className="text-xl text-gray-400 mb-4">{STRINGS.empty.noTickets}</div>
|
||||||
|
<Link
|
||||||
|
href="/buy"
|
||||||
|
className="inline-block bg-bitcoin-orange hover:bg-orange-600 text-white px-6 py-3 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{STRINGS.empty.buyNow}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{tickets.map((ticket) => (
|
||||||
|
<Link
|
||||||
|
key={ticket.id}
|
||||||
|
href={`/tickets/${ticket.id}`}
|
||||||
|
className="block bg-gray-900 hover:bg-gray-800 rounded-xl p-6 border border-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-gray-400 text-sm mb-1">
|
||||||
|
{relativeTime(ticket.created_at)}
|
||||||
|
</div>
|
||||||
|
<div className="text-white font-mono text-sm">
|
||||||
|
{ticket.id.substring(0, 16)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`
|
||||||
|
px-3 py-1 rounded-full text-sm font-medium
|
||||||
|
${ticket.invoice_status === 'paid' ? 'bg-green-900/30 text-green-300' : 'bg-yellow-900/30 text-yellow-300'}
|
||||||
|
`}>
|
||||||
|
{ticket.invoice_status}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Tickets:</span>
|
||||||
|
<span className="text-white ml-2">{ticket.number_of_tickets}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Amount:</span>
|
||||||
|
<span className="text-white ml-2">{ticket.amount_sats.toLocaleString()} sats</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
111
front_end/src/app/dashboard/wins/page.tsx
Normal file
111
front_end/src/app/dashboard/wins/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAppSelector } from '@/store/hooks';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||||
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
import STRINGS from '@/constants/strings';
|
||||||
|
|
||||||
|
export default function DashboardWinsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const user = useAppSelector((state) => state.user);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [wins, setWins] = useState<any[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user.authenticated) {
|
||||||
|
router.push('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadWins();
|
||||||
|
}, [user.authenticated]);
|
||||||
|
|
||||||
|
const loadWins = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await api.getUserWins();
|
||||||
|
if (response.data) {
|
||||||
|
setWins(response.data.wins || []);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to load wins');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-red-500 text-xl mb-4">⚠️ {error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-flex items-center text-sm text-gray-400 hover:text-white mb-4 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="mr-2">←</span>
|
||||||
|
{STRINGS.dashboard.backToDashboard}
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-white">
|
||||||
|
{STRINGS.dashboard.wins}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{wins.length === 0 ? (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-12 border border-gray-800 text-center">
|
||||||
|
<div className="text-4xl mb-4">🏆</div>
|
||||||
|
<div className="text-xl text-gray-400">{STRINGS.empty.noWins}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{wins.map((win) => (
|
||||||
|
<div
|
||||||
|
key={win.id}
|
||||||
|
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-green-500 mb-1">
|
||||||
|
🎉 {win.amount_sats.toLocaleString()} sats
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400 text-sm">
|
||||||
|
{formatDateTime(win.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`
|
||||||
|
px-3 py-1 rounded-full text-sm font-medium
|
||||||
|
${
|
||||||
|
win.status === 'paid'
|
||||||
|
? 'bg-green-900/30 text-green-300'
|
||||||
|
: win.status === 'pending'
|
||||||
|
? 'bg-yellow-900/30 text-yellow-300'
|
||||||
|
: 'bg-red-900/30 text-red-300'
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
{win.status}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
Cycle ID: {win.cycle_id.substring(0, 16)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
53
front_end/src/app/globals.css
Normal file
53
front_end/src/app/globals.css
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--foreground-rgb: 218, 218, 218;
|
||||||
|
--background-rgb: 11, 11, 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: rgb(var(--foreground-rgb));
|
||||||
|
background: rgb(var(--background-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #0b0b0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
36
front_end/src/app/layout.tsx
Normal file
36
front_end/src/app/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
import './globals.css';
|
||||||
|
import { Providers } from './providers';
|
||||||
|
import { TopBar } from '@/components/TopBar';
|
||||||
|
import { Footer } from '@/components/Footer';
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Lightning Lottery - Win Bitcoin',
|
||||||
|
description: 'Bitcoin Lightning Network powered lottery with instant payouts',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={inter.className}>
|
||||||
|
<Providers>
|
||||||
|
<div className="min-h-screen flex flex-col bg-black text-gray-200">
|
||||||
|
<TopBar />
|
||||||
|
<main className="flex-grow container mx-auto px-4 py-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
293
front_end/src/app/page.tsx
Normal file
293
front_end/src/app/page.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { JackpotCountdown } from '@/components/JackpotCountdown';
|
||||||
|
import { JackpotPotDisplay } from '@/components/JackpotPotDisplay';
|
||||||
|
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||||
|
import { DrawAnimation } from '@/components/DrawAnimation';
|
||||||
|
import STRINGS from '@/constants/strings';
|
||||||
|
|
||||||
|
interface RecentWinner {
|
||||||
|
id: string;
|
||||||
|
winner_name: string;
|
||||||
|
winner_lightning_address: string;
|
||||||
|
winning_ticket_serial: number;
|
||||||
|
pot_after_fee_sats: number;
|
||||||
|
scheduled_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [jackpot, setJackpot] = useState<any>(null);
|
||||||
|
const [ticketId, setTicketId] = useState('');
|
||||||
|
const [recentWinner, setRecentWinner] = useState<RecentWinner | null>(null);
|
||||||
|
const [showDrawAnimation, setShowDrawAnimation] = useState(false);
|
||||||
|
const [drawJustCompleted, setDrawJustCompleted] = useState(false);
|
||||||
|
const [winnerBannerDismissed, setWinnerBannerDismissed] = useState(false);
|
||||||
|
const [isRecentWin, setIsRecentWin] = useState(false);
|
||||||
|
|
||||||
|
const loadJackpot = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await api.getNextJackpot();
|
||||||
|
if (response.data) {
|
||||||
|
setJackpot(response.data);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to load jackpot');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadRecentWinner = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getPastWins(1, 0);
|
||||||
|
if (response.data?.wins?.length > 0) {
|
||||||
|
const latestWin = response.data.wins[0];
|
||||||
|
const winTime = new Date(latestWin.scheduled_at).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
const sixtySeconds = 60 * 1000;
|
||||||
|
|
||||||
|
setRecentWinner(latestWin);
|
||||||
|
|
||||||
|
// Check if this is a recent win (within 60 seconds)
|
||||||
|
const isRecent = now - winTime < sixtySeconds;
|
||||||
|
setIsRecentWin(isRecent);
|
||||||
|
|
||||||
|
// If draw completed within last 60 seconds, show animation
|
||||||
|
if (isRecent && !drawJustCompleted) {
|
||||||
|
setShowDrawAnimation(true);
|
||||||
|
setDrawJustCompleted(true);
|
||||||
|
setWinnerBannerDismissed(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load recent winner:', err);
|
||||||
|
}
|
||||||
|
}, [drawJustCompleted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadJackpot();
|
||||||
|
loadRecentWinner();
|
||||||
|
}, [loadJackpot, loadRecentWinner]);
|
||||||
|
|
||||||
|
// Poll for draw completion when countdown reaches zero
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jackpot?.cycle?.scheduled_at) return;
|
||||||
|
|
||||||
|
const checkForDraw = () => {
|
||||||
|
const scheduledTime = new Date(jackpot.cycle.scheduled_at).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// If we're past the scheduled time, start polling for the winner
|
||||||
|
if (now >= scheduledTime && !drawJustCompleted) {
|
||||||
|
loadRecentWinner();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(checkForDraw, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [jackpot?.cycle?.scheduled_at, drawJustCompleted, loadRecentWinner]);
|
||||||
|
|
||||||
|
const handleCheckTicket = () => {
|
||||||
|
if (ticketId.trim()) {
|
||||||
|
router.push(`/tickets/${ticketId.trim()}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnimationComplete = () => {
|
||||||
|
setShowDrawAnimation(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayAgain = () => {
|
||||||
|
setDrawJustCompleted(false);
|
||||||
|
setWinnerBannerDismissed(true);
|
||||||
|
setIsRecentWin(false);
|
||||||
|
loadJackpot();
|
||||||
|
loadRecentWinner();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismissWinnerBanner = () => {
|
||||||
|
setWinnerBannerDismissed(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-red-500 text-xl mb-4">⚠️ {error}</div>
|
||||||
|
<button
|
||||||
|
onClick={loadJackpot}
|
||||||
|
className="bg-bitcoin-orange hover:bg-orange-600 text-white px-6 py-2 rounded-lg"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jackpot) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
No active jackpot available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show winner banner if: recent win (within 60s), not dismissed, and animation not showing
|
||||||
|
const showWinnerBanner = isRecentWin && recentWinner && !showDrawAnimation && !winnerBannerDismissed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Draw Animation Overlay */}
|
||||||
|
{showDrawAnimation && recentWinner && (
|
||||||
|
<DrawAnimation
|
||||||
|
winnerName={recentWinner.winner_name}
|
||||||
|
winningTicket={recentWinner.winning_ticket_serial}
|
||||||
|
potAmount={recentWinner.pot_after_fee_sats}
|
||||||
|
onComplete={handleAnimationComplete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h1 className="text-4xl md:text-6xl font-bold mb-4 text-white">
|
||||||
|
{STRINGS.app.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-400">{STRINGS.app.tagline}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Winner Banner - Only shown for 60 seconds after draw */}
|
||||||
|
{showWinnerBanner && (
|
||||||
|
<div className="bg-gradient-to-r from-yellow-900/40 via-yellow-800/30 to-yellow-900/40 border border-yellow-600/50 rounded-2xl p-6 mb-8 animate-fade-in relative">
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={handleDismissWinnerBanner}
|
||||||
|
className="absolute top-3 right-3 text-yellow-400/60 hover:text-yellow-400 transition-colors p-1"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-yellow-400 text-sm uppercase tracking-wider mb-2">
|
||||||
|
🏆 Latest Winner
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl md:text-3xl font-bold text-white mb-2">
|
||||||
|
{recentWinner.winner_name || 'Anon'}
|
||||||
|
</div>
|
||||||
|
<div className="text-yellow-400 text-xl font-mono mb-3">
|
||||||
|
Won {recentWinner.pot_after_fee_sats.toLocaleString()} sats
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400 text-sm">
|
||||||
|
Ticket #{recentWinner.winning_ticket_serial.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current Jackpot Card */}
|
||||||
|
<div className="bg-gray-900 rounded-2xl p-8 md:p-12 mb-8 border border-gray-800">
|
||||||
|
<h2 className="text-2xl font-semibold text-center mb-6 text-gray-300">
|
||||||
|
{STRINGS.home.currentJackpot}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Pot Display */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<JackpotPotDisplay potTotalSats={jackpot.cycle.pot_total_sats} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Countdown */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="text-center text-gray-400 mb-4">
|
||||||
|
{STRINGS.home.drawIn}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<JackpotCountdown scheduledAt={jackpot.cycle.scheduled_at} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ticket Price */}
|
||||||
|
<div className="text-center text-gray-400 mb-8">
|
||||||
|
Ticket Price: {jackpot.lottery.ticket_price_sats.toLocaleString()} sats
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buy Button - Show Refresh only after draw */}
|
||||||
|
<div className="flex flex-col sm:flex-row justify-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/buy"
|
||||||
|
className="bg-bitcoin-orange hover:bg-orange-600 text-white px-12 py-4 rounded-lg text-xl font-bold transition-colors shadow-lg text-center"
|
||||||
|
>
|
||||||
|
{STRINGS.home.buyTickets}
|
||||||
|
</Link>
|
||||||
|
{drawJustCompleted && (
|
||||||
|
<button
|
||||||
|
onClick={handlePlayAgain}
|
||||||
|
className="bg-gray-700 hover:bg-gray-600 text-white px-8 py-4 rounded-lg text-lg font-medium transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<span>🔄</span> Refresh
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Check Ticket Section */}
|
||||||
|
<div className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
|
||||||
|
<h3 className="text-xl font-semibold text-center mb-4 text-gray-300">
|
||||||
|
{STRINGS.home.checkTicket}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ticketId}
|
||||||
|
onChange={(e) => setTicketId(e.target.value)}
|
||||||
|
placeholder={STRINGS.home.ticketIdPlaceholder}
|
||||||
|
className="flex-1 bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange"
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && handleCheckTicket()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCheckTicket}
|
||||||
|
className="bg-gray-700 hover:bg-gray-600 text-white px-8 py-3 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Check Status
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Section */}
|
||||||
|
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800">
|
||||||
|
<div className="text-4xl mb-3">⚡</div>
|
||||||
|
<h4 className="text-lg font-semibold mb-2 text-white">Instant</h4>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
Lightning-fast ticket purchases and payouts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800">
|
||||||
|
<div className="text-4xl mb-3">🔒</div>
|
||||||
|
<h4 className="text-lg font-semibold mb-2 text-white">Secure</h4>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
Cryptographically secure random number generation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800">
|
||||||
|
<div className="text-4xl mb-3">🎯</div>
|
||||||
|
<h4 className="text-lg font-semibold mb-2 text-white">Fair</h4>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
Transparent draws with verifiable results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
front_end/src/app/past-wins/page.tsx
Normal file
117
front_end/src/app/past-wins/page.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||||
|
import STRINGS from '@/constants/strings';
|
||||||
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
|
||||||
|
interface PastWin {
|
||||||
|
cycle_id: string;
|
||||||
|
cycle_type: string;
|
||||||
|
scheduled_at: string;
|
||||||
|
pot_total_sats: number;
|
||||||
|
pot_after_fee_sats: number | null;
|
||||||
|
winner_name: string;
|
||||||
|
winning_ticket_serial: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PastWinsPage() {
|
||||||
|
const [wins, setWins] = useState<PastWin[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadWins = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.getPastWins();
|
||||||
|
setWins(response.data?.wins || []);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to load past wins');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadWins();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto text-center py-12">
|
||||||
|
<div className="text-red-500 text-xl mb-2">⚠️ {error}</div>
|
||||||
|
<p className="text-gray-400">Please try again in a moment.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<div className="text-center mb-10">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">
|
||||||
|
{STRINGS.pastWins.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400">{STRINGS.pastWins.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{wins.length === 0 ? (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-12 text-center border border-gray-800">
|
||||||
|
<div className="text-4xl mb-4">⏳</div>
|
||||||
|
<div className="text-gray-300 text-lg">{STRINGS.pastWins.noWins}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{wins.map((win) => (
|
||||||
|
<div
|
||||||
|
key={win.cycle_id}
|
||||||
|
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm uppercase tracking-wide text-gray-500">
|
||||||
|
{win.cycle_type} • {formatDateTime(win.scheduled_at)}
|
||||||
|
</div>
|
||||||
|
<div className="text-white font-mono text-sm">
|
||||||
|
{win.cycle_id.substring(0, 16)}...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 md:mt-0 text-right">
|
||||||
|
<div className="text-gray-400 text-sm">{STRINGS.pastWins.pot}</div>
|
||||||
|
<div className="text-2xl font-bold text-bitcoin-orange">
|
||||||
|
{win.pot_total_sats.toLocaleString()} sats
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-400 mb-1">{STRINGS.pastWins.winner}</div>
|
||||||
|
<div className="text-white font-semibold">
|
||||||
|
{win.winner_name || 'Anon'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-400 mb-1">{STRINGS.pastWins.ticket}</div>
|
||||||
|
<div className="text-white">
|
||||||
|
{win.winning_ticket_serial !== null
|
||||||
|
? `#${win.winning_ticket_serial}`
|
||||||
|
: 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-400 mb-1">{STRINGS.pastWins.drawTime}</div>
|
||||||
|
<div className="text-white">{formatDateTime(win.scheduled_at)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
9
front_end/src/app/providers.tsx
Normal file
9
front_end/src/app/providers.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { store } from '@/store';
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return <Provider store={store}>{children}</Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
192
front_end/src/app/tickets/[id]/page.tsx
Normal file
192
front_end/src/app/tickets/[id]/page.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { JackpotCountdown } from '@/components/JackpotCountdown';
|
||||||
|
import { TicketList } from '@/components/TicketList';
|
||||||
|
import { PayoutStatus } from '@/components/PayoutStatus';
|
||||||
|
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||||
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
import STRINGS from '@/constants/strings';
|
||||||
|
|
||||||
|
export default function TicketStatusPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const ticketId = params.id as string;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [data, setData] = useState<any>(null);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTicketStatus();
|
||||||
|
|
||||||
|
// Auto-refresh if payment pending or draw not complete
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (autoRefresh) {
|
||||||
|
loadTicketStatus(true);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [ticketId, autoRefresh]);
|
||||||
|
|
||||||
|
const loadTicketStatus = async (silent = false) => {
|
||||||
|
try {
|
||||||
|
if (!silent) setLoading(true);
|
||||||
|
|
||||||
|
const response = await api.getTicketStatus(ticketId);
|
||||||
|
|
||||||
|
if (response.data) {
|
||||||
|
setData(response.data);
|
||||||
|
|
||||||
|
// Stop auto-refresh if payment is complete and draw is done
|
||||||
|
if (
|
||||||
|
response.data.purchase.invoice_status === 'paid' &&
|
||||||
|
response.data.cycle.status === 'completed'
|
||||||
|
) {
|
||||||
|
setAutoRefresh(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to load ticket status');
|
||||||
|
} finally {
|
||||||
|
if (!silent) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-red-500 text-xl mb-4">⚠️ {error}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => loadTicketStatus()}
|
||||||
|
className="bg-bitcoin-orange hover:bg-orange-600 text-white px-6 py-2 rounded-lg"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <div className="text-center py-12 text-gray-400">Ticket not found</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { purchase, tickets, cycle, result } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-center text-white">
|
||||||
|
{STRINGS.ticket.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Purchase Info */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Purchase ID:</span>
|
||||||
|
<div className="text-white font-mono break-all">{purchase.id}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Status:</span>
|
||||||
|
<div className="text-white capitalize">{purchase.invoice_status}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Tickets:</span>
|
||||||
|
<div className="text-white">{purchase.number_of_tickets}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Amount:</span>
|
||||||
|
<div className="text-white">{purchase.amount_sats.toLocaleString()} sats</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Status */}
|
||||||
|
{purchase.invoice_status === 'pending' && (
|
||||||
|
<div className="bg-yellow-900/30 text-yellow-200 px-6 py-4 rounded-lg mb-6 text-center">
|
||||||
|
{STRINGS.ticket.waiting}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tickets */}
|
||||||
|
{purchase.ticket_issue_status === 'issued' && (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 text-gray-300">
|
||||||
|
{STRINGS.ticket.ticketNumbers}
|
||||||
|
</h2>
|
||||||
|
<TicketList tickets={tickets} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Draw Info */}
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 text-gray-300">
|
||||||
|
Draw Information
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Draw Time:</span>
|
||||||
|
<div className="text-white">{formatDateTime(cycle.scheduled_at)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">Current Pot:</span>
|
||||||
|
<div className="text-2xl font-bold text-bitcoin-orange">
|
||||||
|
{cycle.pot_total_sats.toLocaleString()} sats
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cycle.status !== 'completed' && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400 block mb-2">Time Until Draw:</span>
|
||||||
|
<JackpotCountdown scheduledAt={cycle.scheduled_at} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{result.has_drawn && (
|
||||||
|
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 text-gray-300">
|
||||||
|
Draw Results
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{result.is_winner ? (
|
||||||
|
<div>
|
||||||
|
<div className="bg-green-900/30 text-green-200 px-6 py-4 rounded-lg mb-4 text-center text-2xl font-bold">
|
||||||
|
🎉 {STRINGS.ticket.congratulations}
|
||||||
|
</div>
|
||||||
|
{result.payout && (
|
||||||
|
<PayoutStatus
|
||||||
|
status={result.payout.status}
|
||||||
|
amountSats={result.payout.amount_sats}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="bg-gray-800 px-6 py-4 rounded-lg mb-4 text-center">
|
||||||
|
<div className="text-gray-400 mb-2">{STRINGS.ticket.betterLuck}</div>
|
||||||
|
{cycle.winning_ticket_id && (
|
||||||
|
<div className="text-gray-300">
|
||||||
|
{STRINGS.ticket.winningTicket}: <span className="font-bold text-bitcoin-orange">#{cycle.winning_ticket_id.substring(0, 8)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
214
front_end/src/components/DrawAnimation.tsx
Normal file
214
front_end/src/components/DrawAnimation.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface DrawAnimationProps {
|
||||||
|
winnerName: string;
|
||||||
|
winningTicket: number;
|
||||||
|
potAmount: number;
|
||||||
|
onComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawAnimation({
|
||||||
|
winnerName,
|
||||||
|
winningTicket,
|
||||||
|
potAmount,
|
||||||
|
onComplete,
|
||||||
|
}: DrawAnimationProps) {
|
||||||
|
const [phase, setPhase] = useState<'spinning' | 'revealing' | 'winner' | 'done'>('spinning');
|
||||||
|
const [displayTicket, setDisplayTicket] = useState(0);
|
||||||
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
|
|
||||||
|
// Generate random ticket numbers during spin
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'spinning') return;
|
||||||
|
|
||||||
|
const spinInterval = setInterval(() => {
|
||||||
|
setDisplayTicket(Math.floor(Math.random() * 999999999) + 1);
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
// After 2.5 seconds, start revealing
|
||||||
|
const revealTimeout = setTimeout(() => {
|
||||||
|
setPhase('revealing');
|
||||||
|
}, 2500);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(spinInterval);
|
||||||
|
clearTimeout(revealTimeout);
|
||||||
|
};
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
// Slow down and reveal actual number
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'revealing') return;
|
||||||
|
|
||||||
|
let speed = 50;
|
||||||
|
let iterations = 0;
|
||||||
|
const maxIterations = 15;
|
||||||
|
|
||||||
|
const slowDown = () => {
|
||||||
|
if (iterations >= maxIterations) {
|
||||||
|
setDisplayTicket(winningTicket);
|
||||||
|
setPhase('winner');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
speed += 30;
|
||||||
|
iterations++;
|
||||||
|
setDisplayTicket(Math.floor(Math.random() * 999999999) + 1);
|
||||||
|
|
||||||
|
setTimeout(slowDown, speed);
|
||||||
|
};
|
||||||
|
|
||||||
|
slowDown();
|
||||||
|
}, [phase, winningTicket]);
|
||||||
|
|
||||||
|
// Show winner and confetti
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'winner') return;
|
||||||
|
|
||||||
|
setShowConfetti(true);
|
||||||
|
|
||||||
|
// Auto-dismiss after 6 seconds
|
||||||
|
const dismissTimeout = setTimeout(() => {
|
||||||
|
setPhase('done');
|
||||||
|
onComplete();
|
||||||
|
}, 6000);
|
||||||
|
|
||||||
|
return () => clearTimeout(dismissTimeout);
|
||||||
|
}, [phase, onComplete]);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
setPhase('done');
|
||||||
|
onComplete();
|
||||||
|
}, [onComplete]);
|
||||||
|
|
||||||
|
if (phase === 'done') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
|
||||||
|
onClick={phase === 'winner' ? handleDismiss : undefined}
|
||||||
|
>
|
||||||
|
{/* Confetti Effect */}
|
||||||
|
{showConfetti && (
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
{[...Array(50)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="confetti-piece"
|
||||||
|
style={{
|
||||||
|
left: `${Math.random() * 100}%`,
|
||||||
|
animationDelay: `${Math.random() * 2}s`,
|
||||||
|
backgroundColor: ['#f7931a', '#ffd700', '#ff6b6b', '#4ecdc4', '#45b7d1'][
|
||||||
|
Math.floor(Math.random() * 5)
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center px-6 max-w-lg">
|
||||||
|
{/* Spinning Phase */}
|
||||||
|
{(phase === 'spinning' || phase === 'revealing') && (
|
||||||
|
<>
|
||||||
|
<div className="text-2xl text-yellow-400 mb-4 animate-pulse">
|
||||||
|
🎰 Drawing Winner...
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded-2xl p-8 border-2 border-yellow-500/50 shadow-2xl shadow-yellow-500/20">
|
||||||
|
<div className="text-gray-400 text-sm mb-2">Ticket Number</div>
|
||||||
|
<div
|
||||||
|
className={`text-5xl md:text-6xl font-mono font-bold text-bitcoin-orange ${
|
||||||
|
phase === 'spinning' ? 'animate-number-spin' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
#{displayTicket.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Winner Phase */}
|
||||||
|
{phase === 'winner' && (
|
||||||
|
<div className="animate-winner-reveal">
|
||||||
|
<div className="text-4xl mb-4">🎉🏆🎉</div>
|
||||||
|
<div className="text-3xl md:text-4xl font-bold text-yellow-400 mb-6">
|
||||||
|
We Have a Winner!
|
||||||
|
</div>
|
||||||
|
<div className="bg-gradient-to-br from-yellow-900/60 to-orange-900/60 rounded-2xl p-8 border-2 border-yellow-500 shadow-2xl shadow-yellow-500/30">
|
||||||
|
<div className="text-gray-300 text-sm mb-1">Winner</div>
|
||||||
|
<div className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||||
|
{winnerName || 'Anon'}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 text-sm mb-1">Winning Ticket</div>
|
||||||
|
<div className="text-2xl font-mono text-bitcoin-orange mb-4">
|
||||||
|
#{winningTicket.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-300 text-sm mb-1">Prize</div>
|
||||||
|
<div className="text-4xl md:text-5xl font-bold text-green-400">
|
||||||
|
{potAmount.toLocaleString()} sats
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 text-gray-400 text-sm animate-pulse">
|
||||||
|
Click anywhere to continue
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
.confetti-piece {
|
||||||
|
position: absolute;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
top: -10px;
|
||||||
|
animation: confetti-fall 4s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes confetti-fall {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0) rotate(0deg);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(100vh) rotate(720deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-number-spin {
|
||||||
|
animation: number-glow 0.1s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes number-glow {
|
||||||
|
from {
|
||||||
|
text-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
text-shadow: 0 0 20px rgba(247, 147, 26, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-winner-reveal {
|
||||||
|
animation: winner-pop 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes winner-pop {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.8);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
29
front_end/src/components/Footer.tsx
Normal file
29
front_end/src/components/Footer.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="bg-gray-900 border-t border-gray-800 py-8 mt-12">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||||
|
<div className="text-gray-400 text-sm mb-4 md:mb-0">
|
||||||
|
© 2025 Lightning Lottery. Powered by Bitcoin Lightning Network.
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<Link
|
||||||
|
href="/about"
|
||||||
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
About
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/past-wins"
|
||||||
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Past Winners
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
front_end/src/components/JackpotCountdown.tsx
Normal file
58
front_end/src/components/JackpotCountdown.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { formatCountdown } from '@/lib/format';
|
||||||
|
|
||||||
|
interface JackpotCountdownProps {
|
||||||
|
scheduledAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JackpotCountdown({ scheduledAt }: JackpotCountdownProps) {
|
||||||
|
const [countdown, setCountdown] = useState(formatCountdown(scheduledAt));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCountdown(formatCountdown(scheduledAt));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [scheduledAt]);
|
||||||
|
|
||||||
|
if (countdown.total <= 0) {
|
||||||
|
return <div className="text-2xl font-bold text-yellow-500">Drawing Now!</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex space-x-4" role="timer" aria-live="polite">
|
||||||
|
{countdown.days > 0 && (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||||
|
{countdown.days}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">days</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||||
|
{countdown.hours.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">hours</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-4xl md:text-5xl font-bold text-gray-500">:</div>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||||
|
{countdown.minutes.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">minutes</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-4xl md:text-5xl font-bold text-gray-500">:</div>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
|
||||||
|
{countdown.seconds.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">seconds</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
20
front_end/src/components/JackpotPotDisplay.tsx
Normal file
20
front_end/src/components/JackpotPotDisplay.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { formatSats, satsToBTC } from '@/lib/format';
|
||||||
|
|
||||||
|
interface JackpotPotDisplayProps {
|
||||||
|
potTotalSats: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JackpotPotDisplay({ potTotalSats }: JackpotPotDisplayProps) {
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-5xl md:text-7xl font-bold text-bitcoin-orange mb-2">
|
||||||
|
{formatSats(potTotalSats)}
|
||||||
|
<span className="text-3xl md:text-4xl ml-2 text-gray-400">sats</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl md:text-2xl text-gray-400">
|
||||||
|
≈ {satsToBTC(potTotalSats)} BTC
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
154
front_end/src/components/LightningInvoiceCard.tsx
Normal file
154
front_end/src/components/LightningInvoiceCard.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
|
||||||
|
interface LightningInvoiceCardProps {
|
||||||
|
paymentRequest: string;
|
||||||
|
amountSats: number;
|
||||||
|
showPaidAnimation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LightningInvoiceCard({
|
||||||
|
paymentRequest,
|
||||||
|
amountSats,
|
||||||
|
showPaidAnimation = false,
|
||||||
|
}: LightningInvoiceCardProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(paymentRequest);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow-lg relative overflow-hidden">
|
||||||
|
{/* QR Code Container */}
|
||||||
|
<div className="flex justify-center mb-4 relative">
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-700 ease-out ${
|
||||||
|
showPaidAnimation ? 'scale-95 opacity-80' : 'scale-100 opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<QRCodeSVG
|
||||||
|
value={paymentRequest.toUpperCase()}
|
||||||
|
size={260}
|
||||||
|
level="M"
|
||||||
|
includeMargin={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paid Overlay - Smooth Green Circle with Checkmark */}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 flex items-center justify-center transition-all duration-500 ease-out ${
|
||||||
|
showPaidAnimation
|
||||||
|
? 'opacity-100 scale-100'
|
||||||
|
: 'opacity-0 scale-50 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="paid-badge">
|
||||||
|
<svg
|
||||||
|
className="checkmark-svg"
|
||||||
|
viewBox="0 0 52 52"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="checkmark-circle"
|
||||||
|
cx="26"
|
||||||
|
cy="26"
|
||||||
|
r="24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#22c55e"
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="checkmark-check"
|
||||||
|
fill="none"
|
||||||
|
stroke="#22c55e"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14 27l8 8 16-16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paid Status Text */}
|
||||||
|
<div
|
||||||
|
className={`text-center mb-4 transition-all duration-500 ${
|
||||||
|
showPaidAnimation ? 'opacity-100' : 'opacity-0 h-0 overflow-hidden'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-green-600 font-bold text-lg">Payment Received!</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<div className="text-2xl font-bold text-gray-900">
|
||||||
|
{amountSats.toLocaleString()} sats
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invoice */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="bg-gray-100 p-3 rounded text-xs break-all text-gray-700 max-h-24 overflow-y-auto">
|
||||||
|
{paymentRequest}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Copy Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={showPaidAnimation}
|
||||||
|
className={`w-full py-3 rounded-lg font-medium transition-all duration-300 ${
|
||||||
|
showPaidAnimation
|
||||||
|
? 'bg-green-500 text-white cursor-default'
|
||||||
|
: 'bg-bitcoin-orange hover:bg-orange-600 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{showPaidAnimation ? '✓ Paid' : copied ? '✓ Copied!' : '📋 Copy Invoice'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
.paid-badge {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 10px 40px rgba(34, 197, 94, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkmark-circle {
|
||||||
|
stroke-dasharray: 166;
|
||||||
|
stroke-dashoffset: 166;
|
||||||
|
animation: circle-draw 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkmark-check {
|
||||||
|
stroke-dasharray: 48;
|
||||||
|
stroke-dashoffset: 48;
|
||||||
|
animation: check-draw 0.4s ease-out 0.4s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes circle-draw {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes check-draw {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
front_end/src/components/LoadingSpinner.tsx
Normal file
11
front_end/src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import STRINGS from '@/constants/strings';
|
||||||
|
|
||||||
|
export function LoadingSpinner() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-bitcoin-orange"></div>
|
||||||
|
<div className="text-gray-400 mt-4">{STRINGS.loading}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
72
front_end/src/components/NostrLoginButton.tsx
Normal file
72
front_end/src/components/NostrLoginButton.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAppDispatch } from '@/store/hooks';
|
||||||
|
import { setUser } from '@/store/userSlice';
|
||||||
|
import { getNostrPublicKey, signNostrMessage, storeAuthToken, shortNpub, hexToNpub } from '@/lib/nostr';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
export function NostrLoginButton() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get Nostr public key
|
||||||
|
const pubkey = await getNostrPublicKey();
|
||||||
|
|
||||||
|
// Generate nonce
|
||||||
|
const nonce = Math.random().toString(36).substring(7);
|
||||||
|
|
||||||
|
// Sign message
|
||||||
|
const signature = await signNostrMessage(nonce);
|
||||||
|
|
||||||
|
// Authenticate with backend
|
||||||
|
const response = await api.nostrAuth(pubkey, signature, nonce);
|
||||||
|
|
||||||
|
if (response.data && response.data.token) {
|
||||||
|
// Store token
|
||||||
|
storeAuthToken(response.data.token);
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
response.data.user.display_name ||
|
||||||
|
shortNpub(hexToNpub(response.data.user.nostr_pubkey));
|
||||||
|
|
||||||
|
// Update Redux state
|
||||||
|
dispatch(
|
||||||
|
setUser({
|
||||||
|
pubkey: response.data.user.nostr_pubkey,
|
||||||
|
lightning_address: response.data.user.lightning_address,
|
||||||
|
token: response.data.token,
|
||||||
|
displayName,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Nostr login error:', err);
|
||||||
|
setError(err.message || 'Failed to login with Nostr');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{loading ? 'Connecting...' : '🔐 Login with Nostr'}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-500 text-sm mt-2">{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
55
front_end/src/components/PayoutStatus.tsx
Normal file
55
front_end/src/components/PayoutStatus.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import STRINGS from '@/constants/strings';
|
||||||
|
|
||||||
|
interface PayoutStatusProps {
|
||||||
|
status: 'pending' | 'paid' | 'failed' | null;
|
||||||
|
amountSats?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PayoutStatus({ status, amountSats }: PayoutStatusProps) {
|
||||||
|
if (!status) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusDisplay = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return {
|
||||||
|
text: STRINGS.payout.pending,
|
||||||
|
color: 'text-yellow-500',
|
||||||
|
icon: '⏳',
|
||||||
|
};
|
||||||
|
case 'paid':
|
||||||
|
return {
|
||||||
|
text: STRINGS.payout.paid,
|
||||||
|
color: 'text-green-500',
|
||||||
|
icon: '✓',
|
||||||
|
};
|
||||||
|
case 'failed':
|
||||||
|
return {
|
||||||
|
text: STRINGS.payout.failed,
|
||||||
|
color: 'text-red-500',
|
||||||
|
icon: '⚠️',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const display = getStatusDisplay();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 p-6 rounded-lg">
|
||||||
|
<div className="text-lg font-semibold text-gray-300 mb-2">
|
||||||
|
{STRINGS.ticket.payoutStatus}
|
||||||
|
</div>
|
||||||
|
<div className={`text-2xl font-bold ${display.color} flex items-center space-x-2`}>
|
||||||
|
<span>{display.icon}</span>
|
||||||
|
<span>{display.text}</span>
|
||||||
|
</div>
|
||||||
|
{amountSats && (
|
||||||
|
<div className="text-gray-400 mt-2">
|
||||||
|
Amount: {amountSats.toLocaleString()} sats
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
40
front_end/src/components/TicketList.tsx
Normal file
40
front_end/src/components/TicketList.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
interface Ticket {
|
||||||
|
id: string;
|
||||||
|
serial_number: number;
|
||||||
|
is_winning_ticket: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TicketListProps {
|
||||||
|
tickets: Ticket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketList({ tickets }: TicketListProps) {
|
||||||
|
if (tickets.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center text-gray-400 py-8">
|
||||||
|
No tickets issued yet
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-3">
|
||||||
|
{tickets.map((ticket) => (
|
||||||
|
<div
|
||||||
|
key={ticket.id}
|
||||||
|
className={`
|
||||||
|
p-4 rounded-lg text-center font-bold text-lg
|
||||||
|
${
|
||||||
|
ticket.is_winning_ticket
|
||||||
|
? 'bg-green-600 text-white ring-4 ring-green-400'
|
||||||
|
: 'bg-gray-800 text-gray-300'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
#{ticket.serial_number}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
62
front_end/src/components/TopBar.tsx
Normal file
62
front_end/src/components/TopBar.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAppSelector } from '@/store/hooks';
|
||||||
|
import { NostrLoginButton } from './NostrLoginButton';
|
||||||
|
import { shortNpub, hexToNpub } from '@/lib/nostr';
|
||||||
|
import STRINGS from '@/constants/strings';
|
||||||
|
|
||||||
|
export function TopBar() {
|
||||||
|
const user = useAppSelector((state) => state.user);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="bg-gray-900 border-b border-gray-800">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link href="/" className="flex items-center space-x-2">
|
||||||
|
<span className="text-2xl">⚡</span>
|
||||||
|
<span className="text-xl font-bold text-bitcoin-orange">
|
||||||
|
{STRINGS.app.title}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex items-center space-x-6">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-gray-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/buy"
|
||||||
|
className="text-gray-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Buy Tickets
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/past-wins"
|
||||||
|
className="text-gray-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Past Winners
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{user.authenticated ? (
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="text-gray-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<NostrLoginButton />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
7
front_end/src/config/index.ts
Normal file
7
front_end/src/config/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const config = {
|
||||||
|
apiBaseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
|
||||||
|
appBaseUrl: process.env.NEXT_PUBLIC_APP_BASE_URL || 'http://localhost:3001',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
||||||
96
front_end/src/constants/strings.ts
Normal file
96
front_end/src/constants/strings.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// All user-facing text strings
|
||||||
|
export const STRINGS = {
|
||||||
|
app: {
|
||||||
|
title: 'Lightning Lottery',
|
||||||
|
tagline: 'Win Bitcoin on the Lightning Network',
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
currentJackpot: 'Current Jackpot',
|
||||||
|
drawIn: 'Draw In',
|
||||||
|
buyTickets: 'Buy Tickets',
|
||||||
|
checkTicket: 'Check My Ticket',
|
||||||
|
ticketIdPlaceholder: 'Enter ticket ID',
|
||||||
|
},
|
||||||
|
buy: {
|
||||||
|
title: 'Buy Lottery Tickets',
|
||||||
|
lightningAddress: 'Lightning Address',
|
||||||
|
lightningAddressPlaceholder: 'you@getalby.com',
|
||||||
|
buyerName: 'Display Name (optional)',
|
||||||
|
buyerNamePlaceholder: 'Anon',
|
||||||
|
buyerNameHelp: 'Shown on the public winners board. Leave blank to appear as Anon.',
|
||||||
|
useNostrName: 'Use my Nostr name',
|
||||||
|
numberOfTickets: 'Number of Tickets',
|
||||||
|
ticketPrice: 'Ticket Price',
|
||||||
|
totalCost: 'Total Cost',
|
||||||
|
createInvoice: 'Create Invoice',
|
||||||
|
paymentInstructions: 'Scan QR code or copy invoice to pay',
|
||||||
|
copyInvoice: 'Copy Invoice',
|
||||||
|
waitingForPayment: 'Waiting for payment...',
|
||||||
|
paymentReceived: 'Payment Received!',
|
||||||
|
invoiceExpired: 'Invoice Expired',
|
||||||
|
},
|
||||||
|
ticket: {
|
||||||
|
title: 'Ticket Status',
|
||||||
|
purchaseId: 'Purchase ID',
|
||||||
|
status: 'Status',
|
||||||
|
ticketNumbers: 'Your Ticket Numbers',
|
||||||
|
drawTime: 'Draw Time',
|
||||||
|
currentPot: 'Current Pot',
|
||||||
|
waiting: 'Waiting for payment...',
|
||||||
|
issued: 'Tickets Issued',
|
||||||
|
drawPending: 'Draw pending...',
|
||||||
|
congratulations: 'Congratulations! You Won!',
|
||||||
|
winningTicket: 'Winning Ticket',
|
||||||
|
betterLuck: 'Better luck next time!',
|
||||||
|
payoutStatus: 'Payout Status',
|
||||||
|
},
|
||||||
|
payout: {
|
||||||
|
pending: 'Payout pending...',
|
||||||
|
paid: 'Paid! 🎉',
|
||||||
|
failed: 'Payout failed - contact support',
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
title: 'My Dashboard',
|
||||||
|
profile: 'Profile',
|
||||||
|
tickets: 'My Tickets',
|
||||||
|
wins: 'My Wins',
|
||||||
|
stats: 'Statistics',
|
||||||
|
totalTickets: 'Total Tickets',
|
||||||
|
currentRoundTickets: 'Current Round Tickets',
|
||||||
|
pastTickets: 'Past Tickets (Completed Rounds)',
|
||||||
|
totalWins: 'Total Wins',
|
||||||
|
totalWinnings: 'Total Winnings',
|
||||||
|
backToDashboard: 'Back to Dashboard',
|
||||||
|
},
|
||||||
|
pastWins: {
|
||||||
|
title: 'Past Winners',
|
||||||
|
description: 'Recent jackpots and their champions.',
|
||||||
|
noWins: 'No completed jackpots yet. Check back soon!',
|
||||||
|
winner: 'Winner',
|
||||||
|
ticket: 'Ticket #',
|
||||||
|
pot: 'Pot',
|
||||||
|
drawTime: 'Draw Time',
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
title: 'Admin Dashboard',
|
||||||
|
cycles: 'Cycles',
|
||||||
|
payouts: 'Payouts',
|
||||||
|
runDraw: 'Run Draw',
|
||||||
|
retryPayout: 'Retry Payout',
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
generic: 'Something went wrong. Please try again.',
|
||||||
|
nostrNotFound: 'Nostr extension not found',
|
||||||
|
invalidAddress: 'Invalid Lightning Address',
|
||||||
|
networkError: 'Network error. Please check your connection.',
|
||||||
|
},
|
||||||
|
loading: 'Loading...',
|
||||||
|
empty: {
|
||||||
|
noTickets: 'You have no tickets yet',
|
||||||
|
noWins: 'No wins yet',
|
||||||
|
buyNow: 'Buy Tickets Now',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default STRINGS;
|
||||||
|
|
||||||
224
front_end/src/lib/api.ts
Normal file
224
front_end/src/lib/api.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import config from '@/config';
|
||||||
|
|
||||||
|
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '[::1]']);
|
||||||
|
const EXPLICIT_BACKEND_PORT = process.env.NEXT_PUBLIC_BACKEND_PORT;
|
||||||
|
|
||||||
|
const buildBaseUrl = (protocol: string, hostname: string, port?: string) => {
|
||||||
|
if (port && port.length > 0) {
|
||||||
|
return `${protocol}//${hostname}:${port}`;
|
||||||
|
}
|
||||||
|
return `${protocol}//${hostname}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inferPort = (configuredPort?: string, runtimePort?: string, runtimeProtocol?: string): string | undefined => {
|
||||||
|
if (EXPLICIT_BACKEND_PORT && EXPLICIT_BACKEND_PORT.length > 0) {
|
||||||
|
return EXPLICIT_BACKEND_PORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuredPort && configuredPort.length > 0) {
|
||||||
|
return configuredPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimePort && runtimePort.length > 0) {
|
||||||
|
return runtimePort === '3001' ? '3000' : runtimePort;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeProtocol === 'https:') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveBrowserBaseUrl = (staticBaseUrl: string) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return staticBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { protocol, hostname, port } = window.location;
|
||||||
|
|
||||||
|
if (!staticBaseUrl || staticBaseUrl.length === 0) {
|
||||||
|
const inferredPort = inferPort(undefined, port, protocol);
|
||||||
|
return buildBaseUrl(protocol, hostname, inferredPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(staticBaseUrl);
|
||||||
|
const shouldSwapHost = LOOPBACK_HOSTS.has(parsed.hostname) && !LOOPBACK_HOSTS.has(hostname);
|
||||||
|
|
||||||
|
if (shouldSwapHost) {
|
||||||
|
const inferredPort = inferPort(parsed.port, port, parsed.protocol);
|
||||||
|
return buildBaseUrl(parsed.protocol, hostname, inferredPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
return staticBaseUrl;
|
||||||
|
} catch {
|
||||||
|
return staticBaseUrl;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ApiOptions {
|
||||||
|
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: any;
|
||||||
|
auth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
private cachedBrowserBaseUrl: string | null = null;
|
||||||
|
|
||||||
|
constructor(baseUrl: string) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBaseUrl(): string {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return this.baseUrl || 'http://localhost:3000';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cachedBrowserBaseUrl) {
|
||||||
|
return this.cachedBrowserBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveBrowserBaseUrl(this.baseUrl || 'http://localhost:3000');
|
||||||
|
this.cachedBrowserBaseUrl = resolved;
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAuthToken(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return localStorage.getItem('auth_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
async request<T = any>(path: string, options: ApiOptions = {}): Promise<T> {
|
||||||
|
const { method = 'GET', headers = {}, body, auth = false } = options;
|
||||||
|
|
||||||
|
const url = `${this.getBaseUrl()}${path}`;
|
||||||
|
|
||||||
|
const requestHeaders: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (auth) {
|
||||||
|
const token = this.getAuthToken();
|
||||||
|
if (token) {
|
||||||
|
requestHeaders['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers: requestHeaders,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body && method !== 'GET') {
|
||||||
|
requestOptions.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, requestOptions);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || 'API request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('API request error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public endpoints
|
||||||
|
async getNextJackpot() {
|
||||||
|
return this.request('/jackpot/next');
|
||||||
|
}
|
||||||
|
|
||||||
|
async buyTickets(lightningAddress: string, tickets: number, buyerName?: string) {
|
||||||
|
return this.request('/jackpot/buy', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { lightning_address: lightningAddress, tickets, name: buyerName },
|
||||||
|
auth: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTicketStatus(ticketPurchaseId: string) {
|
||||||
|
return this.request(`/tickets/${ticketPurchaseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPastWins(limit = 25, offset = 0) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: String(limit),
|
||||||
|
offset: String(offset),
|
||||||
|
});
|
||||||
|
return this.request(`/jackpot/past-wins?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth endpoints
|
||||||
|
async nostrAuth(nostrPubkey: string, signedMessage: string, nonce: string) {
|
||||||
|
return this.request('/auth/nostr', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { nostr_pubkey: nostrPubkey, signed_message: signedMessage, nonce },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// User endpoints
|
||||||
|
async getProfile() {
|
||||||
|
return this.request('/me', { auth: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLightningAddress(lightningAddress: string) {
|
||||||
|
return this.request('/me/lightning-address', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { lightning_address: lightningAddress },
|
||||||
|
auth: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserTickets(limit = 50, offset = 0) {
|
||||||
|
return this.request(`/me/tickets?limit=${limit}&offset=${offset}`, { auth: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserWins() {
|
||||||
|
return this.request('/me/wins', { auth: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin endpoints
|
||||||
|
async listCycles(status?: string, cycleType?: string) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (status) params.append('status', status);
|
||||||
|
if (cycleType) params.append('cycle_type', cycleType);
|
||||||
|
|
||||||
|
return this.request(`/admin/cycles?${params.toString()}`, {
|
||||||
|
headers: { 'X-Admin-Key': process.env.NEXT_PUBLIC_ADMIN_KEY || '' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async runDrawManually(cycleId: string) {
|
||||||
|
return this.request(`/admin/cycles/${cycleId}/run-draw`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Admin-Key': process.env.NEXT_PUBLIC_ADMIN_KEY || '' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPayouts(status?: string) {
|
||||||
|
const params = status ? `?status=${status}` : '';
|
||||||
|
return this.request(`/admin/payouts${params}`, {
|
||||||
|
headers: { 'X-Admin-Key': process.env.NEXT_PUBLIC_ADMIN_KEY || '' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async retryPayout(payoutId: string) {
|
||||||
|
return this.request(`/admin/payouts/${payoutId}/retry`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Admin-Key': process.env.NEXT_PUBLIC_ADMIN_KEY || '' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = new ApiClient(config.apiBaseUrl);
|
||||||
|
export default api;
|
||||||
|
|
||||||
79
front_end/src/lib/format.ts
Normal file
79
front_end/src/lib/format.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Convert sats to BTC
|
||||||
|
*/
|
||||||
|
export function satsToBTC(sats: number): string {
|
||||||
|
return (sats / 100000000).toFixed(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format sats with thousands separator
|
||||||
|
*/
|
||||||
|
export function formatSats(sats: number): string {
|
||||||
|
return sats.toLocaleString('en-US');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format relative time
|
||||||
|
*/
|
||||||
|
export function relativeTime(date: Date | string): string {
|
||||||
|
const now = new Date();
|
||||||
|
const then = new Date(date);
|
||||||
|
const diffMs = now.getTime() - then.getTime();
|
||||||
|
const diffSeconds = Math.floor(diffMs / 1000);
|
||||||
|
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
if (diffSeconds < 60) {
|
||||||
|
return 'just now';
|
||||||
|
} else if (diffMinutes < 60) {
|
||||||
|
return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''} ago`;
|
||||||
|
} else if (diffHours < 24) {
|
||||||
|
return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||||
|
} else if (diffDays < 7) {
|
||||||
|
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||||
|
} else {
|
||||||
|
return then.toLocaleDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format countdown time
|
||||||
|
*/
|
||||||
|
export function formatCountdown(targetDate: Date | string): {
|
||||||
|
days: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
total: number;
|
||||||
|
} {
|
||||||
|
const now = new Date();
|
||||||
|
const target = new Date(targetDate);
|
||||||
|
const diffMs = target.getTime() - now.getTime();
|
||||||
|
|
||||||
|
if (diffMs <= 0) {
|
||||||
|
return { days: 0, hours: 0, minutes: 0, seconds: 0, total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
|
||||||
|
|
||||||
|
return { days, hours, minutes, seconds, total: diffMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date and time
|
||||||
|
*/
|
||||||
|
export function formatDateTime(date: Date | string): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
return d.toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
100
front_end/src/lib/nostr.ts
Normal file
100
front_end/src/lib/nostr.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// Nostr utilities for NIP-07 authentication
|
||||||
|
|
||||||
|
interface NostrWindow extends Window {
|
||||||
|
nostr?: {
|
||||||
|
getPublicKey: () => Promise<string>;
|
||||||
|
signEvent: (event: any) => Promise<any>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare let window: NostrWindow;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Nostr extension is available (NIP-07)
|
||||||
|
*/
|
||||||
|
export function isNostrAvailable(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return !!window.nostr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's Nostr public key
|
||||||
|
*/
|
||||||
|
export async function getNostrPublicKey(): Promise<string> {
|
||||||
|
if (!isNostrAvailable()) {
|
||||||
|
throw new Error('Nostr extension not found. Please install a Nostr browser extension.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pubkey = await window.nostr!.getPublicKey();
|
||||||
|
return pubkey;
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error('Failed to get Nostr public key: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a message with Nostr
|
||||||
|
*/
|
||||||
|
export async function signNostrMessage(message: string): Promise<string> {
|
||||||
|
if (!isNostrAvailable()) {
|
||||||
|
throw new Error('Nostr extension not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a simple event to sign
|
||||||
|
const event = {
|
||||||
|
kind: 22242, // Custom kind for auth
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [],
|
||||||
|
content: message,
|
||||||
|
};
|
||||||
|
|
||||||
|
const signedEvent = await window.nostr!.signEvent(event);
|
||||||
|
return signedEvent.sig;
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error('Failed to sign message: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store auth token
|
||||||
|
*/
|
||||||
|
export function storeAuthToken(token: string): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.setItem('auth_token', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get auth token
|
||||||
|
*/
|
||||||
|
export function getAuthToken(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return localStorage.getItem('auth_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove auth token
|
||||||
|
*/
|
||||||
|
export function removeAuthToken(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert hex pubkey to npub format (simplified)
|
||||||
|
*/
|
||||||
|
export function hexToNpub(hex: string): string {
|
||||||
|
// This is a simplified version
|
||||||
|
// In production, use a proper bech32 encoding library
|
||||||
|
return `npub1${hex.substring(0, 58)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorten npub for display
|
||||||
|
*/
|
||||||
|
export function shortNpub(npub: string): string {
|
||||||
|
if (npub.length < 16) return npub;
|
||||||
|
return `${npub.substring(0, 8)}...${npub.substring(npub.length - 4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
6
front_end/src/store/hooks.ts
Normal file
6
front_end/src/store/hooks.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||||
|
import type { RootState, AppDispatch } from './index';
|
||||||
|
|
||||||
|
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||||
|
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||||
|
|
||||||
16
front_end/src/store/index.ts
Normal file
16
front_end/src/store/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import userReducer from './userSlice';
|
||||||
|
import jackpotReducer from './jackpotSlice';
|
||||||
|
import purchaseReducer from './purchaseSlice';
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
user: userReducer,
|
||||||
|
jackpot: jackpotReducer,
|
||||||
|
purchase: purchaseReducer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
export type AppDispatch = typeof store.dispatch;
|
||||||
|
|
||||||
45
front_end/src/store/jackpotSlice.ts
Normal file
45
front_end/src/store/jackpotSlice.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
interface Cycle {
|
||||||
|
id: string;
|
||||||
|
cycle_type: string;
|
||||||
|
scheduled_at: string;
|
||||||
|
pot_total_sats: number;
|
||||||
|
ticket_price_sats: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JackpotState {
|
||||||
|
cycle: Cycle | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: JackpotState = {
|
||||||
|
cycle: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const jackpotSlice = createSlice({
|
||||||
|
name: 'jackpot',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setCycle: (state, action: PayloadAction<Cycle>) => {
|
||||||
|
state.cycle = action.payload;
|
||||||
|
state.loading = false;
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.loading = action.payload;
|
||||||
|
},
|
||||||
|
setError: (state, action: PayloadAction<string>) => {
|
||||||
|
state.error = action.payload;
|
||||||
|
state.loading = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setCycle, setLoading, setError } = jackpotSlice.actions;
|
||||||
|
export default jackpotSlice.reducer;
|
||||||
|
|
||||||
45
front_end/src/store/purchaseSlice.ts
Normal file
45
front_end/src/store/purchaseSlice.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
interface Ticket {
|
||||||
|
id: string;
|
||||||
|
serial_number: number;
|
||||||
|
is_winning_ticket: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchaseState {
|
||||||
|
ticket_purchase_id: string | null;
|
||||||
|
invoice_status: string | null;
|
||||||
|
tickets: Ticket[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: PurchaseState = {
|
||||||
|
ticket_purchase_id: null,
|
||||||
|
invoice_status: null,
|
||||||
|
tickets: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const purchaseSlice = createSlice({
|
||||||
|
name: 'purchase',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setPurchase: (state, action: PayloadAction<Partial<PurchaseState>>) => {
|
||||||
|
return { ...state, ...action.payload, loading: false, error: null };
|
||||||
|
},
|
||||||
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.loading = action.payload;
|
||||||
|
},
|
||||||
|
setError: (state, action: PayloadAction<string>) => {
|
||||||
|
state.error = action.payload;
|
||||||
|
state.loading = false;
|
||||||
|
},
|
||||||
|
clearPurchase: () => initialState,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setPurchase, setLoading, setError, clearPurchase } = purchaseSlice.actions;
|
||||||
|
export default purchaseSlice.reducer;
|
||||||
|
|
||||||
32
front_end/src/store/userSlice.ts
Normal file
32
front_end/src/store/userSlice.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
interface UserState {
|
||||||
|
authenticated: boolean;
|
||||||
|
pubkey: string | null;
|
||||||
|
lightning_address: string | null;
|
||||||
|
token: string | null;
|
||||||
|
displayName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: UserState = {
|
||||||
|
authenticated: false,
|
||||||
|
pubkey: null,
|
||||||
|
lightning_address: null,
|
||||||
|
token: null,
|
||||||
|
displayName: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const userSlice = createSlice({
|
||||||
|
name: 'user',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setUser: (state, action: PayloadAction<Partial<UserState>>) => {
|
||||||
|
return { ...state, ...action.payload, authenticated: true };
|
||||||
|
},
|
||||||
|
logout: () => initialState,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setUser, logout } = userSlice.actions;
|
||||||
|
export default userSlice.reducer;
|
||||||
|
|
||||||
22
front_end/tailwind.config.js
Normal file
22
front_end/tailwind.config.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
bitcoin: {
|
||||||
|
orange: '#f7931a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'sans-serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
28
front_end/tsconfig.json
Normal file
28
front_end/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user