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:
Michilis
2025-11-27 22:13:37 +00:00
commit d3bf8080b6
75 changed files with 18184 additions and 0 deletions

90
.gitignore vendored Normal file
View 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
View 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
View File

@@ -0,0 +1,11 @@
node_modules/
dist/
.env
*.log
.DS_Store
coverage/
# SQLite database
data/
*.db

24
back_end/Dockerfile Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

47
back_end/package.json Normal file
View 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
View 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;

View 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;

View 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);

View 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',
});
}
}

View 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',
});
}
}

View 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',
});
}
}

View 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',
});
}
}

View 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;

View 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
View 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();

View 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();
};

View 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 },
});

View 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;

View 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
View 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;

View 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;

View 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);
}

View 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();
}
}
}

View 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;

View 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;

View 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();
}
}
}

View 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
View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

32
front_end/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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
View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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
View 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;

View 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
View 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)}`;
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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"]
}