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:
100
back_end/src/app.ts
Normal file
100
back_end/src/app.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import config from './config';
|
||||
import { swaggerSpec } from './config/swagger';
|
||||
|
||||
// Routes
|
||||
import publicRoutes from './routes/public';
|
||||
import webhookRoutes from './routes/webhooks';
|
||||
import userRoutes from './routes/user';
|
||||
import adminRoutes from './routes/admin';
|
||||
|
||||
// Middleware
|
||||
import { generalRateLimiter } from './middleware/rateLimit';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Trust proxy for rate limiting (needed when behind reverse proxy)
|
||||
if (config.app.nodeEnv === 'production') {
|
||||
app.set('trust proxy', 1); // Trust first proxy
|
||||
}
|
||||
|
||||
// CORS configuration
|
||||
app.use(cors({
|
||||
origin: config.app.nodeEnv === 'production'
|
||||
? config.cors.allowedOrigins
|
||||
: true,
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Body parser
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// General rate limiting
|
||||
app.use(generalRateLimiter);
|
||||
|
||||
// Swagger API Documentation
|
||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
customSiteTitle: 'Lightning Lottery API Docs',
|
||||
}));
|
||||
|
||||
// Swagger JSON endpoint
|
||||
app.get('/api-docs.json', (req: Request, res: Response) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(swaggerSpec);
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', async (req: Request, res: Response) => {
|
||||
const { db } = await import('./database');
|
||||
const { lnbitsService } = await import('./services/lnbits');
|
||||
|
||||
const dbHealth = await db.healthCheck();
|
||||
const lnbitsHealth = await lnbitsService.healthCheck();
|
||||
|
||||
const healthy = dbHealth && lnbitsHealth;
|
||||
|
||||
res.status(healthy ? 200 : 503).json({
|
||||
version: '1.0',
|
||||
status: healthy ? 'healthy' : 'unhealthy',
|
||||
checks: {
|
||||
database: dbHealth ? 'ok' : 'failed',
|
||||
lnbits: lnbitsHealth ? 'ok' : 'failed',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/', publicRoutes);
|
||||
app.use('/webhooks', webhookRoutes);
|
||||
app.use('/', userRoutes);
|
||||
app.use('/admin', adminRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use((req: Request, res: Response) => {
|
||||
res.status(404).json({
|
||||
version: '1.0',
|
||||
error: 'NOT_FOUND',
|
||||
message: 'Endpoint not found',
|
||||
});
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
|
||||
res.status(500).json({
|
||||
version: '1.0',
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: config.app.nodeEnv === 'production'
|
||||
? 'An internal error occurred'
|
||||
: err.message,
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
159
back_end/src/config/index.ts
Normal file
159
back_end/src/config/index.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const parseOriginsList = (value?: string): string[] => {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.split(',')
|
||||
.map(origin => origin.trim())
|
||||
.filter(origin => origin.length > 0);
|
||||
};
|
||||
|
||||
const appPort = parseInt(process.env.PORT || process.env.APP_PORT || '3000', 10);
|
||||
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||
|
||||
const frontendOrigins = (() => {
|
||||
const candidates = [
|
||||
process.env.FRONTEND_BASE_URL,
|
||||
process.env.NEXT_PUBLIC_APP_BASE_URL,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const parsed = parseOriginsList(candidate);
|
||||
if (parsed.length > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return ['http://localhost:3001'];
|
||||
})();
|
||||
|
||||
const allowedCorsOrigins = (() => {
|
||||
const explicit = parseOriginsList(process.env.CORS_ALLOWED_ORIGINS);
|
||||
if (explicit.length > 0) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
return Array.from(new Set([...frontendOrigins, appBaseUrl]));
|
||||
})();
|
||||
|
||||
interface Config {
|
||||
app: {
|
||||
port: number;
|
||||
baseUrl: string;
|
||||
nodeEnv: string;
|
||||
};
|
||||
frontend: {
|
||||
origins: string[];
|
||||
};
|
||||
cors: {
|
||||
allowedOrigins: string[];
|
||||
};
|
||||
database: {
|
||||
type: 'postgres' | 'sqlite';
|
||||
url: string;
|
||||
};
|
||||
lnbits: {
|
||||
apiBaseUrl: string;
|
||||
adminKey: string;
|
||||
webhookSecret: string;
|
||||
};
|
||||
jwt: {
|
||||
secret: string;
|
||||
};
|
||||
scheduler: {
|
||||
drawIntervalSeconds: number;
|
||||
cycleGeneratorIntervalSeconds: number;
|
||||
};
|
||||
lottery: {
|
||||
defaultTicketPriceSats: number;
|
||||
defaultHouseFeePercent: number;
|
||||
maxTicketsPerPurchase: number;
|
||||
};
|
||||
admin: {
|
||||
apiKey: string;
|
||||
};
|
||||
payout: {
|
||||
maxAttemptsBeforeRedraw: number;
|
||||
};
|
||||
}
|
||||
|
||||
const config: Config = {
|
||||
app: {
|
||||
port: appPort,
|
||||
baseUrl: appBaseUrl,
|
||||
nodeEnv,
|
||||
},
|
||||
frontend: {
|
||||
origins: frontendOrigins,
|
||||
},
|
||||
cors: {
|
||||
allowedOrigins: allowedCorsOrigins,
|
||||
},
|
||||
database: {
|
||||
type: (process.env.DATABASE_TYPE || 'postgres') as 'postgres' | 'sqlite',
|
||||
url: process.env.DATABASE_URL ||
|
||||
(process.env.DATABASE_TYPE === 'sqlite'
|
||||
? './data/lightning_lotto.db'
|
||||
: 'postgresql://localhost:5432/lightning_lotto'),
|
||||
},
|
||||
lnbits: {
|
||||
apiBaseUrl: process.env.LNBITS_API_BASE_URL || '',
|
||||
adminKey: process.env.LNBITS_ADMIN_KEY || '',
|
||||
webhookSecret: process.env.LNBITS_WEBHOOK_SECRET || '',
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'your-secret-key-change-this',
|
||||
},
|
||||
scheduler: {
|
||||
drawIntervalSeconds: parseInt(process.env.DRAW_SCHEDULER_INTERVAL_SECONDS || '60', 10),
|
||||
cycleGeneratorIntervalSeconds: parseInt(process.env.CYCLE_GENERATOR_INTERVAL_SECONDS || '300', 10),
|
||||
},
|
||||
lottery: {
|
||||
defaultTicketPriceSats: parseInt(process.env.DEFAULT_TICKET_PRICE_SATS || '1000', 10),
|
||||
defaultHouseFeePercent: parseInt(process.env.DEFAULT_HOUSE_FEE_PERCENT || '5', 10),
|
||||
maxTicketsPerPurchase: 100,
|
||||
},
|
||||
admin: {
|
||||
apiKey: process.env.ADMIN_API_KEY || '',
|
||||
},
|
||||
payout: {
|
||||
maxAttemptsBeforeRedraw: parseInt(process.env.PAYOUT_MAX_ATTEMPTS || '2', 10),
|
||||
},
|
||||
};
|
||||
|
||||
// Validate critical environment variables
|
||||
function validateConfig(): void {
|
||||
const requiredVars = [
|
||||
{ key: 'LNBITS_API_BASE_URL', value: config.lnbits.apiBaseUrl },
|
||||
{ key: 'LNBITS_ADMIN_KEY', value: config.lnbits.adminKey },
|
||||
{ key: 'JWT_SECRET', value: config.jwt.secret },
|
||||
{ key: 'ADMIN_API_KEY', value: config.admin.apiKey },
|
||||
];
|
||||
|
||||
const missing = requiredVars.filter(v => !v.value || v.value === '');
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error('❌ Missing required environment variables:');
|
||||
missing.forEach(v => console.error(` - ${v.key}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate database type
|
||||
if (!['postgres', 'sqlite'].includes(config.database.type)) {
|
||||
console.error('❌ DATABASE_TYPE must be either "postgres" or "sqlite"');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.app.nodeEnv !== 'test') {
|
||||
validateConfig();
|
||||
}
|
||||
|
||||
export default config;
|
||||
|
||||
118
back_end/src/config/swagger.ts
Normal file
118
back_end/src/config/swagger.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
import config from './index';
|
||||
|
||||
const swaggerDefinition = {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Lightning Lottery API',
|
||||
version: '1.0.0',
|
||||
description: 'Bitcoin Lightning Network powered lottery system with instant payouts',
|
||||
contact: {
|
||||
name: 'Lightning Lottery',
|
||||
url: config.app.baseUrl,
|
||||
},
|
||||
license: {
|
||||
name: 'MIT',
|
||||
url: 'https://opensource.org/licenses/MIT',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: config.app.baseUrl,
|
||||
description: config.app.nodeEnv === 'production' ? 'Production server' : 'Development server',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'JWT token for Nostr authenticated users',
|
||||
},
|
||||
adminKey: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'X-Admin-Key',
|
||||
description: 'Admin API key for administrative endpoints',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
Lottery: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string' },
|
||||
ticket_price_sats: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
JackpotCycle: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
cycle_type: { type: 'string', enum: ['hourly', 'daily', 'weekly', 'monthly'] },
|
||||
scheduled_at: { type: 'string', format: 'date-time' },
|
||||
sales_open_at: { type: 'string', format: 'date-time' },
|
||||
sales_close_at: { type: 'string', format: 'date-time' },
|
||||
status: { type: 'string', enum: ['scheduled', 'sales_open', 'drawing', 'completed', 'cancelled'] },
|
||||
pot_total_sats: { type: 'integer' },
|
||||
pot_after_fee_sats: { type: 'integer', nullable: true },
|
||||
winning_ticket_id: { type: 'string', format: 'uuid', nullable: true },
|
||||
},
|
||||
},
|
||||
TicketPurchase: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
lightning_address: { type: 'string' },
|
||||
number_of_tickets: { type: 'integer' },
|
||||
amount_sats: { type: 'integer' },
|
||||
invoice_status: { type: 'string', enum: ['pending', 'paid', 'expired', 'cancelled'] },
|
||||
ticket_issue_status: { type: 'string', enum: ['not_issued', 'issued'] },
|
||||
},
|
||||
},
|
||||
Ticket: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
serial_number: { type: 'integer' },
|
||||
is_winning_ticket: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
version: { type: 'string', example: '1.0' },
|
||||
error: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
name: 'Public',
|
||||
description: 'Public endpoints (no authentication required)',
|
||||
},
|
||||
{
|
||||
name: 'User',
|
||||
description: 'User endpoints (Nostr authentication required)',
|
||||
},
|
||||
{
|
||||
name: 'Admin',
|
||||
description: 'Admin endpoints (admin API key required)',
|
||||
},
|
||||
{
|
||||
name: 'Webhooks',
|
||||
description: 'Webhook endpoints for external integrations',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options = {
|
||||
swaggerDefinition,
|
||||
apis: ['./src/routes/*.ts', './src/controllers/*.ts'], // Path to API docs
|
||||
};
|
||||
|
||||
export const swaggerSpec = swaggerJsdoc(options);
|
||||
|
||||
191
back_end/src/controllers/admin.ts
Normal file
191
back_end/src/controllers/admin.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { db } from '../database';
|
||||
import { executeDraw } from '../services/draw';
|
||||
import { retryPayoutService } from '../services/payout';
|
||||
import { JackpotCycle, Payout } from '../types';
|
||||
|
||||
/**
|
||||
* GET /admin/cycles
|
||||
* List all cycles with optional filters
|
||||
*/
|
||||
export async function listCycles(req: Request, res: Response) {
|
||||
try {
|
||||
const { status, cycle_type, limit = 50, offset = 0 } = req.query;
|
||||
|
||||
let query = `SELECT * FROM jackpot_cycles WHERE 1=1`;
|
||||
const params: any[] = [];
|
||||
let paramCount = 0;
|
||||
|
||||
if (status) {
|
||||
paramCount++;
|
||||
query += ` AND status = $${paramCount}`;
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
if (cycle_type) {
|
||||
paramCount++;
|
||||
query += ` AND cycle_type = $${paramCount}`;
|
||||
params.push(cycle_type);
|
||||
}
|
||||
|
||||
query += ` ORDER BY scheduled_at DESC LIMIT $${paramCount + 1} OFFSET $${paramCount + 2}`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const result = await db.query<JackpotCycle>(query, params);
|
||||
|
||||
const cycles = result.rows.map(c => ({
|
||||
id: c.id,
|
||||
lottery_id: c.lottery_id,
|
||||
cycle_type: c.cycle_type,
|
||||
sequence_number: c.sequence_number,
|
||||
scheduled_at: c.scheduled_at.toISOString(),
|
||||
status: c.status,
|
||||
pot_total_sats: parseInt(c.pot_total_sats.toString()),
|
||||
pot_after_fee_sats: c.pot_after_fee_sats ? parseInt(c.pot_after_fee_sats.toString()) : null,
|
||||
winning_ticket_id: c.winning_ticket_id,
|
||||
winning_lightning_address: c.winning_lightning_address,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
data: {
|
||||
cycles,
|
||||
total: cycles.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('List cycles error:', error);
|
||||
return res.status(500).json({
|
||||
version: '1.0',
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to list cycles',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/cycles/:id/run-draw
|
||||
* Manually trigger draw execution
|
||||
*/
|
||||
export async function runDrawManually(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await executeDraw(id);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json({
|
||||
version: '1.0',
|
||||
error: result.error || 'DRAW_FAILED',
|
||||
message: result.message || 'Failed to execute draw',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
data: {
|
||||
cycle_id: id,
|
||||
winning_ticket_id: result.winningTicketId,
|
||||
pot_after_fee_sats: result.potAfterFeeSats,
|
||||
payout_status: result.payoutStatus,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Manual draw error:', error);
|
||||
return res.status(500).json({
|
||||
version: '1.0',
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to run draw',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/payouts
|
||||
* List payouts with optional filters
|
||||
*/
|
||||
export async function listPayouts(req: Request, res: Response) {
|
||||
try {
|
||||
const { status, limit = 50, offset = 0 } = req.query;
|
||||
|
||||
let query = `SELECT * FROM payouts WHERE 1=1`;
|
||||
const params: any[] = [];
|
||||
let paramCount = 0;
|
||||
|
||||
if (status) {
|
||||
paramCount++;
|
||||
query += ` AND status = $${paramCount}`;
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
query += ` ORDER BY created_at DESC LIMIT $${paramCount + 1} OFFSET $${paramCount + 2}`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const result = await db.query<Payout>(query, params);
|
||||
|
||||
const payouts = result.rows.map(p => ({
|
||||
id: p.id,
|
||||
lottery_id: p.lottery_id,
|
||||
cycle_id: p.cycle_id,
|
||||
ticket_id: p.ticket_id,
|
||||
lightning_address: p.lightning_address,
|
||||
amount_sats: parseInt(p.amount_sats.toString()),
|
||||
status: p.status,
|
||||
error_message: p.error_message,
|
||||
retry_count: p.retry_count,
|
||||
created_at: p.created_at.toISOString(),
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
data: {
|
||||
payouts,
|
||||
total: payouts.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('List payouts error:', error);
|
||||
return res.status(500).json({
|
||||
version: '1.0',
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to list payouts',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/payouts/:id/retry
|
||||
* Retry a failed payout
|
||||
*/
|
||||
export async function retryPayout(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await retryPayoutService(id);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json({
|
||||
version: '1.0',
|
||||
error: result.error || 'RETRY_FAILED',
|
||||
message: result.message || 'Failed to retry payout',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
data: {
|
||||
payout_id: id,
|
||||
status: result.status,
|
||||
retry_count: result.retryCount,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Retry payout error:', error);
|
||||
return res.status(500).json({
|
||||
version: '1.0',
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to retry payout',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
483
back_end/src/controllers/public.ts
Normal file
483
back_end/src/controllers/public.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { db } from '../database';
|
||||
import { lnbitsService } from '../services/lnbits';
|
||||
import { paymentMonitor } from '../services/paymentMonitor';
|
||||
import { validateLightningAddress, validateTicketCount, sanitizeString } from '../utils/validation';
|
||||
import config from '../config';
|
||||
import { JackpotCycle, TicketPurchase, Ticket, Payout } from '../types';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
|
||||
const toIsoString = (value: any): string => {
|
||||
if (!value) {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// SQLite stores timestamps as "YYYY-MM-DD HH:mm:ss" by default
|
||||
const normalized = value.includes('T')
|
||||
? value
|
||||
: value.replace(' ', 'T') + 'Z';
|
||||
const parsed = new Date(normalized);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && typeof (value as any).toISOString === 'function') {
|
||||
return (value as any).toISOString();
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
return new Date().toISOString();
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /jackpot/next
|
||||
* Returns the next upcoming cycle
|
||||
*/
|
||||
export async function getNextJackpot(req: Request, res: Response) {
|
||||
try {
|
||||
// Get active lottery
|
||||
const lotteryResult = await db.query<any>(
|
||||
`SELECT * FROM lotteries WHERE status = 'active' LIMIT 1`
|
||||
);
|
||||
|
||||
if (lotteryResult.rows.length === 0) {
|
||||
return res.status(503).json({
|
||||
version: '1.0',
|
||||
error: 'NO_ACTIVE_LOTTERY',
|
||||
message: 'No active lottery available',
|
||||
});
|
||||
}
|
||||
|
||||
const lottery = lotteryResult.rows[0];
|
||||
|
||||
// Get next cycle
|
||||
const cycleResult = await db.query<JackpotCycle>(
|
||||
`SELECT * FROM jackpot_cycles
|
||||
WHERE lottery_id = $1
|
||||
AND status IN ('scheduled', 'sales_open')
|
||||
AND scheduled_at > NOW()
|
||||
ORDER BY scheduled_at ASC
|
||||
LIMIT 1`,
|
||||
[lottery.id]
|
||||
);
|
||||
|
||||
if (cycleResult.rows.length === 0) {
|
||||
return res.status(503).json({
|
||||
version: '1.0',
|
||||
error: 'NO_UPCOMING_CYCLE',
|
||||
message: 'No upcoming cycle available',
|
||||
});
|
||||
}
|
||||
|
||||
const cycle = cycleResult.rows[0];
|
||||
|
||||
// Helper function to ensure ISO string format
|
||||
const toISOString = (date: any): string => {
|
||||
if (!date) return new Date().toISOString();
|
||||
if (typeof date === 'string') return date.includes('Z') ? date : new Date(date).toISOString();
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
data: {
|
||||
lottery: {
|
||||
id: lottery.id,
|
||||
name: lottery.name,
|
||||
ticket_price_sats: parseInt(lottery.ticket_price_sats),
|
||||
},
|
||||
cycle: {
|
||||
id: cycle.id,
|
||||
cycle_type: cycle.cycle_type,
|
||||
scheduled_at: toISOString(cycle.scheduled_at),
|
||||
sales_open_at: toISOString(cycle.sales_open_at),
|
||||
sales_close_at: toISOString(cycle.sales_close_at),
|
||||
status: cycle.status,
|
||||
pot_total_sats: parseInt(cycle.pot_total_sats.toString()),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Get next jackpot error:', error);
|
||||
return res.status(500).json({
|
||||
version: '1.0',
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to fetch next jackpot',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /jackpot/buy
|
||||
* Create a ticket purchase
|
||||
*/
|
||||
export async function buyTickets(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const { tickets, lightning_address, nostr_pubkey, name, buyer_name } = req.body;
|
||||
const userId = req.user?.id || null;
|
||||
const authNostrPubkey = req.user?.nostr_pubkey || null;
|
||||
|
||||
// Validation
|
||||
if (!validateTicketCount(tickets, config.lottery.maxTicketsPerPurchase)) {
|
||||
return res.status(400).json({
|
||||
version: '1.0',
|
||||
error: 'INVALID_TICKET_COUNT',
|
||||
message: `Tickets must be between 1 and ${config.lottery.maxTicketsPerPurchase}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!validateLightningAddress(lightning_address)) {
|
||||
return res.status(400).json({
|
||||
version: '1.0',
|
||||
error: 'INVALID_LIGHTNING_ADDRESS',
|
||||
message: 'Lightning address must contain @ and be valid format',
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedLightningAddress = sanitizeString(lightning_address);
|
||||
const rawNameInput = typeof name === 'string'
|
||||
? name
|
||||
: typeof buyer_name === 'string'
|
||||
? buyer_name
|
||||
: '';
|
||||
const buyerName = sanitizeString(rawNameInput || 'Anon', 64) || 'Anon';
|
||||
|
||||
// Get active lottery
|
||||
const lotteryResult = await db.query(
|
||||
`SELECT * FROM lotteries WHERE status = 'active' LIMIT 1`
|
||||
);
|
||||
|
||||
if (lotteryResult.rows.length === 0) {
|
||||
return res.status(503).json({
|
||||
version: '1.0',
|
||||
error: 'NO_ACTIVE_LOTTERY',
|
||||
message: 'No active lottery available',
|
||||
});
|
||||
}
|
||||
|
||||
const lottery = lotteryResult.rows[0];
|
||||
|
||||
// Get next available cycle
|
||||
const cycleResult = await db.query<JackpotCycle>(
|
||||
`SELECT * FROM jackpot_cycles
|
||||
WHERE lottery_id = $1
|
||||
AND status IN ('scheduled', 'sales_open')
|
||||
AND sales_close_at > NOW()
|
||||
ORDER BY scheduled_at ASC
|
||||
LIMIT 1`,
|
||||
[lottery.id]
|
||||
);
|
||||
|
||||
if (cycleResult.rows.length === 0) {
|
||||
return res.status(400).json({
|
||||
version: '1.0',
|
||||
error: 'NO_AVAILABLE_CYCLE',
|
||||
message: 'No cycle available for ticket purchase',
|
||||
});
|
||||
}
|
||||
|
||||
const cycle = cycleResult.rows[0];
|
||||
|
||||
// Calculate amount
|
||||
const ticketPriceSats = parseInt(lottery.ticket_price_sats);
|
||||
const amountSats = tickets * ticketPriceSats;
|
||||
|
||||
// Create ticket purchase record
|
||||
const purchaseId = randomUUID();
|
||||
|
||||
const purchaseResult = await db.query<TicketPurchase>(
|
||||
`INSERT INTO ticket_purchases (
|
||||
id, lottery_id, cycle_id, user_id, nostr_pubkey, lightning_address,
|
||||
buyer_name,
|
||||
number_of_tickets, ticket_price_sats, amount_sats,
|
||||
lnbits_invoice_id, lnbits_payment_hash, invoice_status, ticket_issue_status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING *`,
|
||||
[
|
||||
purchaseId,
|
||||
lottery.id,
|
||||
cycle.id,
|
||||
userId,
|
||||
nostr_pubkey || authNostrPubkey || null,
|
||||
normalizedLightningAddress,
|
||||
buyerName,
|
||||
tickets,
|
||||
ticketPriceSats,
|
||||
amountSats,
|
||||
'', // Will update after invoice creation
|
||||
'', // Will update after invoice creation
|
||||
'pending',
|
||||
'not_issued',
|
||||
]
|
||||
);
|
||||
|
||||
const purchase = purchaseResult.rows[0];
|
||||
const publicUrl = `${config.app.baseUrl}/tickets/${purchase.id}`;
|
||||
|
||||
// Create invoice memo
|
||||
const memo = `Lightning Jackpot
|
||||
Tickets: ${tickets}
|
||||
Purchase ID: ${purchase.id}
|
||||
Check status: ${publicUrl}`;
|
||||
|
||||
// Create LNbits invoice
|
||||
try {
|
||||
const webhookUrl = new URL('/webhooks/lnbits/payment', config.app.baseUrl);
|
||||
if (config.lnbits.webhookSecret) {
|
||||
webhookUrl.searchParams.set('secret', config.lnbits.webhookSecret);
|
||||
}
|
||||
|
||||
const invoice = await lnbitsService.createInvoice({
|
||||
amount: amountSats,
|
||||
memo: memo,
|
||||
webhook: webhookUrl.toString(),
|
||||
});
|
||||
|
||||
// Update purchase with invoice details
|
||||
await db.query(
|
||||
`UPDATE ticket_purchases
|
||||
SET lnbits_invoice_id = $1, lnbits_payment_hash = $2, updated_at = NOW()
|
||||
WHERE id = $3`,
|
||||
[invoice.checking_id, invoice.payment_hash, purchase.id]
|
||||
);
|
||||
|
||||
if (userId) {
|
||||
await db.query(
|
||||
`UPDATE users
|
||||
SET lightning_address = $1, updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[normalizedLightningAddress, userId]
|
||||
);
|
||||
}
|
||||
|
||||
paymentMonitor.addInvoice(purchase.id, invoice.payment_hash);
|
||||
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
data: {
|
||||
ticket_purchase_id: purchase.id,
|
||||
public_url: publicUrl,
|
||||
invoice: {
|
||||
payment_request: invoice.payment_request,
|
||||
amount_sats: amountSats,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (invoiceError: any) {
|
||||
// Cleanup purchase if invoice creation fails
|
||||
await db.query('DELETE FROM ticket_purchases WHERE id = $1', [purchase.id]);
|
||||
|
||||
return res.status(502).json({
|
||||
version: '1.0',
|
||||
error: 'INVOICE_CREATION_FAILED',
|
||||
message: 'Failed to create Lightning invoice',
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Buy tickets error:', error);
|
||||
return res.status(500).json({
|
||||
version: '1.0',
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to process ticket purchase',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /tickets/:id
|
||||
* Get ticket purchase status
|
||||
*/
|
||||
export async function getTicketStatus(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get purchase
|
||||
const purchaseResult = await db.query<TicketPurchase>(
|
||||
`SELECT * FROM ticket_purchases WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (purchaseResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
version: '1.0',
|
||||
error: 'PURCHASE_NOT_FOUND',
|
||||
message: 'Ticket purchase not found',
|
||||
});
|
||||
}
|
||||
|
||||
const purchase = purchaseResult.rows[0];
|
||||
|
||||
// Get cycle
|
||||
const cycleResult = await db.query<JackpotCycle>(
|
||||
`SELECT * FROM jackpot_cycles WHERE id = $1`,
|
||||
[purchase.cycle_id]
|
||||
);
|
||||
|
||||
const cycle = cycleResult.rows[0];
|
||||
|
||||
// Get tickets
|
||||
const ticketsResult = await db.query<Ticket>(
|
||||
`SELECT * FROM tickets WHERE ticket_purchase_id = $1 ORDER BY serial_number`,
|
||||
[id]
|
||||
);
|
||||
|
||||
const tickets = ticketsResult.rows.map(t => ({
|
||||
id: t.id,
|
||||
serial_number: parseInt(t.serial_number.toString()),
|
||||
is_winning_ticket: cycle.winning_ticket_id === t.id,
|
||||
}));
|
||||
|
||||
// Determine result
|
||||
let result = {
|
||||
has_drawn: cycle.status === 'completed',
|
||||
is_winner: false,
|
||||
payout: null as any,
|
||||
};
|
||||
|
||||
if (cycle.status === 'completed' && cycle.winning_ticket_id) {
|
||||
const isWinner = tickets.some(t => t.id === cycle.winning_ticket_id);
|
||||
result.is_winner = isWinner;
|
||||
|
||||
if (isWinner) {
|
||||
// Get payout
|
||||
const payoutResult = await db.query<Payout>(
|
||||
`SELECT * FROM payouts WHERE ticket_id = $1`,
|
||||
[cycle.winning_ticket_id]
|
||||
);
|
||||
|
||||
if (payoutResult.rows.length > 0) {
|
||||
const payout = payoutResult.rows[0];
|
||||
result.payout = {
|
||||
status: payout.status,
|
||||
amount_sats: parseInt(payout.amount_sats.toString()),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
data: {
|
||||
purchase: {
|
||||
id: purchase.id,
|
||||
lottery_id: purchase.lottery_id,
|
||||
cycle_id: purchase.cycle_id,
|
||||
lightning_address: purchase.lightning_address,
|
||||
buyer_name: purchase.buyer_name,
|
||||
number_of_tickets: purchase.number_of_tickets,
|
||||
ticket_price_sats: parseInt(purchase.ticket_price_sats.toString()),
|
||||
amount_sats: parseInt(purchase.amount_sats.toString()),
|
||||
invoice_status: purchase.invoice_status,
|
||||
ticket_issue_status: purchase.ticket_issue_status,
|
||||
created_at: toIsoString(purchase.created_at),
|
||||
},
|
||||
tickets,
|
||||
cycle: {
|
||||
id: cycle.id,
|
||||
cycle_type: cycle.cycle_type,
|
||||
scheduled_at: toIsoString(cycle.scheduled_at),
|
||||
sales_open_at: toIsoString(cycle.sales_open_at),
|
||||
sales_close_at: toIsoString(cycle.sales_close_at),
|
||||
status: cycle.status,
|
||||
pot_total_sats: parseInt(cycle.pot_total_sats.toString()),
|
||||
pot_after_fee_sats: cycle.pot_after_fee_sats ? parseInt(cycle.pot_after_fee_sats.toString()) : null,
|
||||
winning_ticket_id: cycle.winning_ticket_id,
|
||||
},
|
||||
result,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Get ticket status error:', error);
|
||||
return res.status(500).json({
|
||||
version: '1.0',
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to fetch ticket status',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface PastWinRow {
|
||||
cycle_id: string;
|
||||
cycle_type: JackpotCycle['cycle_type'];
|
||||
scheduled_at: Date | string;
|
||||
pot_total_sats: number;
|
||||
pot_after_fee_sats: number | null;
|
||||
buyer_name: string | null;
|
||||
serial_number: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /jackpot/past-wins
|
||||
* List past jackpots with winner info
|
||||
*/
|
||||
export async function getPastWins(req: Request, res: Response) {
|
||||
try {
|
||||
const limitParam = parseInt((req.query.limit as string) || '20', 10);
|
||||
const offsetParam = parseInt((req.query.offset as string) || '0', 10);
|
||||
const limit = Math.min(100, Math.max(1, Number.isNaN(limitParam) ? 20 : limitParam));
|
||||
const offset = Math.max(0, Number.isNaN(offsetParam) ? 0 : offsetParam);
|
||||
|
||||
const result = await db.query<PastWinRow>(
|
||||
`SELECT
|
||||
jc.id as cycle_id,
|
||||
jc.cycle_type,
|
||||
jc.scheduled_at,
|
||||
jc.pot_total_sats,
|
||||
jc.pot_after_fee_sats,
|
||||
tp.buyer_name,
|
||||
t.serial_number
|
||||
FROM jackpot_cycles jc
|
||||
JOIN tickets t ON jc.winning_ticket_id = t.id
|
||||
JOIN ticket_purchases tp ON t.ticket_purchase_id = tp.id
|
||||
WHERE jc.status = 'completed'
|
||||
AND jc.winning_ticket_id IS NOT NULL
|
||||
ORDER BY jc.scheduled_at DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
const wins = result.rows.map((row) => ({
|
||||
cycle_id: row.cycle_id,
|
||||
cycle_type: row.cycle_type,
|
||||
scheduled_at: toIsoString(row.scheduled_at),
|
||||
pot_total_sats: parseInt(row.pot_total_sats.toString()),
|
||||
pot_after_fee_sats: row.pot_after_fee_sats
|
||||
? parseInt(row.pot_after_fee_sats.toString())
|
||||
: null,
|
||||
winner_name: row.buyer_name || 'Anon',
|
||||
winning_ticket_serial: row.serial_number
|
||||
? parseInt(row.serial_number.toString())
|
||||
: null,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
data: {
|
||||
wins,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Get past wins error:', error);
|
||||
return res.status(500).json({
|
||||
version: '1.0',
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to load past wins',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
348
back_end/src/controllers/user.ts
Normal file
348
back_end/src/controllers/user.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db } from '../database';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { validateNostrPubkey, validateLightningAddress } from '../utils/validation';
|
||||
import config from '../config';
|
||||
import { User, TicketPurchase, Payout } from '../types';
|
||||
|
||||
const toIsoString = (value: any): string => {
|
||||
if (!value) {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.includes('T') ? value : value.replace(' ', 'T') + 'Z';
|
||||
const parsed = new Date(normalized);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && typeof (value as any).toISOString === 'function') {
|
||||
return (value as any).toISOString();
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
return new Date().toISOString();
|
||||
};
|
||||
|
||||
const parseCount = (value: any): number => {
|
||||
const parsed = parseInt(value?.toString() ?? '0', 10);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /auth/nostr
|
||||
* Authenticate with Nostr signature
|
||||
*/
|
||||
export async function nostrAuth(req: Request, res: Response) {
|
||||
try {
|
||||
const { nostr_pubkey, signed_message, nonce } = req.body;
|
||||
|
||||
// Validate pubkey format
|
||||
if (!validateNostrPubkey(nostr_pubkey)) {
|
||||
return res.status(400).json({
|
||||
version: '1.0',
|
||||
error: 'INVALID_PUBKEY',
|
||||
message: 'Invalid Nostr public key format',
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Implement actual signature verification using nostr-tools
|
||||
// For now, we'll trust the pubkey (NOT PRODUCTION READY)
|
||||
// In production, verify the signature against the nonce
|
||||
|
||||
// Find or create user
|
||||
let userResult = await db.query<User>(
|
||||
`SELECT * FROM users WHERE nostr_pubkey = $1`,
|
||||
[nostr_pubkey]
|
||||
);
|
||||
|
||||
let user: User;
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
// Create new user
|
||||
const userId = randomUUID();
|
||||
const createResult = await db.query<User>(
|
||||
`INSERT INTO users (id, nostr_pubkey) VALUES ($1, $2) RETURNING *`,
|
||||
[userId, nostr_pubkey]
|
||||
);
|
||||
user = createResult.rows[0];
|
||||
} else {
|
||||
user = userResult.rows[0];
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
nostr_pubkey: user.nostr_pubkey,
|
||||
},
|
||||
config.jwt.secret,
|
||||
{ expiresIn: '30d' }
|
||||
);
|
||||
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
data: {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
nostr_pubkey: user.nostr_pubkey,
|
||||
display_name: user.display_name,
|
||||
lightning_address: user.lightning_address,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Nostr auth error:', error);
|
||||
return res.status(500).json({
|
||||
version: '1.0',
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to authenticate',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /me
|
||||
* Get user profile
|
||||
*/
|
||||
export async function getProfile(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
|
||||
const userResult = await db.query<User>(
|
||||
`SELECT * FROM users WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
version: '1.0',
|
||||
error: 'USER_NOT_FOUND',
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
const nostrPubkey = user.nostr_pubkey;
|
||||
|
||||
const totalTicketsResult = await db.query(
|
||||
`SELECT COALESCE(SUM(number_of_tickets), 0) as total_tickets
|
||||
FROM ticket_purchases
|
||||
WHERE user_id = $1 OR nostr_pubkey = $2`,
|
||||
[userId, nostrPubkey]
|
||||
);
|
||||
|
||||
const currentRoundResult = await db.query(
|
||||
`SELECT COALESCE(SUM(tp.number_of_tickets), 0) as current_tickets
|
||||
FROM ticket_purchases tp
|
||||
JOIN jackpot_cycles jc ON tp.cycle_id = jc.id
|
||||
WHERE (tp.user_id = $1 OR tp.nostr_pubkey = $2)
|
||||
AND jc.status IN ('scheduled', 'sales_open')`,
|
||||
[userId, nostrPubkey]
|
||||
);
|
||||
|
||||
const pastTicketsResult = await db.query(
|
||||
`SELECT COALESCE(SUM(tp.number_of_tickets), 0) as past_tickets
|
||||
FROM ticket_purchases tp
|
||||
JOIN jackpot_cycles jc ON tp.cycle_id = jc.id
|
||||
WHERE (tp.user_id = $1 OR tp.nostr_pubkey = $2)
|
||||
AND jc.status NOT IN ('scheduled', 'sales_open')`,
|
||||
[userId, nostrPubkey]
|
||||
);
|
||||
|
||||
const winStats = await db.query(
|
||||
`SELECT
|
||||
COALESCE(SUM(CASE WHEN p.status = 'paid' THEN 1 ELSE 0 END), 0) as total_wins,
|
||||
COALESCE(SUM(CASE WHEN p.status = 'paid' THEN p.amount_sats ELSE 0 END), 0) as total_winnings
|
||||
FROM payouts p
|
||||
JOIN tickets t ON p.ticket_id = t.id
|
||||
JOIN ticket_purchases tp ON t.ticket_purchase_id = tp.id
|
||||
WHERE tp.user_id = $1 OR tp.nostr_pubkey = $2`,
|
||||
[userId, nostrPubkey]
|
||||
);
|
||||
|
||||
const totalTickets = parseCount(totalTicketsResult.rows[0]?.total_tickets);
|
||||
const currentRoundTickets = parseCount(currentRoundResult.rows[0]?.current_tickets);
|
||||
const pastTickets = parseCount(pastTicketsResult.rows[0]?.past_tickets);
|
||||
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
nostr_pubkey: user.nostr_pubkey,
|
||||
display_name: user.display_name,
|
||||
lightning_address: user.lightning_address,
|
||||
},
|
||||
stats: {
|
||||
total_tickets: totalTickets,
|
||||
current_round_tickets: currentRoundTickets,
|
||||
past_tickets: pastTickets,
|
||||
total_wins: parseCount(winStats.rows[0]?.total_wins),
|
||||
total_winnings_sats: parseCount(winStats.rows[0]?.total_winnings),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Get profile error:', error);
|
||||
return res.status(500).json({
|
||||
version: '1.0',
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to get profile',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /me/lightning-address
|
||||
* Update user's lightning address
|
||||
*/
|
||||
export async function updateLightningAddress(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const { lightning_address } = req.body;
|
||||
|
||||
if (!validateLightningAddress(lightning_address)) {
|
||||
return res.status(400).json({
|
||||
version: '1.0',
|
||||
error: 'INVALID_LIGHTNING_ADDRESS',
|
||||
message: 'Invalid Lightning Address format',
|
||||
});
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`UPDATE users SET lightning_address = $1, updated_at = NOW() WHERE id = $2`,
|
||||
[lightning_address, userId]
|
||||
);
|
||||
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
data: {
|
||||
lightning_address,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Update lightning address error:', error);
|
||||
return res.status(500).json({
|
||||
version: '1.0',
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to update lightning address',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /me/tickets
|
||||
* Get user's ticket purchases
|
||||
*/
|
||||
export async function getUserTickets(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const userPubkey = req.user!.nostr_pubkey;
|
||||
const limitParam = typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : 50;
|
||||
const offsetParam = typeof req.query.offset === 'string' ? parseInt(req.query.offset, 10) : 0;
|
||||
const limitValue = Math.min(100, Math.max(1, Number.isNaN(limitParam) ? 50 : limitParam));
|
||||
const offsetValue = Math.max(0, Number.isNaN(offsetParam) ? 0 : offsetParam);
|
||||
|
||||
const result = await db.query<TicketPurchase>(
|
||||
`SELECT tp.*, jc.cycle_type, jc.scheduled_at, jc.status as cycle_status
|
||||
FROM ticket_purchases tp
|
||||
JOIN jackpot_cycles jc ON tp.cycle_id = jc.id
|
||||
WHERE (tp.user_id = $1 OR tp.nostr_pubkey = $2)
|
||||
ORDER BY tp.created_at DESC
|
||||
LIMIT $3 OFFSET $4`,
|
||||
[userId, userPubkey, limitValue, offsetValue]
|
||||
);
|
||||
|
||||
const purchases = result.rows.map(p => ({
|
||||
id: p.id,
|
||||
cycle_id: p.cycle_id,
|
||||
buyer_name: p.buyer_name,
|
||||
number_of_tickets: p.number_of_tickets,
|
||||
amount_sats: parseInt(p.amount_sats.toString()),
|
||||
invoice_status: p.invoice_status,
|
||||
ticket_issue_status: p.ticket_issue_status,
|
||||
created_at: toIsoString(p.created_at),
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
data: {
|
||||
purchases,
|
||||
total: purchases.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Get user tickets error:', error);
|
||||
return res.status(500).json({
|
||||
version: '1.0',
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to get tickets',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /me/wins
|
||||
* Get user's wins
|
||||
*/
|
||||
export async function getUserWins(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const userPubkey = req.user!.nostr_pubkey;
|
||||
|
||||
const result = await db.query<Payout>(
|
||||
`SELECT p.*, jc.cycle_type, jc.scheduled_at
|
||||
FROM payouts p
|
||||
JOIN tickets t ON p.ticket_id = t.id
|
||||
JOIN ticket_purchases tp ON t.ticket_purchase_id = tp.id
|
||||
JOIN jackpot_cycles jc ON p.cycle_id = jc.id
|
||||
WHERE tp.user_id = $1 OR tp.nostr_pubkey = $2
|
||||
ORDER BY p.created_at DESC`,
|
||||
[userId, userPubkey]
|
||||
);
|
||||
|
||||
const wins = result.rows.map(p => ({
|
||||
id: p.id,
|
||||
cycle_id: p.cycle_id,
|
||||
ticket_id: p.ticket_id,
|
||||
amount_sats: parseInt(p.amount_sats.toString()),
|
||||
status: p.status,
|
||||
created_at: toIsoString(p.created_at),
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
data: {
|
||||
wins,
|
||||
total: wins.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Get user wins error:', error);
|
||||
return res.status(500).json({
|
||||
version: '1.0',
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to get wins',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
98
back_end/src/controllers/webhooks.ts
Normal file
98
back_end/src/controllers/webhooks.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { db } from '../database';
|
||||
import { lnbitsService } from '../services/lnbits';
|
||||
import { TicketPurchase } from '../types';
|
||||
import { finalizeTicketPurchase } from '../services/paymentProcessor';
|
||||
import { paymentMonitor } from '../services/paymentMonitor';
|
||||
|
||||
/**
|
||||
* POST /webhooks/lnbits/payment
|
||||
* Handle LNbits payment webhook
|
||||
*/
|
||||
export async function handleLNbitsPayment(req: Request, res: Response) {
|
||||
try {
|
||||
const { payment_hash, amount, paid } = req.body;
|
||||
|
||||
// Verify webhook (basic secret validation)
|
||||
const webhookSecretHeader = (req.headers['x-webhook-secret'] || req.headers['x-callback-secret']) as string | undefined;
|
||||
const webhookSecretQuery = (() => {
|
||||
const value = req.query?.secret;
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
return value as string | undefined;
|
||||
})();
|
||||
|
||||
const providedSecret = webhookSecretHeader || webhookSecretQuery || '';
|
||||
|
||||
if (!lnbitsService.verifyWebhook(providedSecret)) {
|
||||
console.error('Webhook verification failed');
|
||||
return res.status(403).json({
|
||||
version: '1.0',
|
||||
error: 'FORBIDDEN',
|
||||
message: 'Invalid webhook signature',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if payment is successful
|
||||
if (!paid) {
|
||||
console.log('Payment not completed yet:', payment_hash);
|
||||
return res.json({ version: '1.0', message: 'Payment not completed' });
|
||||
}
|
||||
|
||||
// Find purchase by payment hash
|
||||
const purchaseResult = await db.query<TicketPurchase>(
|
||||
`SELECT * FROM ticket_purchases WHERE lnbits_payment_hash = $1`,
|
||||
[payment_hash]
|
||||
);
|
||||
|
||||
if (purchaseResult.rows.length === 0) {
|
||||
console.error('Purchase not found for payment hash:', payment_hash);
|
||||
// Return 200 to avoid retries for unknown payments
|
||||
return res.json({ version: '1.0', message: 'Purchase not found' });
|
||||
}
|
||||
|
||||
const purchase = purchaseResult.rows[0];
|
||||
|
||||
// Idempotency check
|
||||
if (purchase.invoice_status === 'paid') {
|
||||
console.log('Invoice already marked as paid:', purchase.id);
|
||||
return res.json({ version: '1.0', message: 'Already processed' });
|
||||
}
|
||||
|
||||
// Verify amount (optional, but recommended)
|
||||
const expectedAmount = parseInt(purchase.amount_sats.toString());
|
||||
if (amount && amount < expectedAmount) {
|
||||
console.error('Payment amount mismatch:', { expected: expectedAmount, received: amount });
|
||||
// You could mark as error here, but for now we'll still process
|
||||
}
|
||||
|
||||
const finalizeResult = await finalizeTicketPurchase(purchase.id);
|
||||
if (finalizeResult.status === 'not_found') {
|
||||
console.error('Purchase disappeared before processing:', purchase.id);
|
||||
return res.json({ version: '1.0', message: 'Purchase missing during processing' });
|
||||
}
|
||||
|
||||
paymentMonitor.removeInvoice(purchase.id);
|
||||
|
||||
console.log('Payment processed successfully:', {
|
||||
purchase_id: purchase.id,
|
||||
tickets: finalizeResult.ticketsIssued,
|
||||
amount_sats: purchase.amount_sats,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
version: '1.0',
|
||||
message: 'Payment processed successfully',
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Webhook processing error:', error);
|
||||
return res.status(500).json({
|
||||
version: '1.0',
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: 'Failed to process payment webhook',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
334
back_end/src/database/index.ts
Normal file
334
back_end/src/database/index.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { Pool, QueryResult, PoolClient, QueryResultRow } from 'pg';
|
||||
import Database from 'better-sqlite3';
|
||||
import config from '../config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
interface DbClient {
|
||||
query<T extends QueryResultRow = any>(text: string, params?: any[]): Promise<QueryResult<T>>;
|
||||
release?: () => void;
|
||||
}
|
||||
|
||||
class DatabaseWrapper {
|
||||
private pgPool?: Pool;
|
||||
private sqliteDb?: Database.Database;
|
||||
private dbType: 'postgres' | 'sqlite';
|
||||
|
||||
constructor() {
|
||||
this.dbType = config.database.type;
|
||||
|
||||
if (this.dbType === 'postgres') {
|
||||
this.initPostgres();
|
||||
} else {
|
||||
this.initSqlite();
|
||||
}
|
||||
}
|
||||
|
||||
private initPostgres() {
|
||||
this.pgPool = new Pool({
|
||||
connectionString: config.database.url,
|
||||
ssl: config.app.nodeEnv === 'production' ? { rejectUnauthorized: false } : false,
|
||||
});
|
||||
|
||||
this.pgPool.on('error', (err) => {
|
||||
console.error('Unexpected PostgreSQL error:', err);
|
||||
});
|
||||
|
||||
console.log('✓ PostgreSQL database initialized');
|
||||
}
|
||||
|
||||
private initSqlite() {
|
||||
// Ensure data directory exists
|
||||
const dbPath = config.database.url;
|
||||
const dbDir = path.dirname(dbPath);
|
||||
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
this.sqliteDb = new Database(dbPath);
|
||||
this.sqliteDb.pragma('journal_mode = WAL');
|
||||
this.sqliteDb.pragma('foreign_keys = ON');
|
||||
|
||||
// Initialize schema for SQLite
|
||||
this.initSqliteSchema();
|
||||
|
||||
console.log(`✓ SQLite database initialized at: ${dbPath}`);
|
||||
}
|
||||
|
||||
private initSqliteSchema() {
|
||||
if (!this.sqliteDb) return;
|
||||
|
||||
const schema = `
|
||||
-- SQLite Schema for Lightning Lottery
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lotteries (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'finished')),
|
||||
ticket_price_sats INTEGER NOT NULL,
|
||||
fee_percent INTEGER NOT NULL CHECK (fee_percent >= 0 AND fee_percent <= 100),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jackpot_cycles (
|
||||
id TEXT PRIMARY KEY,
|
||||
lottery_id TEXT NOT NULL REFERENCES lotteries(id),
|
||||
cycle_type TEXT NOT NULL CHECK (cycle_type IN ('hourly', 'daily', 'weekly', 'monthly')),
|
||||
sequence_number INTEGER NOT NULL,
|
||||
scheduled_at TEXT NOT NULL,
|
||||
sales_open_at TEXT NOT NULL,
|
||||
sales_close_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('scheduled', 'sales_open', 'drawing', 'completed', 'cancelled')),
|
||||
pot_total_sats INTEGER NOT NULL DEFAULT 0,
|
||||
pot_after_fee_sats INTEGER,
|
||||
winning_ticket_id TEXT,
|
||||
winning_lightning_address TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
nostr_pubkey TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT,
|
||||
lightning_address TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_purchases (
|
||||
id TEXT PRIMARY KEY,
|
||||
lottery_id TEXT NOT NULL REFERENCES lotteries(id),
|
||||
cycle_id TEXT NOT NULL REFERENCES jackpot_cycles(id),
|
||||
user_id TEXT REFERENCES users(id),
|
||||
nostr_pubkey TEXT,
|
||||
lightning_address TEXT NOT NULL,
|
||||
buyer_name TEXT NOT NULL DEFAULT 'Anon',
|
||||
number_of_tickets INTEGER NOT NULL,
|
||||
ticket_price_sats INTEGER NOT NULL,
|
||||
amount_sats INTEGER NOT NULL,
|
||||
lnbits_invoice_id TEXT NOT NULL,
|
||||
lnbits_payment_hash TEXT NOT NULL,
|
||||
invoice_status TEXT NOT NULL CHECK (invoice_status IN ('pending', 'paid', 'expired', 'cancelled')),
|
||||
ticket_issue_status TEXT NOT NULL CHECK (ticket_issue_status IN ('not_issued', 'issued')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id TEXT PRIMARY KEY,
|
||||
lottery_id TEXT NOT NULL REFERENCES lotteries(id),
|
||||
cycle_id TEXT NOT NULL REFERENCES jackpot_cycles(id),
|
||||
ticket_purchase_id TEXT NOT NULL REFERENCES ticket_purchases(id),
|
||||
user_id TEXT REFERENCES users(id),
|
||||
lightning_address TEXT NOT NULL,
|
||||
serial_number INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payouts (
|
||||
id TEXT PRIMARY KEY,
|
||||
lottery_id TEXT NOT NULL REFERENCES lotteries(id),
|
||||
cycle_id TEXT NOT NULL REFERENCES jackpot_cycles(id),
|
||||
ticket_id TEXT NOT NULL REFERENCES tickets(id),
|
||||
user_id TEXT REFERENCES users(id),
|
||||
lightning_address TEXT NOT NULL,
|
||||
amount_sats INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'failed')),
|
||||
lnbits_payment_id TEXT,
|
||||
error_message TEXT,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS draw_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
cycle_id TEXT NOT NULL REFERENCES jackpot_cycles(id),
|
||||
number_of_tickets INTEGER NOT NULL,
|
||||
pot_total_sats INTEGER NOT NULL,
|
||||
fee_percent INTEGER NOT NULL,
|
||||
pot_after_fee_sats INTEGER NOT NULL,
|
||||
winner_ticket_id TEXT,
|
||||
winner_lightning_address TEXT,
|
||||
rng_source TEXT NOT NULL,
|
||||
selected_index INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_cycles_status_time ON jackpot_cycles(status, scheduled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticketpurchase_paymenthash ON ticket_purchases(lnbits_payment_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticketpurchase_cycle ON ticket_purchases(cycle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_cycle ON tickets(cycle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_purchase ON tickets(ticket_purchase_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payouts_ticket ON payouts(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payouts_status ON payouts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_pubkey ON users(nostr_pubkey);
|
||||
|
||||
-- Insert default lottery if not exists
|
||||
INSERT OR IGNORE INTO lotteries (id, name, status, ticket_price_sats, fee_percent)
|
||||
VALUES ('default-lottery-id', 'Main Lightning Jackpot', 'active', 1000, 5);
|
||||
`;
|
||||
|
||||
this.sqliteDb.exec(schema);
|
||||
this.ensureSqliteColumn('ticket_purchases', 'buyer_name', "TEXT NOT NULL DEFAULT 'Anon'");
|
||||
}
|
||||
|
||||
async query<T extends QueryResultRow = any>(text: string, params?: any[]): Promise<QueryResult<T>> {
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
if (this.dbType === 'postgres' && this.pgPool) {
|
||||
const res = await this.pgPool.query<T>(text, params);
|
||||
const duration = Date.now() - start;
|
||||
if (config.app.nodeEnv === 'development') {
|
||||
console.log('Executed query', { text: text.substring(0, 100), duration, rows: res.rowCount });
|
||||
}
|
||||
return res;
|
||||
} else if (this.dbType === 'sqlite' && this.sqliteDb) {
|
||||
// Convert PostgreSQL-style query to SQLite
|
||||
let sqliteQuery = this.convertToSqlite(text, params);
|
||||
|
||||
// Determine if it's a SELECT or modification query
|
||||
const isSelect = text.trim().toUpperCase().startsWith('SELECT');
|
||||
|
||||
let rows: any[] = [];
|
||||
|
||||
if (isSelect) {
|
||||
const stmt = this.sqliteDb.prepare(sqliteQuery.text);
|
||||
rows = stmt.all(...(sqliteQuery.params || []));
|
||||
} else {
|
||||
const stmt = this.sqliteDb.prepare(sqliteQuery.text);
|
||||
const result = stmt.run(...(sqliteQuery.params || []));
|
||||
|
||||
// For INSERT with RETURNING, we need to fetch the inserted row
|
||||
if (text.toUpperCase().includes('RETURNING')) {
|
||||
const match = text.match(/INSERT INTO (\w+)/i);
|
||||
if (match && result.lastInsertRowid) {
|
||||
const tableName = match[1];
|
||||
rows = this.sqliteDb.prepare(`SELECT * FROM ${tableName} WHERE rowid = ?`)
|
||||
.all(result.lastInsertRowid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - start;
|
||||
if (config.app.nodeEnv === 'development') {
|
||||
console.log('Executed SQLite query', { text: sqliteQuery.text.substring(0, 100), duration, rows: rows.length });
|
||||
}
|
||||
|
||||
return {
|
||||
rows: rows as T[],
|
||||
rowCount: rows.length,
|
||||
command: '',
|
||||
oid: 0,
|
||||
fields: [],
|
||||
} as QueryResult<T>;
|
||||
}
|
||||
|
||||
throw new Error('Database not initialized');
|
||||
} catch (error) {
|
||||
console.error('Database query error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private generateUUID(): string {
|
||||
// Simple UUID v4 generator
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
private convertToSqlite(text: string, params?: any[]): { text: string; params?: any[] } {
|
||||
// Convert PostgreSQL $1, $2 placeholders to SQLite ?
|
||||
let sqliteText = text;
|
||||
let sqliteParams = params || [];
|
||||
|
||||
// Replace $n with ?
|
||||
sqliteText = sqliteText.replace(/\$(\d+)/g, '?');
|
||||
|
||||
// Convert PostgreSQL-specific functions
|
||||
sqliteText = sqliteText.replace(/NOW\(\)/gi, "datetime('now')");
|
||||
|
||||
// Remove FOR UPDATE locking hints (SQLite locks entire database for writes)
|
||||
sqliteText = sqliteText.replace(/\s+FOR\s+UPDATE/gi, '');
|
||||
|
||||
// For UUID generation in INSERT statements, generate actual UUIDs
|
||||
const uuidMatches = sqliteText.match(/uuid_generate_v4\(\)/gi);
|
||||
if (uuidMatches) {
|
||||
uuidMatches.forEach(() => {
|
||||
sqliteText = sqliteText.replace(/uuid_generate_v4\(\)/i, `'${this.generateUUID()}'`);
|
||||
});
|
||||
}
|
||||
|
||||
// Convert RETURNING * to just the statement (SQLite doesn't support RETURNING in the same way)
|
||||
if (sqliteText.toUpperCase().includes('RETURNING')) {
|
||||
sqliteText = sqliteText.replace(/RETURNING \*/gi, '');
|
||||
}
|
||||
|
||||
// Handle TIMESTAMPTZ -> TEXT conversion
|
||||
sqliteText = sqliteText.replace(/TIMESTAMPTZ/gi, 'TEXT');
|
||||
|
||||
return { text: sqliteText, params: sqliteParams };
|
||||
}
|
||||
|
||||
private ensureSqliteColumn(table: string, column: string, definition: string): void {
|
||||
if (!this.sqliteDb) return;
|
||||
const columns = this.sqliteDb.prepare(`PRAGMA table_info(${table})`).all();
|
||||
const exists = columns.some((col: any) => col.name === column);
|
||||
if (!exists) {
|
||||
this.sqliteDb.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getClient(): Promise<DbClient> {
|
||||
if (this.dbType === 'postgres' && this.pgPool) {
|
||||
const client = await this.pgPool.connect();
|
||||
return {
|
||||
query: async <T extends QueryResultRow = any>(text: string, params?: any[]) => {
|
||||
return client.query<T>(text, params);
|
||||
},
|
||||
release: () => client.release(),
|
||||
};
|
||||
} else {
|
||||
// For SQLite, return a wrapper that uses the main connection
|
||||
return {
|
||||
query: async <T extends QueryResultRow = any>(text: string, params?: any[]) => {
|
||||
return this.query<T>(text, params);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
if (this.dbType === 'postgres') {
|
||||
await this.query('SELECT 1');
|
||||
} else {
|
||||
await this.query('SELECT 1');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Database health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.pgPool) {
|
||||
await this.pgPool.end();
|
||||
}
|
||||
if (this.sqliteDb) {
|
||||
this.sqliteDb.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new DatabaseWrapper();
|
||||
export default db;
|
||||
131
back_end/src/database/schema.sql
Normal file
131
back_end/src/database/schema.sql
Normal file
@@ -0,0 +1,131 @@
|
||||
-- Lightning Lottery Database Schema
|
||||
-- PostgreSQL 14+
|
||||
|
||||
-- Create UUID extension if not exists
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Lotteries table
|
||||
CREATE TABLE IF NOT EXISTS lotteries (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'finished')),
|
||||
ticket_price_sats BIGINT NOT NULL,
|
||||
fee_percent INTEGER NOT NULL CHECK (fee_percent >= 0 AND fee_percent <= 100),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Jackpot Cycles table
|
||||
CREATE TABLE IF NOT EXISTS jackpot_cycles (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
lottery_id UUID NOT NULL REFERENCES lotteries(id),
|
||||
cycle_type TEXT NOT NULL CHECK (cycle_type IN ('hourly', 'daily', 'weekly', 'monthly')),
|
||||
sequence_number INTEGER NOT NULL,
|
||||
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||
sales_open_at TIMESTAMPTZ NOT NULL,
|
||||
sales_close_at TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('scheduled', 'sales_open', 'drawing', 'completed', 'cancelled')),
|
||||
pot_total_sats BIGINT NOT NULL DEFAULT 0,
|
||||
pot_after_fee_sats BIGINT,
|
||||
winning_ticket_id UUID,
|
||||
winning_lightning_address TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Users table (optional for Nostr)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
nostr_pubkey TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT,
|
||||
lightning_address TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Ticket Purchases table
|
||||
CREATE TABLE IF NOT EXISTS ticket_purchases (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
lottery_id UUID NOT NULL REFERENCES lotteries(id),
|
||||
cycle_id UUID NOT NULL REFERENCES jackpot_cycles(id),
|
||||
user_id UUID REFERENCES users(id),
|
||||
nostr_pubkey TEXT,
|
||||
lightning_address TEXT NOT NULL,
|
||||
buyer_name TEXT NOT NULL DEFAULT 'Anon',
|
||||
number_of_tickets INTEGER NOT NULL,
|
||||
ticket_price_sats BIGINT NOT NULL,
|
||||
amount_sats BIGINT NOT NULL,
|
||||
lnbits_invoice_id TEXT NOT NULL,
|
||||
lnbits_payment_hash TEXT NOT NULL,
|
||||
invoice_status TEXT NOT NULL CHECK (invoice_status IN ('pending', 'paid', 'expired', 'cancelled')),
|
||||
ticket_issue_status TEXT NOT NULL CHECK (ticket_issue_status IN ('not_issued', 'issued')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Tickets table
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
lottery_id UUID NOT NULL REFERENCES lotteries(id),
|
||||
cycle_id UUID NOT NULL REFERENCES jackpot_cycles(id),
|
||||
ticket_purchase_id UUID NOT NULL REFERENCES ticket_purchases(id),
|
||||
user_id UUID REFERENCES users(id),
|
||||
lightning_address TEXT NOT NULL,
|
||||
serial_number BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Payouts table
|
||||
CREATE TABLE IF NOT EXISTS payouts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
lottery_id UUID NOT NULL REFERENCES lotteries(id),
|
||||
cycle_id UUID NOT NULL REFERENCES jackpot_cycles(id),
|
||||
ticket_id UUID NOT NULL REFERENCES tickets(id),
|
||||
user_id UUID REFERENCES users(id),
|
||||
lightning_address TEXT NOT NULL,
|
||||
amount_sats BIGINT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'failed')),
|
||||
lnbits_payment_id TEXT,
|
||||
error_message TEXT,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Draw logs table (for audit and transparency)
|
||||
CREATE TABLE IF NOT EXISTS draw_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
cycle_id UUID NOT NULL REFERENCES jackpot_cycles(id),
|
||||
number_of_tickets INTEGER NOT NULL,
|
||||
pot_total_sats BIGINT NOT NULL,
|
||||
fee_percent INTEGER NOT NULL,
|
||||
pot_after_fee_sats BIGINT NOT NULL,
|
||||
winner_ticket_id UUID,
|
||||
winner_lightning_address TEXT,
|
||||
rng_source TEXT NOT NULL,
|
||||
selected_index INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_cycles_status_time ON jackpot_cycles(status, scheduled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticketpurchase_paymenthash ON ticket_purchases(lnbits_payment_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticketpurchase_cycle ON ticket_purchases(cycle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_cycle ON tickets(cycle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_purchase ON tickets(ticket_purchase_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payouts_ticket ON payouts(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payouts_status ON payouts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_pubkey ON users(nostr_pubkey);
|
||||
|
||||
-- Add foreign key constraint for winning ticket (after tickets table exists)
|
||||
ALTER TABLE jackpot_cycles ADD CONSTRAINT fk_winning_ticket
|
||||
FOREIGN KEY (winning_ticket_id) REFERENCES tickets(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ticket_purchases
|
||||
ADD COLUMN IF NOT EXISTS buyer_name TEXT NOT NULL DEFAULT 'Anon';
|
||||
|
||||
-- Insert default lottery
|
||||
INSERT INTO lotteries (name, status, ticket_price_sats, fee_percent)
|
||||
VALUES ('Main Lightning Jackpot', 'active', 1000, 5)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
63
back_end/src/index.ts
Normal file
63
back_end/src/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import app from './app';
|
||||
import config from './config';
|
||||
import { db } from './database';
|
||||
import { startSchedulers } from './scheduler';
|
||||
import { paymentMonitor } from './services/paymentMonitor';
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
// Test database connection
|
||||
console.log('Testing database connection...');
|
||||
const dbHealthy = await db.healthCheck();
|
||||
|
||||
if (!dbHealthy) {
|
||||
console.error('❌ Database connection failed');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('✓ Database connected');
|
||||
|
||||
// Start HTTP server
|
||||
app.listen(config.app.port, () => {
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════╗
|
||||
║ Lightning Lottery API Server ║
|
||||
║ ║
|
||||
║ Port: ${config.app.port} ║
|
||||
║ Environment: ${config.app.nodeEnv} ║
|
||||
║ Base URL: ${config.app.baseUrl} ║
|
||||
╚═══════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
|
||||
// Start schedulers
|
||||
startSchedulers();
|
||||
await paymentMonitor.start();
|
||||
|
||||
console.log('✓ Schedulers started');
|
||||
console.log('\n🚀 Lightning Lottery API is ready!\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('SIGTERM received, shutting down gracefully...');
|
||||
paymentMonitor.stop();
|
||||
await db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('SIGINT received, shutting down gracefully...');
|
||||
paymentMonitor.stop();
|
||||
await db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start the server
|
||||
start();
|
||||
|
||||
86
back_end/src/middleware/auth.ts
Normal file
86
back_end/src/middleware/auth.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import config from '../config';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: {
|
||||
id: string;
|
||||
nostr_pubkey: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to verify JWT token
|
||||
*/
|
||||
export const verifyToken = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
version: '1.0',
|
||||
error: 'UNAUTHORIZED',
|
||||
message: 'Missing or invalid authorization header',
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as {
|
||||
id: string;
|
||||
nostr_pubkey: string;
|
||||
};
|
||||
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({
|
||||
version: '1.0',
|
||||
error: 'INVALID_TOKEN',
|
||||
message: 'Invalid or expired token',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to verify admin API key
|
||||
*/
|
||||
export const verifyAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||
const adminKey = req.headers['x-admin-key'];
|
||||
|
||||
if (!adminKey || adminKey !== config.admin.apiKey) {
|
||||
return res.status(403).json({
|
||||
version: '1.0',
|
||||
error: 'FORBIDDEN',
|
||||
message: 'Invalid admin API key',
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional auth - doesn't fail if no token
|
||||
*/
|
||||
export const optionalAuth = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as {
|
||||
id: string;
|
||||
nostr_pubkey: string;
|
||||
};
|
||||
req.user = decoded;
|
||||
} catch (error) {
|
||||
// Token invalid but we don't fail
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
61
back_end/src/middleware/rateLimit.ts
Normal file
61
back_end/src/middleware/rateLimit.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
/**
|
||||
* Rate limiter for buy endpoint
|
||||
* Max 10 calls per IP per minute
|
||||
*/
|
||||
export const buyRateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 10,
|
||||
message: {
|
||||
version: '1.0',
|
||||
error: 'RATE_LIMIT',
|
||||
message: 'Too many purchase requests, please try again later',
|
||||
retry_after: 60,
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
// Skip failed requests - don't count them against the limit
|
||||
skipFailedRequests: true,
|
||||
// Use IP from request, ignore X-Forwarded-For in development
|
||||
validate: { xForwardedForHeader: false },
|
||||
});
|
||||
|
||||
/**
|
||||
* Rate limiter for ticket status endpoint
|
||||
* Max 60 calls per minute
|
||||
*/
|
||||
export const ticketStatusRateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 60,
|
||||
message: {
|
||||
version: '1.0',
|
||||
error: 'RATE_LIMIT',
|
||||
message: 'Too many status requests, please try again later',
|
||||
retry_after: 60,
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipFailedRequests: true,
|
||||
validate: { xForwardedForHeader: false },
|
||||
});
|
||||
|
||||
/**
|
||||
* General rate limiter
|
||||
* Max 100 requests per minute
|
||||
*/
|
||||
export const generalRateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 100,
|
||||
message: {
|
||||
version: '1.0',
|
||||
error: 'RATE_LIMIT',
|
||||
message: 'Too many requests, please try again later',
|
||||
retry_after: 60,
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipFailedRequests: true,
|
||||
validate: { xForwardedForHeader: false },
|
||||
});
|
||||
|
||||
130
back_end/src/routes/admin.ts
Normal file
130
back_end/src/routes/admin.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
listCycles,
|
||||
runDrawManually,
|
||||
retryPayout,
|
||||
listPayouts
|
||||
} from '../controllers/admin';
|
||||
import { verifyAdmin } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All admin routes require admin key
|
||||
router.use(verifyAdmin);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/cycles:
|
||||
* get:
|
||||
* summary: List all jackpot cycles
|
||||
* tags: [Admin]
|
||||
* security:
|
||||
* - adminKey: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Filter by status
|
||||
* - in: query
|
||||
* name: cycle_type
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Filter by cycle type
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 50
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 0
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of cycles
|
||||
* 403:
|
||||
* description: Invalid admin key
|
||||
*/
|
||||
router.get('/cycles', listCycles);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/cycles/{id}/run-draw:
|
||||
* post:
|
||||
* summary: Manually trigger a draw for a cycle
|
||||
* tags: [Admin]
|
||||
* security:
|
||||
* - adminKey: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Draw executed successfully
|
||||
* 400:
|
||||
* description: Draw failed or invalid cycle
|
||||
* 403:
|
||||
* description: Invalid admin key
|
||||
*/
|
||||
router.post('/cycles/:id/run-draw', runDrawManually);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/payouts:
|
||||
* get:
|
||||
* summary: List all payouts
|
||||
* tags: [Admin]
|
||||
* security:
|
||||
* - adminKey: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: status
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Filter by status
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 50
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of payouts
|
||||
* 403:
|
||||
* description: Invalid admin key
|
||||
*/
|
||||
router.get('/payouts', listPayouts);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin/payouts/{id}/retry:
|
||||
* post:
|
||||
* summary: Retry a failed payout
|
||||
* tags: [Admin]
|
||||
* security:
|
||||
* - adminKey: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Payout retry successful
|
||||
* 400:
|
||||
* description: Retry failed or invalid payout
|
||||
* 403:
|
||||
* description: Invalid admin key
|
||||
*/
|
||||
router.post('/payouts/:id/retry', retryPayout);
|
||||
|
||||
export default router;
|
||||
|
||||
191
back_end/src/routes/public.ts
Normal file
191
back_end/src/routes/public.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
getNextJackpot,
|
||||
buyTickets,
|
||||
getTicketStatus,
|
||||
getPastWins
|
||||
} from '../controllers/public';
|
||||
import { buyRateLimiter, ticketStatusRateLimiter } from '../middleware/rateLimit';
|
||||
import { optionalAuth } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /jackpot/next:
|
||||
* get:
|
||||
* summary: Get next upcoming jackpot cycle
|
||||
* tags: [Public]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Next jackpot cycle information
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* version:
|
||||
* type: string
|
||||
* example: "1.0"
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* lottery:
|
||||
* $ref: '#/components/schemas/Lottery'
|
||||
* cycle:
|
||||
* $ref: '#/components/schemas/JackpotCycle'
|
||||
* 503:
|
||||
* description: No active lottery or cycle available
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/jackpot/next', getNextJackpot);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /jackpot/buy:
|
||||
* post:
|
||||
* summary: Purchase lottery tickets
|
||||
* tags: [Public]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - tickets
|
||||
* - lightning_address
|
||||
* properties:
|
||||
* tickets:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 100
|
||||
* description: Number of tickets to purchase
|
||||
* example: 5
|
||||
* lightning_address:
|
||||
* type: string
|
||||
* description: Lightning Address for receiving payouts
|
||||
* example: "user@getalby.com"
|
||||
* nostr_pubkey:
|
||||
* type: string
|
||||
* description: Optional Nostr public key
|
||||
* example: "npub1..."
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Invoice created successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* version:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* ticket_purchase_id:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* public_url:
|
||||
* type: string
|
||||
* invoice:
|
||||
* type: object
|
||||
* properties:
|
||||
* payment_request:
|
||||
* type: string
|
||||
* description: BOLT11 invoice
|
||||
* amount_sats:
|
||||
* type: integer
|
||||
* 400:
|
||||
* description: Invalid input
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.post('/jackpot/buy', buyRateLimiter, optionalAuth, buyTickets);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /jackpot/past-wins:
|
||||
* get:
|
||||
* summary: List recent jackpot winners
|
||||
* tags: [Public]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 20
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 0
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of completed jackpots and their winners
|
||||
* 500:
|
||||
* description: Failed to load past wins
|
||||
*/
|
||||
router.get('/jackpot/past-wins', getPastWins);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tickets/{id}:
|
||||
* get:
|
||||
* summary: Get ticket purchase status
|
||||
* tags: [Public]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* format: uuid
|
||||
* description: Ticket purchase ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Ticket status retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* version:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* purchase:
|
||||
* $ref: '#/components/schemas/TicketPurchase'
|
||||
* tickets:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Ticket'
|
||||
* cycle:
|
||||
* $ref: '#/components/schemas/JackpotCycle'
|
||||
* result:
|
||||
* type: object
|
||||
* properties:
|
||||
* has_drawn:
|
||||
* type: boolean
|
||||
* is_winner:
|
||||
* type: boolean
|
||||
* payout:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* 404:
|
||||
* description: Ticket purchase not found
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/tickets/:id', ticketStatusRateLimiter, getTicketStatus);
|
||||
|
||||
export default router;
|
||||
|
||||
152
back_end/src/routes/user.ts
Normal file
152
back_end/src/routes/user.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
nostrAuth,
|
||||
getProfile,
|
||||
updateLightningAddress,
|
||||
getUserTickets,
|
||||
getUserWins
|
||||
} from '../controllers/user';
|
||||
import { verifyToken } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/nostr:
|
||||
* post:
|
||||
* summary: Authenticate with Nostr
|
||||
* tags: [User]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - nostr_pubkey
|
||||
* - signed_message
|
||||
* - nonce
|
||||
* properties:
|
||||
* nostr_pubkey:
|
||||
* type: string
|
||||
* description: Nostr public key (hex or npub)
|
||||
* signed_message:
|
||||
* type: string
|
||||
* description: Signature of the nonce
|
||||
* nonce:
|
||||
* type: string
|
||||
* description: Random nonce for signature verification
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Authentication successful
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* version:
|
||||
* type: string
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* token:
|
||||
* type: string
|
||||
* description: JWT token
|
||||
* user:
|
||||
* type: object
|
||||
* 400:
|
||||
* description: Invalid public key or signature
|
||||
*/
|
||||
router.post('/auth/nostr', nostrAuth);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /me:
|
||||
* get:
|
||||
* summary: Get user profile
|
||||
* tags: [User]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: User profile and statistics
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.get('/me', verifyToken, getProfile);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /me/lightning-address:
|
||||
* patch:
|
||||
* summary: Update user's Lightning Address
|
||||
* tags: [User]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - lightning_address
|
||||
* properties:
|
||||
* lightning_address:
|
||||
* type: string
|
||||
* example: "user@getalby.com"
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Lightning Address updated
|
||||
* 400:
|
||||
* description: Invalid Lightning Address
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.patch('/me/lightning-address', verifyToken, updateLightningAddress);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /me/tickets:
|
||||
* get:
|
||||
* summary: Get user's ticket purchases
|
||||
* tags: [User]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 50
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 0
|
||||
* responses:
|
||||
* 200:
|
||||
* description: User's ticket purchase history
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.get('/me/tickets', verifyToken, getUserTickets);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /me/wins:
|
||||
* get:
|
||||
* summary: Get user's wins and payouts
|
||||
* tags: [User]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: User's wins
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
router.get('/me/wins', verifyToken, getUserWins);
|
||||
|
||||
export default router;
|
||||
|
||||
44
back_end/src/routes/webhooks.ts
Normal file
44
back_end/src/routes/webhooks.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Router } from 'express';
|
||||
import { handleLNbitsPayment } from '../controllers/webhooks';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /webhooks/lnbits/payment:
|
||||
* post:
|
||||
* summary: LNbits payment webhook callback
|
||||
* description: LNbits calls this endpoint when a Lightning invoice is paid.
|
||||
* tags: [Webhooks]
|
||||
* parameters:
|
||||
* - in: header
|
||||
* name: X-Webhook-Secret
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Shared secret configured in LNbits (or supply `secret` query param)
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* payment_hash:
|
||||
* type: string
|
||||
* amount:
|
||||
* type: number
|
||||
* description: Amount paid in sats
|
||||
* paid:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Webhook processed
|
||||
* 403:
|
||||
* description: Invalid secret
|
||||
* 500:
|
||||
* description: Internal error processing the webhook
|
||||
*/
|
||||
router.post('/lnbits/payment', handleLNbitsPayment);
|
||||
|
||||
export default router;
|
||||
|
||||
199
back_end/src/scheduler/index.ts
Normal file
199
back_end/src/scheduler/index.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import cron from 'node-cron';
|
||||
import { db } from '../database';
|
||||
import { executeDraw } from '../services/draw';
|
||||
import { autoRetryFailedPayouts } from '../services/payout';
|
||||
import config from '../config';
|
||||
import { JackpotCycle } from '../types';
|
||||
|
||||
/**
|
||||
* Generate future cycles for all cycle types
|
||||
*/
|
||||
async function generateFutureCycles(): Promise<void> {
|
||||
try {
|
||||
console.log('Running cycle generator...');
|
||||
|
||||
// Get active lottery
|
||||
const lotteryResult = await db.query(
|
||||
`SELECT * FROM lotteries WHERE status = 'active' LIMIT 1`
|
||||
);
|
||||
|
||||
if (lotteryResult.rows.length === 0) {
|
||||
console.log('No active lottery found');
|
||||
return;
|
||||
}
|
||||
|
||||
const lottery = lotteryResult.rows[0];
|
||||
const cycleTypes: Array<'hourly' | 'daily' | 'weekly' | 'monthly'> = ['hourly', 'daily'];
|
||||
|
||||
for (const cycleType of cycleTypes) {
|
||||
await generateCyclesForType(lottery.id, cycleType);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Cycle generation error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cycles for a specific type
|
||||
*/
|
||||
async function generateCyclesForType(
|
||||
lotteryId: string,
|
||||
cycleType: 'hourly' | 'daily' | 'weekly' | 'monthly'
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Determine horizon (how far in the future to generate)
|
||||
const horizonHours = cycleType === 'hourly' ? 48 : 168; // 48h for hourly, 1 week for daily
|
||||
const horizonDate = new Date(Date.now() + horizonHours * 60 * 60 * 1000);
|
||||
|
||||
// Get latest cycle for this type
|
||||
const latestResult = await db.query<JackpotCycle>(
|
||||
`SELECT * FROM jackpot_cycles
|
||||
WHERE lottery_id = $1 AND cycle_type = $2
|
||||
ORDER BY sequence_number DESC
|
||||
LIMIT 1`,
|
||||
[lotteryId, cycleType]
|
||||
);
|
||||
|
||||
let lastScheduledAt: Date;
|
||||
let sequenceNumber: number;
|
||||
|
||||
if (latestResult.rows.length === 0) {
|
||||
// No cycles exist, start from now
|
||||
lastScheduledAt = new Date();
|
||||
sequenceNumber = 0;
|
||||
} else {
|
||||
const latest = latestResult.rows[0];
|
||||
lastScheduledAt = new Date(latest.scheduled_at);
|
||||
sequenceNumber = latest.sequence_number;
|
||||
}
|
||||
|
||||
// Generate cycles until horizon
|
||||
while (lastScheduledAt < horizonDate) {
|
||||
sequenceNumber++;
|
||||
|
||||
// Calculate next scheduled time
|
||||
let nextScheduledAt: Date;
|
||||
|
||||
switch (cycleType) {
|
||||
case 'hourly':
|
||||
nextScheduledAt = new Date(lastScheduledAt.getTime() + 60 * 60 * 1000);
|
||||
break;
|
||||
case 'daily':
|
||||
nextScheduledAt = new Date(lastScheduledAt.getTime() + 24 * 60 * 60 * 1000);
|
||||
nextScheduledAt.setHours(20, 0, 0, 0); // 8 PM UTC
|
||||
break;
|
||||
case 'weekly':
|
||||
nextScheduledAt = new Date(lastScheduledAt.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'monthly':
|
||||
nextScheduledAt = new Date(lastScheduledAt);
|
||||
nextScheduledAt.setMonth(nextScheduledAt.getMonth() + 1);
|
||||
break;
|
||||
}
|
||||
|
||||
// Sales open immediately, close at draw time
|
||||
const salesOpenAt = new Date();
|
||||
const salesCloseAt = nextScheduledAt;
|
||||
|
||||
// Check if cycle already exists
|
||||
const existingResult = await db.query(
|
||||
`SELECT id FROM jackpot_cycles
|
||||
WHERE lottery_id = $1 AND cycle_type = $2 AND sequence_number = $3`,
|
||||
[lotteryId, cycleType, sequenceNumber]
|
||||
);
|
||||
|
||||
if (existingResult.rows.length === 0) {
|
||||
// Create new cycle with explicit UUID generation
|
||||
const crypto = require('crypto');
|
||||
const cycleId = crypto.randomUUID();
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO jackpot_cycles (
|
||||
id, lottery_id, cycle_type, sequence_number, scheduled_at,
|
||||
sales_open_at, sales_close_at, status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
cycleId,
|
||||
lotteryId,
|
||||
cycleType,
|
||||
sequenceNumber,
|
||||
nextScheduledAt.toISOString(),
|
||||
salesOpenAt.toISOString(),
|
||||
salesCloseAt.toISOString(),
|
||||
'scheduled',
|
||||
]
|
||||
);
|
||||
|
||||
console.log(`Created ${cycleType} cycle #${sequenceNumber} (${cycleId}) for ${nextScheduledAt.toISOString()}`);
|
||||
}
|
||||
|
||||
lastScheduledAt = nextScheduledAt;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error generating ${cycleType} cycles:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for cycles that need to be drawn
|
||||
*/
|
||||
async function checkAndExecuteDraws(): Promise<void> {
|
||||
try {
|
||||
console.log('Checking for cycles to draw...');
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Find cycles that are ready to draw
|
||||
const result = await db.query<JackpotCycle>(
|
||||
`SELECT * FROM jackpot_cycles
|
||||
WHERE status IN ('scheduled', 'sales_open')
|
||||
AND scheduled_at <= $1
|
||||
ORDER BY scheduled_at ASC
|
||||
LIMIT 10`,
|
||||
[now.toISOString()]
|
||||
);
|
||||
|
||||
console.log(`Found ${result.rows.length} cycles ready for draw`);
|
||||
|
||||
for (const cycle of result.rows) {
|
||||
console.log(`Executing draw for cycle ${cycle.id} (${cycle.cycle_type})`);
|
||||
await executeDraw(cycle.id);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Draw execution scheduler error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start all schedulers
|
||||
*/
|
||||
export function startSchedulers(): void {
|
||||
console.log('Starting schedulers...');
|
||||
|
||||
// Cycle generator - every 5 minutes (or configured interval)
|
||||
const cycleGenInterval = Math.max(config.scheduler.cycleGeneratorIntervalSeconds, 60);
|
||||
cron.schedule(`*/${Math.floor(cycleGenInterval / 60)} * * * *`, generateFutureCycles);
|
||||
|
||||
console.log(`✓ Cycle generator scheduled (every ${cycleGenInterval}s)`);
|
||||
|
||||
// Draw executor - every minute (or configured interval)
|
||||
const drawInterval = Math.max(config.scheduler.drawIntervalSeconds, 30);
|
||||
cron.schedule(`*/${Math.floor(drawInterval / 60)} * * * *`, checkAndExecuteDraws);
|
||||
|
||||
console.log(`✓ Draw executor scheduled (every ${drawInterval}s)`);
|
||||
|
||||
// Payout retry - every 10 minutes
|
||||
cron.schedule('*/10 * * * *', autoRetryFailedPayouts);
|
||||
|
||||
console.log(`✓ Payout retry scheduled (every 10 minutes)`);
|
||||
|
||||
// Run immediately on startup
|
||||
setTimeout(() => {
|
||||
generateFutureCycles();
|
||||
checkAndExecuteDraws();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
443
back_end/src/services/draw.ts
Normal file
443
back_end/src/services/draw.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import crypto from 'crypto';
|
||||
import { db } from '../database';
|
||||
import { lnbitsService } from './lnbits';
|
||||
import { JackpotCycle, Ticket, Payout } from '../types';
|
||||
import config from '../config';
|
||||
|
||||
interface DrawResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
message?: string;
|
||||
winningTicketId?: string;
|
||||
potAfterFeeSats?: number;
|
||||
payoutStatus?: string;
|
||||
}
|
||||
|
||||
interface PayoutAttemptResult {
|
||||
status: 'paid' | 'failed';
|
||||
retryCount?: number;
|
||||
}
|
||||
|
||||
const RANDOM_INDEX_BYTES = 8;
|
||||
|
||||
function pickRandomIndex(length: number): number {
|
||||
if (length <= 0) {
|
||||
throw new Error('Cannot pick random index from empty list');
|
||||
}
|
||||
const randomBytes = crypto.randomBytes(RANDOM_INDEX_BYTES);
|
||||
const randomValue = BigInt('0x' + randomBytes.toString('hex'));
|
||||
return Number(randomValue % BigInt(length));
|
||||
}
|
||||
|
||||
async function attemptImmediatePayout(
|
||||
client: { query: typeof db.query },
|
||||
payoutId: string,
|
||||
lightningAddress: string,
|
||||
amountSats: number
|
||||
): Promise<PayoutAttemptResult> {
|
||||
try {
|
||||
const paymentResult = await lnbitsService.payLightningAddress(
|
||||
lightningAddress,
|
||||
amountSats
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`UPDATE payouts
|
||||
SET status = 'paid', lnbits_payment_id = $1, updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[paymentResult.payment_hash, payoutId]
|
||||
);
|
||||
|
||||
console.log('Payout successful:', {
|
||||
payout_id: payoutId,
|
||||
amount_sats: amountSats,
|
||||
lightning_address: lightningAddress,
|
||||
});
|
||||
|
||||
return { status: 'paid' };
|
||||
} catch (error: any) {
|
||||
const updateResult = await client.query(
|
||||
`UPDATE payouts
|
||||
SET status = 'failed',
|
||||
error_message = $1,
|
||||
retry_count = retry_count + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING retry_count`,
|
||||
[error.message, payoutId]
|
||||
);
|
||||
|
||||
const retryCount = updateResult.rows.length > 0
|
||||
? parseInt(updateResult.rows[0].retry_count.toString())
|
||||
: undefined;
|
||||
|
||||
console.error('Payout failed:', {
|
||||
payout_id: payoutId,
|
||||
error: error.message,
|
||||
retry_count: retryCount,
|
||||
});
|
||||
|
||||
return { status: 'failed', retryCount };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute draw for a specific cycle
|
||||
*/
|
||||
export async function executeDraw(cycleId: string): Promise<DrawResult> {
|
||||
const client = await db.getClient();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Lock the cycle row for update
|
||||
const cycleResult = await client.query<JackpotCycle>(
|
||||
`SELECT * FROM jackpot_cycles WHERE id = $1 FOR UPDATE`,
|
||||
[cycleId]
|
||||
);
|
||||
|
||||
if (cycleResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return {
|
||||
success: false,
|
||||
error: 'CYCLE_NOT_FOUND',
|
||||
message: 'Cycle not found',
|
||||
};
|
||||
}
|
||||
|
||||
const cycle = cycleResult.rows[0];
|
||||
|
||||
// Check if cycle can be drawn
|
||||
if (cycle.status === 'completed') {
|
||||
await client.query('ROLLBACK');
|
||||
return {
|
||||
success: false,
|
||||
error: 'ALREADY_COMPLETED',
|
||||
message: 'Draw already completed for this cycle',
|
||||
};
|
||||
}
|
||||
|
||||
if (cycle.status === 'cancelled') {
|
||||
await client.query('ROLLBACK');
|
||||
return {
|
||||
success: false,
|
||||
error: 'CYCLE_CANCELLED',
|
||||
message: 'Cannot draw cancelled cycle',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's time to draw
|
||||
const now = new Date();
|
||||
if (now < cycle.scheduled_at) {
|
||||
await client.query('ROLLBACK');
|
||||
return {
|
||||
success: false,
|
||||
error: 'TOO_EARLY',
|
||||
message: 'Draw time has not arrived yet',
|
||||
};
|
||||
}
|
||||
|
||||
// Update status to drawing
|
||||
await client.query(
|
||||
`UPDATE jackpot_cycles SET status = 'drawing', updated_at = NOW() WHERE id = $1`,
|
||||
[cycleId]
|
||||
);
|
||||
|
||||
// Get all tickets for this cycle
|
||||
const ticketsResult = await client.query<Ticket>(
|
||||
`SELECT * FROM tickets WHERE cycle_id = $1 ORDER BY serial_number`,
|
||||
[cycleId]
|
||||
);
|
||||
|
||||
const tickets = ticketsResult.rows;
|
||||
|
||||
// Handle case with no tickets
|
||||
if (tickets.length === 0) {
|
||||
await client.query(
|
||||
`UPDATE jackpot_cycles
|
||||
SET status = 'completed', pot_after_fee_sats = 0, updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[cycleId]
|
||||
);
|
||||
|
||||
// Log draw
|
||||
const emptyDrawLogId = crypto.randomUUID();
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO draw_logs (
|
||||
id, cycle_id, number_of_tickets, pot_total_sats, fee_percent,
|
||||
pot_after_fee_sats, rng_source
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[emptyDrawLogId, cycleId, 0, 0, 0, 0, 'crypto.randomInt']
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
console.log('Draw completed with no tickets:', { cycle_id: cycleId });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Draw completed with no tickets',
|
||||
potAfterFeeSats: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Get lottery for fee percent
|
||||
const lotteryResult = await client.query(
|
||||
`SELECT * FROM lotteries WHERE id = $1`,
|
||||
[cycle.lottery_id]
|
||||
);
|
||||
const lottery = lotteryResult.rows[0];
|
||||
|
||||
// Calculate pot after fee
|
||||
const potTotalSats = parseInt(cycle.pot_total_sats.toString());
|
||||
const feePercent = lottery.fee_percent;
|
||||
const potAfterFeeSats = Math.floor(potTotalSats * (100 - feePercent) / 100);
|
||||
|
||||
// Select random winning ticket via cryptographic randomness
|
||||
const randomIndex = pickRandomIndex(tickets.length);
|
||||
const winningTicket = tickets[randomIndex];
|
||||
|
||||
console.log('Draw execution:', {
|
||||
cycle_id: cycleId,
|
||||
total_tickets: tickets.length,
|
||||
pot_total_sats: potTotalSats,
|
||||
pot_after_fee_sats: potAfterFeeSats,
|
||||
random_index: randomIndex,
|
||||
winning_ticket_id: winningTicket.id,
|
||||
winning_serial: winningTicket.serial_number,
|
||||
});
|
||||
|
||||
// Update cycle with winner
|
||||
await client.query(
|
||||
`UPDATE jackpot_cycles
|
||||
SET pot_after_fee_sats = $1,
|
||||
winning_ticket_id = $2,
|
||||
winning_lightning_address = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $4`,
|
||||
[potAfterFeeSats, winningTicket.id, winningTicket.lightning_address, cycleId]
|
||||
);
|
||||
|
||||
// Create payout record
|
||||
const payoutId = crypto.randomUUID();
|
||||
|
||||
const payoutResult = await client.query<Payout>(
|
||||
`INSERT INTO payouts (
|
||||
id, lottery_id, cycle_id, ticket_id, user_id, lightning_address,
|
||||
amount_sats, status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[
|
||||
payoutId,
|
||||
cycle.lottery_id,
|
||||
cycleId,
|
||||
winningTicket.id,
|
||||
winningTicket.user_id,
|
||||
winningTicket.lightning_address,
|
||||
potAfterFeeSats,
|
||||
'pending',
|
||||
]
|
||||
);
|
||||
|
||||
const payout = payoutResult.rows[0];
|
||||
|
||||
// Log draw for audit
|
||||
const drawLogId = crypto.randomUUID();
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO draw_logs (
|
||||
id, cycle_id, number_of_tickets, pot_total_sats, fee_percent,
|
||||
pot_after_fee_sats, winner_ticket_id, winner_lightning_address,
|
||||
rng_source, selected_index
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[
|
||||
drawLogId,
|
||||
cycleId,
|
||||
tickets.length,
|
||||
potTotalSats,
|
||||
feePercent,
|
||||
potAfterFeeSats,
|
||||
winningTicket.id,
|
||||
winningTicket.lightning_address,
|
||||
'crypto.randomInt',
|
||||
randomIndex,
|
||||
]
|
||||
);
|
||||
|
||||
const payoutAttempt = await attemptImmediatePayout(
|
||||
client,
|
||||
payout.id,
|
||||
winningTicket.lightning_address,
|
||||
potAfterFeeSats
|
||||
);
|
||||
const payoutStatus = payoutAttempt.status;
|
||||
|
||||
// Mark cycle as completed
|
||||
await client.query(
|
||||
`UPDATE jackpot_cycles SET status = 'completed', updated_at = NOW() WHERE id = $1`,
|
||||
[cycleId]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
winningTicketId: winningTicket.id,
|
||||
potAfterFeeSats,
|
||||
payoutStatus,
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Draw execution error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'DRAW_EXECUTION_ERROR',
|
||||
message: error.message,
|
||||
};
|
||||
} finally {
|
||||
if (client.release) {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function redrawWinner(cycleId: string): Promise<boolean> {
|
||||
const client = await db.getClient();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const cycleResult = await client.query<JackpotCycle>(
|
||||
`SELECT * FROM jackpot_cycles WHERE id = $1`,
|
||||
[cycleId]
|
||||
);
|
||||
|
||||
if (cycleResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
console.warn('Cannot redraw winner, cycle not found', { cycle_id: cycleId });
|
||||
return false;
|
||||
}
|
||||
|
||||
const cycle = cycleResult.rows[0];
|
||||
|
||||
const candidatesResult = await client.query<Ticket>(
|
||||
`SELECT * FROM tickets
|
||||
WHERE cycle_id = $1
|
||||
AND id NOT IN (
|
||||
SELECT ticket_id FROM payouts WHERE cycle_id = $2
|
||||
)
|
||||
ORDER BY serial_number`,
|
||||
[cycleId, cycleId]
|
||||
);
|
||||
|
||||
if (candidatesResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
console.warn('No eligible tickets remain for redraw', { cycle_id: cycleId });
|
||||
return false;
|
||||
}
|
||||
|
||||
const tickets = candidatesResult.rows;
|
||||
const randomIndex = pickRandomIndex(tickets.length);
|
||||
const winningTicket = tickets[randomIndex];
|
||||
|
||||
let potAfterFeeSats = cycle.pot_after_fee_sats
|
||||
? parseInt(cycle.pot_after_fee_sats.toString())
|
||||
: null;
|
||||
|
||||
const potTotalSats = parseInt(cycle.pot_total_sats.toString());
|
||||
|
||||
if (potAfterFeeSats === null) {
|
||||
const lotteryResult = await client.query(
|
||||
`SELECT fee_percent FROM lotteries WHERE id = $1`,
|
||||
[cycle.lottery_id]
|
||||
);
|
||||
|
||||
const feePercent = lotteryResult.rows.length > 0
|
||||
? lotteryResult.rows[0].fee_percent
|
||||
: config.lottery.defaultHouseFeePercent;
|
||||
|
||||
potAfterFeeSats = Math.floor(potTotalSats * (100 - feePercent) / 100);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE jackpot_cycles
|
||||
SET winning_ticket_id = $1,
|
||||
winning_lightning_address = $2,
|
||||
pot_after_fee_sats = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $4`,
|
||||
[winningTicket.id, winningTicket.lightning_address, potAfterFeeSats, cycleId]
|
||||
);
|
||||
|
||||
const payoutId = crypto.randomUUID();
|
||||
|
||||
const payoutResult = await client.query<Payout>(
|
||||
`INSERT INTO payouts (
|
||||
id, lottery_id, cycle_id, ticket_id, user_id, lightning_address,
|
||||
amount_sats, status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[
|
||||
payoutId,
|
||||
cycle.lottery_id,
|
||||
cycleId,
|
||||
winningTicket.id,
|
||||
winningTicket.user_id,
|
||||
winningTicket.lightning_address,
|
||||
potAfterFeeSats,
|
||||
'pending',
|
||||
]
|
||||
);
|
||||
|
||||
const payout = payoutResult.rows[0];
|
||||
|
||||
const drawLogId = crypto.randomUUID();
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO draw_logs (
|
||||
id, cycle_id, number_of_tickets, pot_total_sats,
|
||||
pot_after_fee_sats, winner_ticket_id, winner_lightning_address,
|
||||
rng_source, selected_index
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
drawLogId,
|
||||
cycleId,
|
||||
tickets.length,
|
||||
potTotalSats,
|
||||
potAfterFeeSats,
|
||||
winningTicket.id,
|
||||
winningTicket.lightning_address,
|
||||
'redraw',
|
||||
randomIndex,
|
||||
]
|
||||
);
|
||||
|
||||
const attempt = await attemptImmediatePayout(
|
||||
client,
|
||||
payout.id,
|
||||
winningTicket.lightning_address,
|
||||
potAfterFeeSats
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
console.log('Winner redrawn for cycle', {
|
||||
cycle_id: cycleId,
|
||||
new_winner_ticket_id: winningTicket.id,
|
||||
payout_status: attempt.status,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Redraw winner error:', error);
|
||||
return false;
|
||||
} finally {
|
||||
if (client.release) {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
156
back_end/src/services/lnbits.ts
Normal file
156
back_end/src/services/lnbits.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import config from '../config';
|
||||
|
||||
interface CreateInvoiceParams {
|
||||
amount: number; // sats
|
||||
memo: string;
|
||||
webhook?: string;
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
payment_hash: string;
|
||||
payment_request: string;
|
||||
checking_id: string;
|
||||
}
|
||||
|
||||
interface PayoutParams {
|
||||
bolt11?: string;
|
||||
description?: string;
|
||||
out?: boolean;
|
||||
}
|
||||
|
||||
interface PaymentResult {
|
||||
payment_hash: string;
|
||||
checking_id: string;
|
||||
paid?: boolean;
|
||||
}
|
||||
|
||||
class LNbitsService {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: config.lnbits.apiBaseUrl,
|
||||
headers: {
|
||||
'X-Api-Key': config.lnbits.adminKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Lightning invoice
|
||||
*/
|
||||
async createInvoice(params: CreateInvoiceParams): Promise<Invoice> {
|
||||
try {
|
||||
const response = await this.client.post('/api/v1/payments', {
|
||||
out: false,
|
||||
amount: params.amount,
|
||||
memo: params.memo,
|
||||
webhook: params.webhook,
|
||||
unit: 'sat',
|
||||
});
|
||||
|
||||
return {
|
||||
payment_hash: response.data.payment_hash,
|
||||
payment_request: response.data.payment_request,
|
||||
checking_id: response.data.checking_id,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('LNbits create invoice error:', error.response?.data || error.message);
|
||||
throw new Error(`Failed to create invoice: ${error.response?.data?.detail || error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay a Lightning Address or BOLT11 invoice
|
||||
*/
|
||||
async payLightningAddress(lightningAddress: string, amountSats: number): Promise<PaymentResult> {
|
||||
try {
|
||||
// First resolve the Lightning Address to get LNURL
|
||||
const [username, domain] = lightningAddress.split('@');
|
||||
if (!username || !domain) {
|
||||
throw new Error('Invalid Lightning Address format');
|
||||
}
|
||||
|
||||
// Fetch LNURL pay metadata
|
||||
const lnurlResponse = await axios.get(
|
||||
`https://${domain}/.well-known/lnurlp/${username}`
|
||||
);
|
||||
|
||||
const { callback, minSendable, maxSendable } = lnurlResponse.data;
|
||||
|
||||
const amountMsats = amountSats * 1000;
|
||||
|
||||
if (amountMsats < minSendable || amountMsats > maxSendable) {
|
||||
throw new Error('Amount out of range for this Lightning Address');
|
||||
}
|
||||
|
||||
// Get invoice from callback
|
||||
const invoiceResponse = await axios.get(callback, {
|
||||
params: { amount: amountMsats },
|
||||
});
|
||||
|
||||
const bolt11 = invoiceResponse.data.pr;
|
||||
|
||||
if (!bolt11) {
|
||||
throw new Error('Failed to get invoice from Lightning Address');
|
||||
}
|
||||
|
||||
// Pay the invoice using LNbits
|
||||
const paymentResponse = await this.client.post('/api/v1/payments', {
|
||||
out: true,
|
||||
bolt11: bolt11,
|
||||
});
|
||||
|
||||
return {
|
||||
payment_hash: paymentResponse.data.payment_hash,
|
||||
checking_id: paymentResponse.data.checking_id,
|
||||
paid: true,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('LNbits payout error:', error.response?.data || error.message);
|
||||
throw new Error(`Failed to pay Lightning Address: ${error.response?.data?.detail || error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check payment status
|
||||
*/
|
||||
async checkPaymentStatus(paymentHash: string): Promise<{ paid: boolean }> {
|
||||
try {
|
||||
const response = await this.client.get(`/api/v1/payments/${paymentHash}`);
|
||||
return {
|
||||
paid: response.data.paid || false,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('LNbits check payment error:', error.response?.data || error.message);
|
||||
throw new Error(`Failed to check payment status: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify webhook signature/secret
|
||||
*/
|
||||
verifyWebhook(secret: string): boolean {
|
||||
return secret === config.lnbits.webhookSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for LNbits connectivity
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.client.get('/api/v1/wallet');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('LNbits health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const lnbitsService = new LNbitsService();
|
||||
export default lnbitsService;
|
||||
|
||||
130
back_end/src/services/paymentMonitor.ts
Normal file
130
back_end/src/services/paymentMonitor.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { db } from '../database';
|
||||
import { lnbitsService } from './lnbits';
|
||||
import { finalizeTicketPurchase } from './paymentProcessor';
|
||||
|
||||
interface PendingInvoice {
|
||||
purchaseId: string;
|
||||
paymentHash: string;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const EXPIRY_MS = 20 * 60 * 1000;
|
||||
|
||||
class PaymentMonitor {
|
||||
private pendingInvoices = new Map<string, PendingInvoice>();
|
||||
private checkInterval: NodeJS.Timeout | null = null;
|
||||
private isChecking = false;
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.checkInterval) {
|
||||
console.log('Payment monitor already running');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.bootstrapPendingInvoices();
|
||||
console.log('✓ Payment monitor started (checking every 3 seconds)');
|
||||
|
||||
// Immediate check, then schedule interval
|
||||
this.checkPendingPayments();
|
||||
this.checkInterval = setInterval(() => {
|
||||
this.checkPendingPayments();
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.checkInterval) {
|
||||
clearInterval(this.checkInterval);
|
||||
this.checkInterval = null;
|
||||
console.log('Payment monitor stopped');
|
||||
}
|
||||
}
|
||||
|
||||
addInvoice(purchaseId: string, paymentHash?: string): void {
|
||||
if (!purchaseId || !paymentHash) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingInvoices.set(purchaseId, {
|
||||
purchaseId,
|
||||
paymentHash,
|
||||
addedAt: Date.now(),
|
||||
});
|
||||
|
||||
console.log(`📋 Monitoring payment: ${purchaseId} (${this.pendingInvoices.size} pending)`);
|
||||
}
|
||||
|
||||
removeInvoice(purchaseId: string): void {
|
||||
this.pendingInvoices.delete(purchaseId);
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
pending: this.pendingInvoices.size,
|
||||
checking: this.isChecking,
|
||||
};
|
||||
}
|
||||
|
||||
private async bootstrapPendingInvoices(): Promise<void> {
|
||||
const pendingResult = await db.query<{ id: string; lnbits_payment_hash: string }>(
|
||||
`SELECT id, lnbits_payment_hash
|
||||
FROM ticket_purchases
|
||||
WHERE invoice_status = 'pending'
|
||||
AND lnbits_payment_hash IS NOT NULL
|
||||
AND lnbits_payment_hash <> ''`
|
||||
);
|
||||
|
||||
pendingResult.rows.forEach(row => {
|
||||
this.pendingInvoices.set(row.id, {
|
||||
purchaseId: row.id,
|
||||
paymentHash: row.lnbits_payment_hash,
|
||||
addedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
if (pendingResult.rows.length > 0) {
|
||||
console.log(`📎 Restored ${pendingResult.rows.length} pending invoice(s) into monitor`);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkPendingPayments(): Promise<void> {
|
||||
if (this.isChecking || this.pendingInvoices.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isChecking = true;
|
||||
|
||||
try {
|
||||
const now = Date.now();
|
||||
const invoices = Array.from(this.pendingInvoices.values());
|
||||
|
||||
for (const invoice of invoices) {
|
||||
if (now - invoice.addedAt > EXPIRY_MS) {
|
||||
console.log(`⏰ Invoice expired: ${invoice.purchaseId}`);
|
||||
this.pendingInvoices.delete(invoice.purchaseId);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await lnbitsService.checkPaymentStatus(invoice.paymentHash);
|
||||
if (status.paid) {
|
||||
console.log(`💰 Payment detected via monitor: ${invoice.purchaseId}`);
|
||||
await finalizeTicketPurchase(invoice.purchaseId);
|
||||
this.pendingInvoices.delete(invoice.purchaseId);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`Error checking payment ${invoice.purchaseId}:`, error?.message || error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Payment monitor error:', error);
|
||||
} finally {
|
||||
this.isChecking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const paymentMonitor = new PaymentMonitor();
|
||||
export default paymentMonitor;
|
||||
|
||||
|
||||
129
back_end/src/services/paymentProcessor.ts
Normal file
129
back_end/src/services/paymentProcessor.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import crypto from 'crypto';
|
||||
import { db } from '../database';
|
||||
import { TicketPurchase } from '../types';
|
||||
|
||||
export type FinalizeStatus = 'success' | 'already_paid' | 'not_found';
|
||||
|
||||
export interface FinalizeResult {
|
||||
status: FinalizeStatus;
|
||||
purchaseId?: string;
|
||||
ticketsIssued?: number;
|
||||
}
|
||||
|
||||
const SERIAL_MIN = 1;
|
||||
const SERIAL_MAX = 1_000_000_000; // 1 billion possible ticket numbers
|
||||
|
||||
function createSerialNumberGenerator(existingSerials: Set<number>) {
|
||||
return () => {
|
||||
for (let attempts = 0; attempts < 2000; attempts++) {
|
||||
const candidate = crypto.randomInt(SERIAL_MIN, SERIAL_MAX);
|
||||
if (!existingSerials.has(candidate)) {
|
||||
existingSerials.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new Error('Unable to generate unique ticket number');
|
||||
};
|
||||
}
|
||||
|
||||
export async function finalizeTicketPurchase(purchaseId: string): Promise<FinalizeResult> {
|
||||
const client = await db.getClient();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const purchaseResult = await client.query<TicketPurchase>(
|
||||
`SELECT * FROM ticket_purchases WHERE id = $1`,
|
||||
[purchaseId]
|
||||
);
|
||||
|
||||
if (purchaseResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
const purchase = purchaseResult.rows[0];
|
||||
|
||||
if (purchase.invoice_status === 'paid') {
|
||||
await client.query('ROLLBACK');
|
||||
return { status: 'already_paid', purchaseId: purchase.id, ticketsIssued: purchase.number_of_tickets };
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE ticket_purchases
|
||||
SET invoice_status = 'paid', updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[purchase.id]
|
||||
);
|
||||
|
||||
let ticketsIssued = 0;
|
||||
|
||||
if (purchase.ticket_issue_status === 'not_issued') {
|
||||
const existingSerialsResult = await client.query(
|
||||
`SELECT serial_number FROM tickets WHERE cycle_id = $1`,
|
||||
[purchase.cycle_id]
|
||||
);
|
||||
|
||||
const usedSerials = new Set<number>(
|
||||
existingSerialsResult.rows.map((row: any) =>
|
||||
parseInt(row.serial_number?.toString() ?? '0', 10)
|
||||
)
|
||||
);
|
||||
|
||||
const generateSerialNumber = createSerialNumberGenerator(usedSerials);
|
||||
|
||||
for (let i = 0; i < purchase.number_of_tickets; i++) {
|
||||
const serialNumber = generateSerialNumber();
|
||||
ticketsIssued++;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO tickets (
|
||||
id, lottery_id, cycle_id, ticket_purchase_id, user_id,
|
||||
lightning_address, serial_number
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
crypto.randomUUID(),
|
||||
purchase.lottery_id,
|
||||
purchase.cycle_id,
|
||||
purchase.id,
|
||||
purchase.user_id,
|
||||
purchase.lightning_address,
|
||||
serialNumber,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE ticket_purchases
|
||||
SET ticket_issue_status = 'issued', updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[purchase.id]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`UPDATE jackpot_cycles
|
||||
SET pot_total_sats = pot_total_sats + $1, updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[purchase.amount_sats, purchase.cycle_id]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
purchaseId: purchase.id,
|
||||
ticketsIssued: ticketsIssued || purchase.number_of_tickets,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
if (client.release) {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
176
back_end/src/services/payout.ts
Normal file
176
back_end/src/services/payout.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { db } from '../database';
|
||||
import { lnbitsService } from './lnbits';
|
||||
import { Payout } from '../types';
|
||||
import config from '../config';
|
||||
import { redrawWinner } from './draw';
|
||||
|
||||
interface PayoutRetryResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
message?: string;
|
||||
status?: string;
|
||||
retryCount?: number;
|
||||
}
|
||||
|
||||
const MAX_RETRY_ATTEMPTS = config.payout.maxAttemptsBeforeRedraw;
|
||||
|
||||
/**
|
||||
* Retry a failed payout
|
||||
*/
|
||||
export async function retryPayoutService(payoutId: string): Promise<PayoutRetryResult> {
|
||||
try {
|
||||
// Get payout
|
||||
const payoutResult = await db.query<Payout>(
|
||||
`SELECT * FROM payouts WHERE id = $1`,
|
||||
[payoutId]
|
||||
);
|
||||
|
||||
if (payoutResult.rows.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'PAYOUT_NOT_FOUND',
|
||||
message: 'Payout not found',
|
||||
};
|
||||
}
|
||||
|
||||
const payout = payoutResult.rows[0];
|
||||
|
||||
// Check if payout is in failed status
|
||||
if (payout.status !== 'failed') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'INVALID_STATUS',
|
||||
message: `Cannot retry payout with status: ${payout.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check retry limit
|
||||
if (payout.retry_count >= MAX_RETRY_ATTEMPTS) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'MAX_RETRIES_EXCEEDED',
|
||||
message: `Maximum retry attempts (${MAX_RETRY_ATTEMPTS}) exceeded`,
|
||||
};
|
||||
}
|
||||
|
||||
const amountSats = parseInt(payout.amount_sats.toString());
|
||||
|
||||
console.log('Retrying payout:', {
|
||||
payout_id: payoutId,
|
||||
attempt: payout.retry_count + 1,
|
||||
amount_sats: amountSats,
|
||||
lightning_address: payout.lightning_address,
|
||||
});
|
||||
|
||||
// Attempt payout
|
||||
try {
|
||||
const paymentResult = await lnbitsService.payLightningAddress(
|
||||
payout.lightning_address,
|
||||
amountSats
|
||||
);
|
||||
|
||||
// Update payout as paid
|
||||
await db.query(
|
||||
`UPDATE payouts
|
||||
SET status = 'paid',
|
||||
lnbits_payment_id = $1,
|
||||
retry_count = retry_count + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[paymentResult.payment_hash, payoutId]
|
||||
);
|
||||
|
||||
console.log('Payout retry successful:', {
|
||||
payout_id: payoutId,
|
||||
payment_hash: paymentResult.payment_hash,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: 'paid',
|
||||
retryCount: payout.retry_count + 1,
|
||||
};
|
||||
|
||||
} catch (payoutError: any) {
|
||||
// Update error message and increment retry count
|
||||
const updateResult = await db.query(
|
||||
`UPDATE payouts
|
||||
SET error_message = $1,
|
||||
retry_count = retry_count + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING retry_count`,
|
||||
[payoutError.message, payoutId]
|
||||
);
|
||||
|
||||
const newRetryCount = updateResult.rows.length > 0
|
||||
? parseInt(updateResult.rows[0].retry_count.toString())
|
||||
: payout.retry_count + 1;
|
||||
|
||||
console.error('Payout retry failed:', {
|
||||
payout_id: payoutId,
|
||||
error: payoutError.message,
|
||||
retry_count: newRetryCount,
|
||||
});
|
||||
|
||||
if (newRetryCount >= MAX_RETRY_ATTEMPTS) {
|
||||
console.warn('Max payout attempts reached. Drawing new winner.', {
|
||||
cycle_id: payout.cycle_id,
|
||||
payout_id: payoutId,
|
||||
});
|
||||
await redrawWinner(payout.cycle_id);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'PAYOUT_FAILED',
|
||||
message: payoutError.message,
|
||||
status: 'failed',
|
||||
retryCount: newRetryCount,
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Retry payout error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically retry all failed payouts that haven't exceeded max retries
|
||||
*/
|
||||
export async function autoRetryFailedPayouts(): Promise<void> {
|
||||
try {
|
||||
const result = await db.query<Payout>(
|
||||
`SELECT * FROM payouts
|
||||
WHERE status = 'failed'
|
||||
AND retry_count < $1
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 10`,
|
||||
[MAX_RETRY_ATTEMPTS]
|
||||
);
|
||||
|
||||
console.log(`Found ${result.rows.length} failed payouts to retry`);
|
||||
|
||||
for (const payout of result.rows) {
|
||||
// Add delay between retries (exponential backoff)
|
||||
const delaySeconds = Math.pow(2, payout.retry_count) * 10;
|
||||
const timeSinceLastUpdate = Date.now() - payout.updated_at.getTime();
|
||||
|
||||
if (timeSinceLastUpdate < delaySeconds * 1000) {
|
||||
console.log(`Skipping payout ${payout.id} - waiting for backoff period`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await retryPayoutService(payout.id);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Auto retry failed payouts error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
108
back_end/src/types/index.ts
Normal file
108
back_end/src/types/index.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
export interface Lottery {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'paused' | 'finished';
|
||||
ticket_price_sats: number;
|
||||
fee_percent: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface JackpotCycle {
|
||||
id: string;
|
||||
lottery_id: string;
|
||||
cycle_type: 'hourly' | 'daily' | 'weekly' | 'monthly';
|
||||
sequence_number: number;
|
||||
scheduled_at: Date;
|
||||
sales_open_at: Date;
|
||||
sales_close_at: Date;
|
||||
status: 'scheduled' | 'sales_open' | 'drawing' | 'completed' | 'cancelled';
|
||||
pot_total_sats: number;
|
||||
pot_after_fee_sats: number | null;
|
||||
winning_ticket_id: string | null;
|
||||
winning_lightning_address: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface TicketPurchase {
|
||||
id: string;
|
||||
lottery_id: string;
|
||||
cycle_id: string;
|
||||
user_id: string | null;
|
||||
nostr_pubkey: string | null;
|
||||
lightning_address: string;
|
||||
buyer_name: string;
|
||||
number_of_tickets: number;
|
||||
ticket_price_sats: number;
|
||||
amount_sats: number;
|
||||
lnbits_invoice_id: string;
|
||||
lnbits_payment_hash: string;
|
||||
invoice_status: 'pending' | 'paid' | 'expired' | 'cancelled';
|
||||
ticket_issue_status: 'not_issued' | 'issued';
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
lottery_id: string;
|
||||
cycle_id: string;
|
||||
ticket_purchase_id: string;
|
||||
user_id: string | null;
|
||||
lightning_address: string;
|
||||
serial_number: number;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface Payout {
|
||||
id: string;
|
||||
lottery_id: string;
|
||||
cycle_id: string;
|
||||
ticket_id: string;
|
||||
user_id: string | null;
|
||||
lightning_address: string;
|
||||
amount_sats: number;
|
||||
status: 'pending' | 'paid' | 'failed';
|
||||
lnbits_payment_id: string | null;
|
||||
error_message: string | null;
|
||||
retry_count: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
nostr_pubkey: string;
|
||||
display_name: string | null;
|
||||
lightning_address: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface DrawLog {
|
||||
id: string;
|
||||
cycle_id: string;
|
||||
number_of_tickets: number;
|
||||
pot_total_sats: number;
|
||||
fee_percent: number;
|
||||
pot_after_fee_sats: number;
|
||||
winner_ticket_id: string | null;
|
||||
winner_lightning_address: string | null;
|
||||
rng_source: string;
|
||||
selected_index: number | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
version: string;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
70
back_end/src/utils/validation.ts
Normal file
70
back_end/src/utils/validation.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Validates a Lightning Address format
|
||||
*/
|
||||
export function validateLightningAddress(address: string): boolean {
|
||||
if (!address || typeof address !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must contain @ symbol
|
||||
if (!address.includes('@')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic email-like format check
|
||||
const parts = address.split('@');
|
||||
if (parts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [username, domain] = parts;
|
||||
|
||||
// Username and domain must not be empty
|
||||
if (!username || !domain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Length check
|
||||
if (address.length > 255) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates Nostr public key (hex or npub format)
|
||||
*/
|
||||
export function validateNostrPubkey(pubkey: string): boolean {
|
||||
if (!pubkey || typeof pubkey !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hex format: 64 characters
|
||||
if (/^[0-9a-f]{64}$/i.test(pubkey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// npub format (bech32)
|
||||
if (pubkey.startsWith('npub1') && pubkey.length === 63) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates number of tickets
|
||||
*/
|
||||
export function validateTicketCount(count: number, max: number = 100): boolean {
|
||||
return Number.isInteger(count) && count > 0 && count <= max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes input string
|
||||
*/
|
||||
export function sanitizeString(input: string, maxLength: number = 255): string {
|
||||
if (!input) return '';
|
||||
return input.trim().substring(0, maxLength);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user