Initial commit: Lightning Lottery - Bitcoin Lightning Network powered lottery

Features:
- Lightning Network payments via LNbits integration
- Provably fair draws using CSPRNG
- Random ticket number generation
- Automatic payouts with retry/redraw logic
- Nostr authentication (NIP-07)
- Multiple draw cycles (hourly, daily, weekly, monthly)
- PostgreSQL and SQLite database support
- Real-time countdown and payment animations
- Swagger API documentation
- Docker support

Stack:
- Backend: Node.js, TypeScript, Express
- Frontend: Next.js, React, TailwindCSS, Redux
- Payments: LNbits
This commit is contained in:
Michilis
2025-11-27 22:13:37 +00:00
commit d3bf8080b6
75 changed files with 18184 additions and 0 deletions

100
back_end/src/app.ts Normal file
View File

@@ -0,0 +1,100 @@
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import swaggerUi from 'swagger-ui-express';
import config from './config';
import { swaggerSpec } from './config/swagger';
// Routes
import publicRoutes from './routes/public';
import webhookRoutes from './routes/webhooks';
import userRoutes from './routes/user';
import adminRoutes from './routes/admin';
// Middleware
import { generalRateLimiter } from './middleware/rateLimit';
const app = express();
// Trust proxy for rate limiting (needed when behind reverse proxy)
if (config.app.nodeEnv === 'production') {
app.set('trust proxy', 1); // Trust first proxy
}
// CORS configuration
app.use(cors({
origin: config.app.nodeEnv === 'production'
? config.cors.allowedOrigins
: true,
credentials: true,
}));
// Body parser
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// General rate limiting
app.use(generalRateLimiter);
// Swagger API Documentation
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'Lightning Lottery API Docs',
}));
// Swagger JSON endpoint
app.get('/api-docs.json', (req: Request, res: Response) => {
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
});
// Health check endpoint
app.get('/health', async (req: Request, res: Response) => {
const { db } = await import('./database');
const { lnbitsService } = await import('./services/lnbits');
const dbHealth = await db.healthCheck();
const lnbitsHealth = await lnbitsService.healthCheck();
const healthy = dbHealth && lnbitsHealth;
res.status(healthy ? 200 : 503).json({
version: '1.0',
status: healthy ? 'healthy' : 'unhealthy',
checks: {
database: dbHealth ? 'ok' : 'failed',
lnbits: lnbitsHealth ? 'ok' : 'failed',
},
timestamp: new Date().toISOString(),
});
});
// API routes
app.use('/', publicRoutes);
app.use('/webhooks', webhookRoutes);
app.use('/', userRoutes);
app.use('/admin', adminRoutes);
// 404 handler
app.use((req: Request, res: Response) => {
res.status(404).json({
version: '1.0',
error: 'NOT_FOUND',
message: 'Endpoint not found',
});
});
// Error handler
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error('Unhandled error:', err);
res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: config.app.nodeEnv === 'production'
? 'An internal error occurred'
: err.message,
});
});
export default app;

View File

@@ -0,0 +1,159 @@
import dotenv from 'dotenv';
dotenv.config();
const parseOriginsList = (value?: string): string[] => {
if (!value) {
return [];
}
return value
.split(',')
.map(origin => origin.trim())
.filter(origin => origin.length > 0);
};
const appPort = parseInt(process.env.PORT || process.env.APP_PORT || '3000', 10);
const appBaseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
const nodeEnv = process.env.NODE_ENV || 'development';
const frontendOrigins = (() => {
const candidates = [
process.env.FRONTEND_BASE_URL,
process.env.NEXT_PUBLIC_APP_BASE_URL,
];
for (const candidate of candidates) {
const parsed = parseOriginsList(candidate);
if (parsed.length > 0) {
return parsed;
}
}
return ['http://localhost:3001'];
})();
const allowedCorsOrigins = (() => {
const explicit = parseOriginsList(process.env.CORS_ALLOWED_ORIGINS);
if (explicit.length > 0) {
return explicit;
}
return Array.from(new Set([...frontendOrigins, appBaseUrl]));
})();
interface Config {
app: {
port: number;
baseUrl: string;
nodeEnv: string;
};
frontend: {
origins: string[];
};
cors: {
allowedOrigins: string[];
};
database: {
type: 'postgres' | 'sqlite';
url: string;
};
lnbits: {
apiBaseUrl: string;
adminKey: string;
webhookSecret: string;
};
jwt: {
secret: string;
};
scheduler: {
drawIntervalSeconds: number;
cycleGeneratorIntervalSeconds: number;
};
lottery: {
defaultTicketPriceSats: number;
defaultHouseFeePercent: number;
maxTicketsPerPurchase: number;
};
admin: {
apiKey: string;
};
payout: {
maxAttemptsBeforeRedraw: number;
};
}
const config: Config = {
app: {
port: appPort,
baseUrl: appBaseUrl,
nodeEnv,
},
frontend: {
origins: frontendOrigins,
},
cors: {
allowedOrigins: allowedCorsOrigins,
},
database: {
type: (process.env.DATABASE_TYPE || 'postgres') as 'postgres' | 'sqlite',
url: process.env.DATABASE_URL ||
(process.env.DATABASE_TYPE === 'sqlite'
? './data/lightning_lotto.db'
: 'postgresql://localhost:5432/lightning_lotto'),
},
lnbits: {
apiBaseUrl: process.env.LNBITS_API_BASE_URL || '',
adminKey: process.env.LNBITS_ADMIN_KEY || '',
webhookSecret: process.env.LNBITS_WEBHOOK_SECRET || '',
},
jwt: {
secret: process.env.JWT_SECRET || 'your-secret-key-change-this',
},
scheduler: {
drawIntervalSeconds: parseInt(process.env.DRAW_SCHEDULER_INTERVAL_SECONDS || '60', 10),
cycleGeneratorIntervalSeconds: parseInt(process.env.CYCLE_GENERATOR_INTERVAL_SECONDS || '300', 10),
},
lottery: {
defaultTicketPriceSats: parseInt(process.env.DEFAULT_TICKET_PRICE_SATS || '1000', 10),
defaultHouseFeePercent: parseInt(process.env.DEFAULT_HOUSE_FEE_PERCENT || '5', 10),
maxTicketsPerPurchase: 100,
},
admin: {
apiKey: process.env.ADMIN_API_KEY || '',
},
payout: {
maxAttemptsBeforeRedraw: parseInt(process.env.PAYOUT_MAX_ATTEMPTS || '2', 10),
},
};
// Validate critical environment variables
function validateConfig(): void {
const requiredVars = [
{ key: 'LNBITS_API_BASE_URL', value: config.lnbits.apiBaseUrl },
{ key: 'LNBITS_ADMIN_KEY', value: config.lnbits.adminKey },
{ key: 'JWT_SECRET', value: config.jwt.secret },
{ key: 'ADMIN_API_KEY', value: config.admin.apiKey },
];
const missing = requiredVars.filter(v => !v.value || v.value === '');
if (missing.length > 0) {
console.error('❌ Missing required environment variables:');
missing.forEach(v => console.error(` - ${v.key}`));
process.exit(1);
}
// Validate database type
if (!['postgres', 'sqlite'].includes(config.database.type)) {
console.error('❌ DATABASE_TYPE must be either "postgres" or "sqlite"');
process.exit(1);
}
}
if (config.app.nodeEnv !== 'test') {
validateConfig();
}
export default config;

View File

@@ -0,0 +1,118 @@
import swaggerJsdoc from 'swagger-jsdoc';
import config from './index';
const swaggerDefinition = {
openapi: '3.0.0',
info: {
title: 'Lightning Lottery API',
version: '1.0.0',
description: 'Bitcoin Lightning Network powered lottery system with instant payouts',
contact: {
name: 'Lightning Lottery',
url: config.app.baseUrl,
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT',
},
},
servers: [
{
url: config.app.baseUrl,
description: config.app.nodeEnv === 'production' ? 'Production server' : 'Development server',
},
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT token for Nostr authenticated users',
},
adminKey: {
type: 'apiKey',
in: 'header',
name: 'X-Admin-Key',
description: 'Admin API key for administrative endpoints',
},
},
schemas: {
Lottery: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' },
ticket_price_sats: { type: 'integer' },
},
},
JackpotCycle: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
cycle_type: { type: 'string', enum: ['hourly', 'daily', 'weekly', 'monthly'] },
scheduled_at: { type: 'string', format: 'date-time' },
sales_open_at: { type: 'string', format: 'date-time' },
sales_close_at: { type: 'string', format: 'date-time' },
status: { type: 'string', enum: ['scheduled', 'sales_open', 'drawing', 'completed', 'cancelled'] },
pot_total_sats: { type: 'integer' },
pot_after_fee_sats: { type: 'integer', nullable: true },
winning_ticket_id: { type: 'string', format: 'uuid', nullable: true },
},
},
TicketPurchase: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
lightning_address: { type: 'string' },
number_of_tickets: { type: 'integer' },
amount_sats: { type: 'integer' },
invoice_status: { type: 'string', enum: ['pending', 'paid', 'expired', 'cancelled'] },
ticket_issue_status: { type: 'string', enum: ['not_issued', 'issued'] },
},
},
Ticket: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
serial_number: { type: 'integer' },
is_winning_ticket: { type: 'boolean' },
},
},
Error: {
type: 'object',
properties: {
version: { type: 'string', example: '1.0' },
error: { type: 'string' },
message: { type: 'string' },
},
},
},
},
tags: [
{
name: 'Public',
description: 'Public endpoints (no authentication required)',
},
{
name: 'User',
description: 'User endpoints (Nostr authentication required)',
},
{
name: 'Admin',
description: 'Admin endpoints (admin API key required)',
},
{
name: 'Webhooks',
description: 'Webhook endpoints for external integrations',
},
],
};
const options = {
swaggerDefinition,
apis: ['./src/routes/*.ts', './src/controllers/*.ts'], // Path to API docs
};
export const swaggerSpec = swaggerJsdoc(options);

View File

@@ -0,0 +1,191 @@
import { Request, Response } from 'express';
import { db } from '../database';
import { executeDraw } from '../services/draw';
import { retryPayoutService } from '../services/payout';
import { JackpotCycle, Payout } from '../types';
/**
* GET /admin/cycles
* List all cycles with optional filters
*/
export async function listCycles(req: Request, res: Response) {
try {
const { status, cycle_type, limit = 50, offset = 0 } = req.query;
let query = `SELECT * FROM jackpot_cycles WHERE 1=1`;
const params: any[] = [];
let paramCount = 0;
if (status) {
paramCount++;
query += ` AND status = $${paramCount}`;
params.push(status);
}
if (cycle_type) {
paramCount++;
query += ` AND cycle_type = $${paramCount}`;
params.push(cycle_type);
}
query += ` ORDER BY scheduled_at DESC LIMIT $${paramCount + 1} OFFSET $${paramCount + 2}`;
params.push(limit, offset);
const result = await db.query<JackpotCycle>(query, params);
const cycles = result.rows.map(c => ({
id: c.id,
lottery_id: c.lottery_id,
cycle_type: c.cycle_type,
sequence_number: c.sequence_number,
scheduled_at: c.scheduled_at.toISOString(),
status: c.status,
pot_total_sats: parseInt(c.pot_total_sats.toString()),
pot_after_fee_sats: c.pot_after_fee_sats ? parseInt(c.pot_after_fee_sats.toString()) : null,
winning_ticket_id: c.winning_ticket_id,
winning_lightning_address: c.winning_lightning_address,
}));
return res.json({
version: '1.0',
data: {
cycles,
total: cycles.length,
},
});
} catch (error: any) {
console.error('List cycles error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to list cycles',
});
}
}
/**
* POST /admin/cycles/:id/run-draw
* Manually trigger draw execution
*/
export async function runDrawManually(req: Request, res: Response) {
try {
const { id } = req.params;
const result = await executeDraw(id);
if (!result.success) {
return res.status(400).json({
version: '1.0',
error: result.error || 'DRAW_FAILED',
message: result.message || 'Failed to execute draw',
});
}
return res.json({
version: '1.0',
data: {
cycle_id: id,
winning_ticket_id: result.winningTicketId,
pot_after_fee_sats: result.potAfterFeeSats,
payout_status: result.payoutStatus,
},
});
} catch (error: any) {
console.error('Manual draw error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to run draw',
});
}
}
/**
* GET /admin/payouts
* List payouts with optional filters
*/
export async function listPayouts(req: Request, res: Response) {
try {
const { status, limit = 50, offset = 0 } = req.query;
let query = `SELECT * FROM payouts WHERE 1=1`;
const params: any[] = [];
let paramCount = 0;
if (status) {
paramCount++;
query += ` AND status = $${paramCount}`;
params.push(status);
}
query += ` ORDER BY created_at DESC LIMIT $${paramCount + 1} OFFSET $${paramCount + 2}`;
params.push(limit, offset);
const result = await db.query<Payout>(query, params);
const payouts = result.rows.map(p => ({
id: p.id,
lottery_id: p.lottery_id,
cycle_id: p.cycle_id,
ticket_id: p.ticket_id,
lightning_address: p.lightning_address,
amount_sats: parseInt(p.amount_sats.toString()),
status: p.status,
error_message: p.error_message,
retry_count: p.retry_count,
created_at: p.created_at.toISOString(),
}));
return res.json({
version: '1.0',
data: {
payouts,
total: payouts.length,
},
});
} catch (error: any) {
console.error('List payouts error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to list payouts',
});
}
}
/**
* POST /admin/payouts/:id/retry
* Retry a failed payout
*/
export async function retryPayout(req: Request, res: Response) {
try {
const { id } = req.params;
const result = await retryPayoutService(id);
if (!result.success) {
return res.status(400).json({
version: '1.0',
error: result.error || 'RETRY_FAILED',
message: result.message || 'Failed to retry payout',
});
}
return res.json({
version: '1.0',
data: {
payout_id: id,
status: result.status,
retry_count: result.retryCount,
},
});
} catch (error: any) {
console.error('Retry payout error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to retry payout',
});
}
}

View File

@@ -0,0 +1,483 @@
import { Request, Response } from 'express';
import { randomUUID } from 'crypto';
import { db } from '../database';
import { lnbitsService } from '../services/lnbits';
import { paymentMonitor } from '../services/paymentMonitor';
import { validateLightningAddress, validateTicketCount, sanitizeString } from '../utils/validation';
import config from '../config';
import { JackpotCycle, TicketPurchase, Ticket, Payout } from '../types';
import { AuthRequest } from '../middleware/auth';
const toIsoString = (value: any): string => {
if (!value) {
return new Date().toISOString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === 'number') {
return new Date(value).toISOString();
}
if (typeof value === 'string') {
// SQLite stores timestamps as "YYYY-MM-DD HH:mm:ss" by default
const normalized = value.includes('T')
? value
: value.replace(' ', 'T') + 'Z';
const parsed = new Date(normalized);
if (!isNaN(parsed.getTime())) {
return parsed.toISOString();
}
}
if (typeof value === 'object' && typeof (value as any).toISOString === 'function') {
return (value as any).toISOString();
}
const parsed = new Date(value);
if (!isNaN(parsed.getTime())) {
return parsed.toISOString();
}
return new Date().toISOString();
};
/**
* GET /jackpot/next
* Returns the next upcoming cycle
*/
export async function getNextJackpot(req: Request, res: Response) {
try {
// Get active lottery
const lotteryResult = await db.query<any>(
`SELECT * FROM lotteries WHERE status = 'active' LIMIT 1`
);
if (lotteryResult.rows.length === 0) {
return res.status(503).json({
version: '1.0',
error: 'NO_ACTIVE_LOTTERY',
message: 'No active lottery available',
});
}
const lottery = lotteryResult.rows[0];
// Get next cycle
const cycleResult = await db.query<JackpotCycle>(
`SELECT * FROM jackpot_cycles
WHERE lottery_id = $1
AND status IN ('scheduled', 'sales_open')
AND scheduled_at > NOW()
ORDER BY scheduled_at ASC
LIMIT 1`,
[lottery.id]
);
if (cycleResult.rows.length === 0) {
return res.status(503).json({
version: '1.0',
error: 'NO_UPCOMING_CYCLE',
message: 'No upcoming cycle available',
});
}
const cycle = cycleResult.rows[0];
// Helper function to ensure ISO string format
const toISOString = (date: any): string => {
if (!date) return new Date().toISOString();
if (typeof date === 'string') return date.includes('Z') ? date : new Date(date).toISOString();
return date.toISOString();
};
return res.json({
version: '1.0',
data: {
lottery: {
id: lottery.id,
name: lottery.name,
ticket_price_sats: parseInt(lottery.ticket_price_sats),
},
cycle: {
id: cycle.id,
cycle_type: cycle.cycle_type,
scheduled_at: toISOString(cycle.scheduled_at),
sales_open_at: toISOString(cycle.sales_open_at),
sales_close_at: toISOString(cycle.sales_close_at),
status: cycle.status,
pot_total_sats: parseInt(cycle.pot_total_sats.toString()),
},
},
});
} catch (error: any) {
console.error('Get next jackpot error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to fetch next jackpot',
});
}
}
/**
* POST /jackpot/buy
* Create a ticket purchase
*/
export async function buyTickets(req: AuthRequest, res: Response) {
try {
const { tickets, lightning_address, nostr_pubkey, name, buyer_name } = req.body;
const userId = req.user?.id || null;
const authNostrPubkey = req.user?.nostr_pubkey || null;
// Validation
if (!validateTicketCount(tickets, config.lottery.maxTicketsPerPurchase)) {
return res.status(400).json({
version: '1.0',
error: 'INVALID_TICKET_COUNT',
message: `Tickets must be between 1 and ${config.lottery.maxTicketsPerPurchase}`,
});
}
if (!validateLightningAddress(lightning_address)) {
return res.status(400).json({
version: '1.0',
error: 'INVALID_LIGHTNING_ADDRESS',
message: 'Lightning address must contain @ and be valid format',
});
}
const normalizedLightningAddress = sanitizeString(lightning_address);
const rawNameInput = typeof name === 'string'
? name
: typeof buyer_name === 'string'
? buyer_name
: '';
const buyerName = sanitizeString(rawNameInput || 'Anon', 64) || 'Anon';
// Get active lottery
const lotteryResult = await db.query(
`SELECT * FROM lotteries WHERE status = 'active' LIMIT 1`
);
if (lotteryResult.rows.length === 0) {
return res.status(503).json({
version: '1.0',
error: 'NO_ACTIVE_LOTTERY',
message: 'No active lottery available',
});
}
const lottery = lotteryResult.rows[0];
// Get next available cycle
const cycleResult = await db.query<JackpotCycle>(
`SELECT * FROM jackpot_cycles
WHERE lottery_id = $1
AND status IN ('scheduled', 'sales_open')
AND sales_close_at > NOW()
ORDER BY scheduled_at ASC
LIMIT 1`,
[lottery.id]
);
if (cycleResult.rows.length === 0) {
return res.status(400).json({
version: '1.0',
error: 'NO_AVAILABLE_CYCLE',
message: 'No cycle available for ticket purchase',
});
}
const cycle = cycleResult.rows[0];
// Calculate amount
const ticketPriceSats = parseInt(lottery.ticket_price_sats);
const amountSats = tickets * ticketPriceSats;
// Create ticket purchase record
const purchaseId = randomUUID();
const purchaseResult = await db.query<TicketPurchase>(
`INSERT INTO ticket_purchases (
id, lottery_id, cycle_id, user_id, nostr_pubkey, lightning_address,
buyer_name,
number_of_tickets, ticket_price_sats, amount_sats,
lnbits_invoice_id, lnbits_payment_hash, invoice_status, ticket_issue_status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING *`,
[
purchaseId,
lottery.id,
cycle.id,
userId,
nostr_pubkey || authNostrPubkey || null,
normalizedLightningAddress,
buyerName,
tickets,
ticketPriceSats,
amountSats,
'', // Will update after invoice creation
'', // Will update after invoice creation
'pending',
'not_issued',
]
);
const purchase = purchaseResult.rows[0];
const publicUrl = `${config.app.baseUrl}/tickets/${purchase.id}`;
// Create invoice memo
const memo = `Lightning Jackpot
Tickets: ${tickets}
Purchase ID: ${purchase.id}
Check status: ${publicUrl}`;
// Create LNbits invoice
try {
const webhookUrl = new URL('/webhooks/lnbits/payment', config.app.baseUrl);
if (config.lnbits.webhookSecret) {
webhookUrl.searchParams.set('secret', config.lnbits.webhookSecret);
}
const invoice = await lnbitsService.createInvoice({
amount: amountSats,
memo: memo,
webhook: webhookUrl.toString(),
});
// Update purchase with invoice details
await db.query(
`UPDATE ticket_purchases
SET lnbits_invoice_id = $1, lnbits_payment_hash = $2, updated_at = NOW()
WHERE id = $3`,
[invoice.checking_id, invoice.payment_hash, purchase.id]
);
if (userId) {
await db.query(
`UPDATE users
SET lightning_address = $1, updated_at = NOW()
WHERE id = $2`,
[normalizedLightningAddress, userId]
);
}
paymentMonitor.addInvoice(purchase.id, invoice.payment_hash);
return res.json({
version: '1.0',
data: {
ticket_purchase_id: purchase.id,
public_url: publicUrl,
invoice: {
payment_request: invoice.payment_request,
amount_sats: amountSats,
},
},
});
} catch (invoiceError: any) {
// Cleanup purchase if invoice creation fails
await db.query('DELETE FROM ticket_purchases WHERE id = $1', [purchase.id]);
return res.status(502).json({
version: '1.0',
error: 'INVOICE_CREATION_FAILED',
message: 'Failed to create Lightning invoice',
});
}
} catch (error: any) {
console.error('Buy tickets error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to process ticket purchase',
});
}
}
/**
* GET /tickets/:id
* Get ticket purchase status
*/
export async function getTicketStatus(req: Request, res: Response) {
try {
const { id } = req.params;
// Get purchase
const purchaseResult = await db.query<TicketPurchase>(
`SELECT * FROM ticket_purchases WHERE id = $1`,
[id]
);
if (purchaseResult.rows.length === 0) {
return res.status(404).json({
version: '1.0',
error: 'PURCHASE_NOT_FOUND',
message: 'Ticket purchase not found',
});
}
const purchase = purchaseResult.rows[0];
// Get cycle
const cycleResult = await db.query<JackpotCycle>(
`SELECT * FROM jackpot_cycles WHERE id = $1`,
[purchase.cycle_id]
);
const cycle = cycleResult.rows[0];
// Get tickets
const ticketsResult = await db.query<Ticket>(
`SELECT * FROM tickets WHERE ticket_purchase_id = $1 ORDER BY serial_number`,
[id]
);
const tickets = ticketsResult.rows.map(t => ({
id: t.id,
serial_number: parseInt(t.serial_number.toString()),
is_winning_ticket: cycle.winning_ticket_id === t.id,
}));
// Determine result
let result = {
has_drawn: cycle.status === 'completed',
is_winner: false,
payout: null as any,
};
if (cycle.status === 'completed' && cycle.winning_ticket_id) {
const isWinner = tickets.some(t => t.id === cycle.winning_ticket_id);
result.is_winner = isWinner;
if (isWinner) {
// Get payout
const payoutResult = await db.query<Payout>(
`SELECT * FROM payouts WHERE ticket_id = $1`,
[cycle.winning_ticket_id]
);
if (payoutResult.rows.length > 0) {
const payout = payoutResult.rows[0];
result.payout = {
status: payout.status,
amount_sats: parseInt(payout.amount_sats.toString()),
};
}
}
}
return res.json({
version: '1.0',
data: {
purchase: {
id: purchase.id,
lottery_id: purchase.lottery_id,
cycle_id: purchase.cycle_id,
lightning_address: purchase.lightning_address,
buyer_name: purchase.buyer_name,
number_of_tickets: purchase.number_of_tickets,
ticket_price_sats: parseInt(purchase.ticket_price_sats.toString()),
amount_sats: parseInt(purchase.amount_sats.toString()),
invoice_status: purchase.invoice_status,
ticket_issue_status: purchase.ticket_issue_status,
created_at: toIsoString(purchase.created_at),
},
tickets,
cycle: {
id: cycle.id,
cycle_type: cycle.cycle_type,
scheduled_at: toIsoString(cycle.scheduled_at),
sales_open_at: toIsoString(cycle.sales_open_at),
sales_close_at: toIsoString(cycle.sales_close_at),
status: cycle.status,
pot_total_sats: parseInt(cycle.pot_total_sats.toString()),
pot_after_fee_sats: cycle.pot_after_fee_sats ? parseInt(cycle.pot_after_fee_sats.toString()) : null,
winning_ticket_id: cycle.winning_ticket_id,
},
result,
},
});
} catch (error: any) {
console.error('Get ticket status error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to fetch ticket status',
});
}
}
interface PastWinRow {
cycle_id: string;
cycle_type: JackpotCycle['cycle_type'];
scheduled_at: Date | string;
pot_total_sats: number;
pot_after_fee_sats: number | null;
buyer_name: string | null;
serial_number: number | null;
}
/**
* GET /jackpot/past-wins
* List past jackpots with winner info
*/
export async function getPastWins(req: Request, res: Response) {
try {
const limitParam = parseInt((req.query.limit as string) || '20', 10);
const offsetParam = parseInt((req.query.offset as string) || '0', 10);
const limit = Math.min(100, Math.max(1, Number.isNaN(limitParam) ? 20 : limitParam));
const offset = Math.max(0, Number.isNaN(offsetParam) ? 0 : offsetParam);
const result = await db.query<PastWinRow>(
`SELECT
jc.id as cycle_id,
jc.cycle_type,
jc.scheduled_at,
jc.pot_total_sats,
jc.pot_after_fee_sats,
tp.buyer_name,
t.serial_number
FROM jackpot_cycles jc
JOIN tickets t ON jc.winning_ticket_id = t.id
JOIN ticket_purchases tp ON t.ticket_purchase_id = tp.id
WHERE jc.status = 'completed'
AND jc.winning_ticket_id IS NOT NULL
ORDER BY jc.scheduled_at DESC
LIMIT $1 OFFSET $2`,
[limit, offset]
);
const wins = result.rows.map((row) => ({
cycle_id: row.cycle_id,
cycle_type: row.cycle_type,
scheduled_at: toIsoString(row.scheduled_at),
pot_total_sats: parseInt(row.pot_total_sats.toString()),
pot_after_fee_sats: row.pot_after_fee_sats
? parseInt(row.pot_after_fee_sats.toString())
: null,
winner_name: row.buyer_name || 'Anon',
winning_ticket_serial: row.serial_number
? parseInt(row.serial_number.toString())
: null,
}));
return res.json({
version: '1.0',
data: {
wins,
},
});
} catch (error: any) {
console.error('Get past wins error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to load past wins',
});
}
}

View File

@@ -0,0 +1,348 @@
import { Request, Response } from 'express';
import { randomUUID } from 'crypto';
import jwt from 'jsonwebtoken';
import { db } from '../database';
import { AuthRequest } from '../middleware/auth';
import { validateNostrPubkey, validateLightningAddress } from '../utils/validation';
import config from '../config';
import { User, TicketPurchase, Payout } from '../types';
const toIsoString = (value: any): string => {
if (!value) {
return new Date().toISOString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === 'number') {
return new Date(value).toISOString();
}
if (typeof value === 'string') {
const normalized = value.includes('T') ? value : value.replace(' ', 'T') + 'Z';
const parsed = new Date(normalized);
if (!isNaN(parsed.getTime())) {
return parsed.toISOString();
}
}
if (typeof value === 'object' && typeof (value as any).toISOString === 'function') {
return (value as any).toISOString();
}
const parsed = new Date(value);
if (!isNaN(parsed.getTime())) {
return parsed.toISOString();
}
return new Date().toISOString();
};
const parseCount = (value: any): number => {
const parsed = parseInt(value?.toString() ?? '0', 10);
return Number.isNaN(parsed) ? 0 : parsed;
};
/**
* POST /auth/nostr
* Authenticate with Nostr signature
*/
export async function nostrAuth(req: Request, res: Response) {
try {
const { nostr_pubkey, signed_message, nonce } = req.body;
// Validate pubkey format
if (!validateNostrPubkey(nostr_pubkey)) {
return res.status(400).json({
version: '1.0',
error: 'INVALID_PUBKEY',
message: 'Invalid Nostr public key format',
});
}
// TODO: Implement actual signature verification using nostr-tools
// For now, we'll trust the pubkey (NOT PRODUCTION READY)
// In production, verify the signature against the nonce
// Find or create user
let userResult = await db.query<User>(
`SELECT * FROM users WHERE nostr_pubkey = $1`,
[nostr_pubkey]
);
let user: User;
if (userResult.rows.length === 0) {
// Create new user
const userId = randomUUID();
const createResult = await db.query<User>(
`INSERT INTO users (id, nostr_pubkey) VALUES ($1, $2) RETURNING *`,
[userId, nostr_pubkey]
);
user = createResult.rows[0];
} else {
user = userResult.rows[0];
}
// Generate JWT token
const token = jwt.sign(
{
id: user.id,
nostr_pubkey: user.nostr_pubkey,
},
config.jwt.secret,
{ expiresIn: '30d' }
);
return res.json({
version: '1.0',
data: {
token,
user: {
id: user.id,
nostr_pubkey: user.nostr_pubkey,
display_name: user.display_name,
lightning_address: user.lightning_address,
},
},
});
} catch (error: any) {
console.error('Nostr auth error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to authenticate',
});
}
}
/**
* GET /me
* Get user profile
*/
export async function getProfile(req: AuthRequest, res: Response) {
try {
const userId = req.user!.id;
const userResult = await db.query<User>(
`SELECT * FROM users WHERE id = $1`,
[userId]
);
if (userResult.rows.length === 0) {
return res.status(404).json({
version: '1.0',
error: 'USER_NOT_FOUND',
message: 'User not found',
});
}
const user = userResult.rows[0];
const nostrPubkey = user.nostr_pubkey;
const totalTicketsResult = await db.query(
`SELECT COALESCE(SUM(number_of_tickets), 0) as total_tickets
FROM ticket_purchases
WHERE user_id = $1 OR nostr_pubkey = $2`,
[userId, nostrPubkey]
);
const currentRoundResult = await db.query(
`SELECT COALESCE(SUM(tp.number_of_tickets), 0) as current_tickets
FROM ticket_purchases tp
JOIN jackpot_cycles jc ON tp.cycle_id = jc.id
WHERE (tp.user_id = $1 OR tp.nostr_pubkey = $2)
AND jc.status IN ('scheduled', 'sales_open')`,
[userId, nostrPubkey]
);
const pastTicketsResult = await db.query(
`SELECT COALESCE(SUM(tp.number_of_tickets), 0) as past_tickets
FROM ticket_purchases tp
JOIN jackpot_cycles jc ON tp.cycle_id = jc.id
WHERE (tp.user_id = $1 OR tp.nostr_pubkey = $2)
AND jc.status NOT IN ('scheduled', 'sales_open')`,
[userId, nostrPubkey]
);
const winStats = await db.query(
`SELECT
COALESCE(SUM(CASE WHEN p.status = 'paid' THEN 1 ELSE 0 END), 0) as total_wins,
COALESCE(SUM(CASE WHEN p.status = 'paid' THEN p.amount_sats ELSE 0 END), 0) as total_winnings
FROM payouts p
JOIN tickets t ON p.ticket_id = t.id
JOIN ticket_purchases tp ON t.ticket_purchase_id = tp.id
WHERE tp.user_id = $1 OR tp.nostr_pubkey = $2`,
[userId, nostrPubkey]
);
const totalTickets = parseCount(totalTicketsResult.rows[0]?.total_tickets);
const currentRoundTickets = parseCount(currentRoundResult.rows[0]?.current_tickets);
const pastTickets = parseCount(pastTicketsResult.rows[0]?.past_tickets);
return res.json({
version: '1.0',
data: {
user: {
id: user.id,
nostr_pubkey: user.nostr_pubkey,
display_name: user.display_name,
lightning_address: user.lightning_address,
},
stats: {
total_tickets: totalTickets,
current_round_tickets: currentRoundTickets,
past_tickets: pastTickets,
total_wins: parseCount(winStats.rows[0]?.total_wins),
total_winnings_sats: parseCount(winStats.rows[0]?.total_winnings),
},
},
});
} catch (error: any) {
console.error('Get profile error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to get profile',
});
}
}
/**
* PATCH /me/lightning-address
* Update user's lightning address
*/
export async function updateLightningAddress(req: AuthRequest, res: Response) {
try {
const userId = req.user!.id;
const { lightning_address } = req.body;
if (!validateLightningAddress(lightning_address)) {
return res.status(400).json({
version: '1.0',
error: 'INVALID_LIGHTNING_ADDRESS',
message: 'Invalid Lightning Address format',
});
}
await db.query(
`UPDATE users SET lightning_address = $1, updated_at = NOW() WHERE id = $2`,
[lightning_address, userId]
);
return res.json({
version: '1.0',
data: {
lightning_address,
},
});
} catch (error: any) {
console.error('Update lightning address error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to update lightning address',
});
}
}
/**
* GET /me/tickets
* Get user's ticket purchases
*/
export async function getUserTickets(req: AuthRequest, res: Response) {
try {
const userId = req.user!.id;
const userPubkey = req.user!.nostr_pubkey;
const limitParam = typeof req.query.limit === 'string' ? parseInt(req.query.limit, 10) : 50;
const offsetParam = typeof req.query.offset === 'string' ? parseInt(req.query.offset, 10) : 0;
const limitValue = Math.min(100, Math.max(1, Number.isNaN(limitParam) ? 50 : limitParam));
const offsetValue = Math.max(0, Number.isNaN(offsetParam) ? 0 : offsetParam);
const result = await db.query<TicketPurchase>(
`SELECT tp.*, jc.cycle_type, jc.scheduled_at, jc.status as cycle_status
FROM ticket_purchases tp
JOIN jackpot_cycles jc ON tp.cycle_id = jc.id
WHERE (tp.user_id = $1 OR tp.nostr_pubkey = $2)
ORDER BY tp.created_at DESC
LIMIT $3 OFFSET $4`,
[userId, userPubkey, limitValue, offsetValue]
);
const purchases = result.rows.map(p => ({
id: p.id,
cycle_id: p.cycle_id,
buyer_name: p.buyer_name,
number_of_tickets: p.number_of_tickets,
amount_sats: parseInt(p.amount_sats.toString()),
invoice_status: p.invoice_status,
ticket_issue_status: p.ticket_issue_status,
created_at: toIsoString(p.created_at),
}));
return res.json({
version: '1.0',
data: {
purchases,
total: purchases.length,
},
});
} catch (error: any) {
console.error('Get user tickets error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to get tickets',
});
}
}
/**
* GET /me/wins
* Get user's wins
*/
export async function getUserWins(req: AuthRequest, res: Response) {
try {
const userId = req.user!.id;
const userPubkey = req.user!.nostr_pubkey;
const result = await db.query<Payout>(
`SELECT p.*, jc.cycle_type, jc.scheduled_at
FROM payouts p
JOIN tickets t ON p.ticket_id = t.id
JOIN ticket_purchases tp ON t.ticket_purchase_id = tp.id
JOIN jackpot_cycles jc ON p.cycle_id = jc.id
WHERE tp.user_id = $1 OR tp.nostr_pubkey = $2
ORDER BY p.created_at DESC`,
[userId, userPubkey]
);
const wins = result.rows.map(p => ({
id: p.id,
cycle_id: p.cycle_id,
ticket_id: p.ticket_id,
amount_sats: parseInt(p.amount_sats.toString()),
status: p.status,
created_at: toIsoString(p.created_at),
}));
return res.json({
version: '1.0',
data: {
wins,
total: wins.length,
},
});
} catch (error: any) {
console.error('Get user wins error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to get wins',
});
}
}

View File

@@ -0,0 +1,98 @@
import { Request, Response } from 'express';
import { db } from '../database';
import { lnbitsService } from '../services/lnbits';
import { TicketPurchase } from '../types';
import { finalizeTicketPurchase } from '../services/paymentProcessor';
import { paymentMonitor } from '../services/paymentMonitor';
/**
* POST /webhooks/lnbits/payment
* Handle LNbits payment webhook
*/
export async function handleLNbitsPayment(req: Request, res: Response) {
try {
const { payment_hash, amount, paid } = req.body;
// Verify webhook (basic secret validation)
const webhookSecretHeader = (req.headers['x-webhook-secret'] || req.headers['x-callback-secret']) as string | undefined;
const webhookSecretQuery = (() => {
const value = req.query?.secret;
if (Array.isArray(value)) {
return value[0];
}
return value as string | undefined;
})();
const providedSecret = webhookSecretHeader || webhookSecretQuery || '';
if (!lnbitsService.verifyWebhook(providedSecret)) {
console.error('Webhook verification failed');
return res.status(403).json({
version: '1.0',
error: 'FORBIDDEN',
message: 'Invalid webhook signature',
});
}
// Check if payment is successful
if (!paid) {
console.log('Payment not completed yet:', payment_hash);
return res.json({ version: '1.0', message: 'Payment not completed' });
}
// Find purchase by payment hash
const purchaseResult = await db.query<TicketPurchase>(
`SELECT * FROM ticket_purchases WHERE lnbits_payment_hash = $1`,
[payment_hash]
);
if (purchaseResult.rows.length === 0) {
console.error('Purchase not found for payment hash:', payment_hash);
// Return 200 to avoid retries for unknown payments
return res.json({ version: '1.0', message: 'Purchase not found' });
}
const purchase = purchaseResult.rows[0];
// Idempotency check
if (purchase.invoice_status === 'paid') {
console.log('Invoice already marked as paid:', purchase.id);
return res.json({ version: '1.0', message: 'Already processed' });
}
// Verify amount (optional, but recommended)
const expectedAmount = parseInt(purchase.amount_sats.toString());
if (amount && amount < expectedAmount) {
console.error('Payment amount mismatch:', { expected: expectedAmount, received: amount });
// You could mark as error here, but for now we'll still process
}
const finalizeResult = await finalizeTicketPurchase(purchase.id);
if (finalizeResult.status === 'not_found') {
console.error('Purchase disappeared before processing:', purchase.id);
return res.json({ version: '1.0', message: 'Purchase missing during processing' });
}
paymentMonitor.removeInvoice(purchase.id);
console.log('Payment processed successfully:', {
purchase_id: purchase.id,
tickets: finalizeResult.ticketsIssued,
amount_sats: purchase.amount_sats,
});
return res.json({
version: '1.0',
message: 'Payment processed successfully',
});
} catch (error: any) {
console.error('Webhook processing error:', error);
return res.status(500).json({
version: '1.0',
error: 'INTERNAL_ERROR',
message: 'Failed to process payment webhook',
});
}
}

View File

@@ -0,0 +1,334 @@
import { Pool, QueryResult, PoolClient, QueryResultRow } from 'pg';
import Database from 'better-sqlite3';
import config from '../config';
import * as fs from 'fs';
import * as path from 'path';
interface DbClient {
query<T extends QueryResultRow = any>(text: string, params?: any[]): Promise<QueryResult<T>>;
release?: () => void;
}
class DatabaseWrapper {
private pgPool?: Pool;
private sqliteDb?: Database.Database;
private dbType: 'postgres' | 'sqlite';
constructor() {
this.dbType = config.database.type;
if (this.dbType === 'postgres') {
this.initPostgres();
} else {
this.initSqlite();
}
}
private initPostgres() {
this.pgPool = new Pool({
connectionString: config.database.url,
ssl: config.app.nodeEnv === 'production' ? { rejectUnauthorized: false } : false,
});
this.pgPool.on('error', (err) => {
console.error('Unexpected PostgreSQL error:', err);
});
console.log('✓ PostgreSQL database initialized');
}
private initSqlite() {
// Ensure data directory exists
const dbPath = config.database.url;
const dbDir = path.dirname(dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
this.sqliteDb = new Database(dbPath);
this.sqliteDb.pragma('journal_mode = WAL');
this.sqliteDb.pragma('foreign_keys = ON');
// Initialize schema for SQLite
this.initSqliteSchema();
console.log(`✓ SQLite database initialized at: ${dbPath}`);
}
private initSqliteSchema() {
if (!this.sqliteDb) return;
const schema = `
-- SQLite Schema for Lightning Lottery
CREATE TABLE IF NOT EXISTS lotteries (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'finished')),
ticket_price_sats INTEGER NOT NULL,
fee_percent INTEGER NOT NULL CHECK (fee_percent >= 0 AND fee_percent <= 100),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS jackpot_cycles (
id TEXT PRIMARY KEY,
lottery_id TEXT NOT NULL REFERENCES lotteries(id),
cycle_type TEXT NOT NULL CHECK (cycle_type IN ('hourly', 'daily', 'weekly', 'monthly')),
sequence_number INTEGER NOT NULL,
scheduled_at TEXT NOT NULL,
sales_open_at TEXT NOT NULL,
sales_close_at TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('scheduled', 'sales_open', 'drawing', 'completed', 'cancelled')),
pot_total_sats INTEGER NOT NULL DEFAULT 0,
pot_after_fee_sats INTEGER,
winning_ticket_id TEXT,
winning_lightning_address TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
nostr_pubkey TEXT UNIQUE NOT NULL,
display_name TEXT,
lightning_address TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS ticket_purchases (
id TEXT PRIMARY KEY,
lottery_id TEXT NOT NULL REFERENCES lotteries(id),
cycle_id TEXT NOT NULL REFERENCES jackpot_cycles(id),
user_id TEXT REFERENCES users(id),
nostr_pubkey TEXT,
lightning_address TEXT NOT NULL,
buyer_name TEXT NOT NULL DEFAULT 'Anon',
number_of_tickets INTEGER NOT NULL,
ticket_price_sats INTEGER NOT NULL,
amount_sats INTEGER NOT NULL,
lnbits_invoice_id TEXT NOT NULL,
lnbits_payment_hash TEXT NOT NULL,
invoice_status TEXT NOT NULL CHECK (invoice_status IN ('pending', 'paid', 'expired', 'cancelled')),
ticket_issue_status TEXT NOT NULL CHECK (ticket_issue_status IN ('not_issued', 'issued')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
lottery_id TEXT NOT NULL REFERENCES lotteries(id),
cycle_id TEXT NOT NULL REFERENCES jackpot_cycles(id),
ticket_purchase_id TEXT NOT NULL REFERENCES ticket_purchases(id),
user_id TEXT REFERENCES users(id),
lightning_address TEXT NOT NULL,
serial_number INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS payouts (
id TEXT PRIMARY KEY,
lottery_id TEXT NOT NULL REFERENCES lotteries(id),
cycle_id TEXT NOT NULL REFERENCES jackpot_cycles(id),
ticket_id TEXT NOT NULL REFERENCES tickets(id),
user_id TEXT REFERENCES users(id),
lightning_address TEXT NOT NULL,
amount_sats INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'failed')),
lnbits_payment_id TEXT,
error_message TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS draw_logs (
id TEXT PRIMARY KEY,
cycle_id TEXT NOT NULL REFERENCES jackpot_cycles(id),
number_of_tickets INTEGER NOT NULL,
pot_total_sats INTEGER NOT NULL,
fee_percent INTEGER NOT NULL,
pot_after_fee_sats INTEGER NOT NULL,
winner_ticket_id TEXT,
winner_lightning_address TEXT,
rng_source TEXT NOT NULL,
selected_index INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_cycles_status_time ON jackpot_cycles(status, scheduled_at);
CREATE INDEX IF NOT EXISTS idx_ticketpurchase_paymenthash ON ticket_purchases(lnbits_payment_hash);
CREATE INDEX IF NOT EXISTS idx_ticketpurchase_cycle ON ticket_purchases(cycle_id);
CREATE INDEX IF NOT EXISTS idx_tickets_cycle ON tickets(cycle_id);
CREATE INDEX IF NOT EXISTS idx_tickets_purchase ON tickets(ticket_purchase_id);
CREATE INDEX IF NOT EXISTS idx_payouts_ticket ON payouts(ticket_id);
CREATE INDEX IF NOT EXISTS idx_payouts_status ON payouts(status);
CREATE INDEX IF NOT EXISTS idx_users_pubkey ON users(nostr_pubkey);
-- Insert default lottery if not exists
INSERT OR IGNORE INTO lotteries (id, name, status, ticket_price_sats, fee_percent)
VALUES ('default-lottery-id', 'Main Lightning Jackpot', 'active', 1000, 5);
`;
this.sqliteDb.exec(schema);
this.ensureSqliteColumn('ticket_purchases', 'buyer_name', "TEXT NOT NULL DEFAULT 'Anon'");
}
async query<T extends QueryResultRow = any>(text: string, params?: any[]): Promise<QueryResult<T>> {
const start = Date.now();
try {
if (this.dbType === 'postgres' && this.pgPool) {
const res = await this.pgPool.query<T>(text, params);
const duration = Date.now() - start;
if (config.app.nodeEnv === 'development') {
console.log('Executed query', { text: text.substring(0, 100), duration, rows: res.rowCount });
}
return res;
} else if (this.dbType === 'sqlite' && this.sqliteDb) {
// Convert PostgreSQL-style query to SQLite
let sqliteQuery = this.convertToSqlite(text, params);
// Determine if it's a SELECT or modification query
const isSelect = text.trim().toUpperCase().startsWith('SELECT');
let rows: any[] = [];
if (isSelect) {
const stmt = this.sqliteDb.prepare(sqliteQuery.text);
rows = stmt.all(...(sqliteQuery.params || []));
} else {
const stmt = this.sqliteDb.prepare(sqliteQuery.text);
const result = stmt.run(...(sqliteQuery.params || []));
// For INSERT with RETURNING, we need to fetch the inserted row
if (text.toUpperCase().includes('RETURNING')) {
const match = text.match(/INSERT INTO (\w+)/i);
if (match && result.lastInsertRowid) {
const tableName = match[1];
rows = this.sqliteDb.prepare(`SELECT * FROM ${tableName} WHERE rowid = ?`)
.all(result.lastInsertRowid);
}
}
}
const duration = Date.now() - start;
if (config.app.nodeEnv === 'development') {
console.log('Executed SQLite query', { text: sqliteQuery.text.substring(0, 100), duration, rows: rows.length });
}
return {
rows: rows as T[],
rowCount: rows.length,
command: '',
oid: 0,
fields: [],
} as QueryResult<T>;
}
throw new Error('Database not initialized');
} catch (error) {
console.error('Database query error:', error);
throw error;
}
}
private generateUUID(): string {
// Simple UUID v4 generator
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
private convertToSqlite(text: string, params?: any[]): { text: string; params?: any[] } {
// Convert PostgreSQL $1, $2 placeholders to SQLite ?
let sqliteText = text;
let sqliteParams = params || [];
// Replace $n with ?
sqliteText = sqliteText.replace(/\$(\d+)/g, '?');
// Convert PostgreSQL-specific functions
sqliteText = sqliteText.replace(/NOW\(\)/gi, "datetime('now')");
// Remove FOR UPDATE locking hints (SQLite locks entire database for writes)
sqliteText = sqliteText.replace(/\s+FOR\s+UPDATE/gi, '');
// For UUID generation in INSERT statements, generate actual UUIDs
const uuidMatches = sqliteText.match(/uuid_generate_v4\(\)/gi);
if (uuidMatches) {
uuidMatches.forEach(() => {
sqliteText = sqliteText.replace(/uuid_generate_v4\(\)/i, `'${this.generateUUID()}'`);
});
}
// Convert RETURNING * to just the statement (SQLite doesn't support RETURNING in the same way)
if (sqliteText.toUpperCase().includes('RETURNING')) {
sqliteText = sqliteText.replace(/RETURNING \*/gi, '');
}
// Handle TIMESTAMPTZ -> TEXT conversion
sqliteText = sqliteText.replace(/TIMESTAMPTZ/gi, 'TEXT');
return { text: sqliteText, params: sqliteParams };
}
private ensureSqliteColumn(table: string, column: string, definition: string): void {
if (!this.sqliteDb) return;
const columns = this.sqliteDb.prepare(`PRAGMA table_info(${table})`).all();
const exists = columns.some((col: any) => col.name === column);
if (!exists) {
this.sqliteDb.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
}
async getClient(): Promise<DbClient> {
if (this.dbType === 'postgres' && this.pgPool) {
const client = await this.pgPool.connect();
return {
query: async <T extends QueryResultRow = any>(text: string, params?: any[]) => {
return client.query<T>(text, params);
},
release: () => client.release(),
};
} else {
// For SQLite, return a wrapper that uses the main connection
return {
query: async <T extends QueryResultRow = any>(text: string, params?: any[]) => {
return this.query<T>(text, params);
},
};
}
}
async healthCheck(): Promise<boolean> {
try {
if (this.dbType === 'postgres') {
await this.query('SELECT 1');
} else {
await this.query('SELECT 1');
}
return true;
} catch (error) {
console.error('Database health check failed:', error);
return false;
}
}
async close(): Promise<void> {
if (this.pgPool) {
await this.pgPool.end();
}
if (this.sqliteDb) {
this.sqliteDb.close();
}
}
}
export const db = new DatabaseWrapper();
export default db;

View File

@@ -0,0 +1,131 @@
-- Lightning Lottery Database Schema
-- PostgreSQL 14+
-- Create UUID extension if not exists
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Lotteries table
CREATE TABLE IF NOT EXISTS lotteries (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'finished')),
ticket_price_sats BIGINT NOT NULL,
fee_percent INTEGER NOT NULL CHECK (fee_percent >= 0 AND fee_percent <= 100),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Jackpot Cycles table
CREATE TABLE IF NOT EXISTS jackpot_cycles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
lottery_id UUID NOT NULL REFERENCES lotteries(id),
cycle_type TEXT NOT NULL CHECK (cycle_type IN ('hourly', 'daily', 'weekly', 'monthly')),
sequence_number INTEGER NOT NULL,
scheduled_at TIMESTAMPTZ NOT NULL,
sales_open_at TIMESTAMPTZ NOT NULL,
sales_close_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL CHECK (status IN ('scheduled', 'sales_open', 'drawing', 'completed', 'cancelled')),
pot_total_sats BIGINT NOT NULL DEFAULT 0,
pot_after_fee_sats BIGINT,
winning_ticket_id UUID,
winning_lightning_address TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Users table (optional for Nostr)
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
nostr_pubkey TEXT UNIQUE NOT NULL,
display_name TEXT,
lightning_address TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Ticket Purchases table
CREATE TABLE IF NOT EXISTS ticket_purchases (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
lottery_id UUID NOT NULL REFERENCES lotteries(id),
cycle_id UUID NOT NULL REFERENCES jackpot_cycles(id),
user_id UUID REFERENCES users(id),
nostr_pubkey TEXT,
lightning_address TEXT NOT NULL,
buyer_name TEXT NOT NULL DEFAULT 'Anon',
number_of_tickets INTEGER NOT NULL,
ticket_price_sats BIGINT NOT NULL,
amount_sats BIGINT NOT NULL,
lnbits_invoice_id TEXT NOT NULL,
lnbits_payment_hash TEXT NOT NULL,
invoice_status TEXT NOT NULL CHECK (invoice_status IN ('pending', 'paid', 'expired', 'cancelled')),
ticket_issue_status TEXT NOT NULL CHECK (ticket_issue_status IN ('not_issued', 'issued')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Tickets table
CREATE TABLE IF NOT EXISTS tickets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
lottery_id UUID NOT NULL REFERENCES lotteries(id),
cycle_id UUID NOT NULL REFERENCES jackpot_cycles(id),
ticket_purchase_id UUID NOT NULL REFERENCES ticket_purchases(id),
user_id UUID REFERENCES users(id),
lightning_address TEXT NOT NULL,
serial_number BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Payouts table
CREATE TABLE IF NOT EXISTS payouts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
lottery_id UUID NOT NULL REFERENCES lotteries(id),
cycle_id UUID NOT NULL REFERENCES jackpot_cycles(id),
ticket_id UUID NOT NULL REFERENCES tickets(id),
user_id UUID REFERENCES users(id),
lightning_address TEXT NOT NULL,
amount_sats BIGINT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'failed')),
lnbits_payment_id TEXT,
error_message TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Draw logs table (for audit and transparency)
CREATE TABLE IF NOT EXISTS draw_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
cycle_id UUID NOT NULL REFERENCES jackpot_cycles(id),
number_of_tickets INTEGER NOT NULL,
pot_total_sats BIGINT NOT NULL,
fee_percent INTEGER NOT NULL,
pot_after_fee_sats BIGINT NOT NULL,
winner_ticket_id UUID,
winner_lightning_address TEXT,
rng_source TEXT NOT NULL,
selected_index INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_cycles_status_time ON jackpot_cycles(status, scheduled_at);
CREATE INDEX IF NOT EXISTS idx_ticketpurchase_paymenthash ON ticket_purchases(lnbits_payment_hash);
CREATE INDEX IF NOT EXISTS idx_ticketpurchase_cycle ON ticket_purchases(cycle_id);
CREATE INDEX IF NOT EXISTS idx_tickets_cycle ON tickets(cycle_id);
CREATE INDEX IF NOT EXISTS idx_tickets_purchase ON tickets(ticket_purchase_id);
CREATE INDEX IF NOT EXISTS idx_payouts_ticket ON payouts(ticket_id);
CREATE INDEX IF NOT EXISTS idx_payouts_status ON payouts(status);
CREATE INDEX IF NOT EXISTS idx_users_pubkey ON users(nostr_pubkey);
-- Add foreign key constraint for winning ticket (after tickets table exists)
ALTER TABLE jackpot_cycles ADD CONSTRAINT fk_winning_ticket
FOREIGN KEY (winning_ticket_id) REFERENCES tickets(id) ON DELETE SET NULL;
ALTER TABLE ticket_purchases
ADD COLUMN IF NOT EXISTS buyer_name TEXT NOT NULL DEFAULT 'Anon';
-- Insert default lottery
INSERT INTO lotteries (name, status, ticket_price_sats, fee_percent)
VALUES ('Main Lightning Jackpot', 'active', 1000, 5)
ON CONFLICT DO NOTHING;

63
back_end/src/index.ts Normal file
View File

@@ -0,0 +1,63 @@
import app from './app';
import config from './config';
import { db } from './database';
import { startSchedulers } from './scheduler';
import { paymentMonitor } from './services/paymentMonitor';
async function start() {
try {
// Test database connection
console.log('Testing database connection...');
const dbHealthy = await db.healthCheck();
if (!dbHealthy) {
console.error('❌ Database connection failed');
process.exit(1);
}
console.log('✓ Database connected');
// Start HTTP server
app.listen(config.app.port, () => {
console.log(`
╔═══════════════════════════════════════════╗
║ Lightning Lottery API Server ║
║ ║
║ Port: ${config.app.port}
║ Environment: ${config.app.nodeEnv}
║ Base URL: ${config.app.baseUrl}
╚═══════════════════════════════════════════╝
`);
});
// Start schedulers
startSchedulers();
await paymentMonitor.start();
console.log('✓ Schedulers started');
console.log('\n🚀 Lightning Lottery API is ready!\n');
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
// Handle graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully...');
paymentMonitor.stop();
await db.close();
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('SIGINT received, shutting down gracefully...');
paymentMonitor.stop();
await db.close();
process.exit(0);
});
// Start the server
start();

View File

@@ -0,0 +1,86 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import config from '../config';
export interface AuthRequest extends Request {
user?: {
id: string;
nostr_pubkey: string;
};
}
/**
* Middleware to verify JWT token
*/
export const verifyToken = (req: AuthRequest, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
version: '1.0',
error: 'UNAUTHORIZED',
message: 'Missing or invalid authorization header',
});
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, config.jwt.secret) as {
id: string;
nostr_pubkey: string;
};
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({
version: '1.0',
error: 'INVALID_TOKEN',
message: 'Invalid or expired token',
});
}
};
/**
* Middleware to verify admin API key
*/
export const verifyAdmin = (req: Request, res: Response, next: NextFunction) => {
const adminKey = req.headers['x-admin-key'];
if (!adminKey || adminKey !== config.admin.apiKey) {
return res.status(403).json({
version: '1.0',
error: 'FORBIDDEN',
message: 'Invalid admin API key',
});
}
next();
};
/**
* Optional auth - doesn't fail if no token
*/
export const optionalAuth = (req: AuthRequest, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next();
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, config.jwt.secret) as {
id: string;
nostr_pubkey: string;
};
req.user = decoded;
} catch (error) {
// Token invalid but we don't fail
}
next();
};

View File

@@ -0,0 +1,61 @@
import rateLimit from 'express-rate-limit';
/**
* Rate limiter for buy endpoint
* Max 10 calls per IP per minute
*/
export const buyRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10,
message: {
version: '1.0',
error: 'RATE_LIMIT',
message: 'Too many purchase requests, please try again later',
retry_after: 60,
},
standardHeaders: true,
legacyHeaders: false,
// Skip failed requests - don't count them against the limit
skipFailedRequests: true,
// Use IP from request, ignore X-Forwarded-For in development
validate: { xForwardedForHeader: false },
});
/**
* Rate limiter for ticket status endpoint
* Max 60 calls per minute
*/
export const ticketStatusRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60,
message: {
version: '1.0',
error: 'RATE_LIMIT',
message: 'Too many status requests, please try again later',
retry_after: 60,
},
standardHeaders: true,
legacyHeaders: false,
skipFailedRequests: true,
validate: { xForwardedForHeader: false },
});
/**
* General rate limiter
* Max 100 requests per minute
*/
export const generalRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100,
message: {
version: '1.0',
error: 'RATE_LIMIT',
message: 'Too many requests, please try again later',
retry_after: 60,
},
standardHeaders: true,
legacyHeaders: false,
skipFailedRequests: true,
validate: { xForwardedForHeader: false },
});

View File

@@ -0,0 +1,130 @@
import { Router } from 'express';
import {
listCycles,
runDrawManually,
retryPayout,
listPayouts
} from '../controllers/admin';
import { verifyAdmin } from '../middleware/auth';
const router = Router();
// All admin routes require admin key
router.use(verifyAdmin);
/**
* @swagger
* /admin/cycles:
* get:
* summary: List all jackpot cycles
* tags: [Admin]
* security:
* - adminKey: []
* parameters:
* - in: query
* name: status
* schema:
* type: string
* description: Filter by status
* - in: query
* name: cycle_type
* schema:
* type: string
* description: Filter by cycle type
* - in: query
* name: limit
* schema:
* type: integer
* default: 50
* - in: query
* name: offset
* schema:
* type: integer
* default: 0
* responses:
* 200:
* description: List of cycles
* 403:
* description: Invalid admin key
*/
router.get('/cycles', listCycles);
/**
* @swagger
* /admin/cycles/{id}/run-draw:
* post:
* summary: Manually trigger a draw for a cycle
* tags: [Admin]
* security:
* - adminKey: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* format: uuid
* responses:
* 200:
* description: Draw executed successfully
* 400:
* description: Draw failed or invalid cycle
* 403:
* description: Invalid admin key
*/
router.post('/cycles/:id/run-draw', runDrawManually);
/**
* @swagger
* /admin/payouts:
* get:
* summary: List all payouts
* tags: [Admin]
* security:
* - adminKey: []
* parameters:
* - in: query
* name: status
* schema:
* type: string
* description: Filter by status
* - in: query
* name: limit
* schema:
* type: integer
* default: 50
* responses:
* 200:
* description: List of payouts
* 403:
* description: Invalid admin key
*/
router.get('/payouts', listPayouts);
/**
* @swagger
* /admin/payouts/{id}/retry:
* post:
* summary: Retry a failed payout
* tags: [Admin]
* security:
* - adminKey: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* format: uuid
* responses:
* 200:
* description: Payout retry successful
* 400:
* description: Retry failed or invalid payout
* 403:
* description: Invalid admin key
*/
router.post('/payouts/:id/retry', retryPayout);
export default router;

View File

@@ -0,0 +1,191 @@
import { Router } from 'express';
import {
getNextJackpot,
buyTickets,
getTicketStatus,
getPastWins
} from '../controllers/public';
import { buyRateLimiter, ticketStatusRateLimiter } from '../middleware/rateLimit';
import { optionalAuth } from '../middleware/auth';
const router = Router();
/**
* @swagger
* /jackpot/next:
* get:
* summary: Get next upcoming jackpot cycle
* tags: [Public]
* responses:
* 200:
* description: Next jackpot cycle information
* content:
* application/json:
* schema:
* type: object
* properties:
* version:
* type: string
* example: "1.0"
* data:
* type: object
* properties:
* lottery:
* $ref: '#/components/schemas/Lottery'
* cycle:
* $ref: '#/components/schemas/JackpotCycle'
* 503:
* description: No active lottery or cycle available
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get('/jackpot/next', getNextJackpot);
/**
* @swagger
* /jackpot/buy:
* post:
* summary: Purchase lottery tickets
* tags: [Public]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - tickets
* - lightning_address
* properties:
* tickets:
* type: integer
* minimum: 1
* maximum: 100
* description: Number of tickets to purchase
* example: 5
* lightning_address:
* type: string
* description: Lightning Address for receiving payouts
* example: "user@getalby.com"
* nostr_pubkey:
* type: string
* description: Optional Nostr public key
* example: "npub1..."
* responses:
* 200:
* description: Invoice created successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* version:
* type: string
* data:
* type: object
* properties:
* ticket_purchase_id:
* type: string
* format: uuid
* public_url:
* type: string
* invoice:
* type: object
* properties:
* payment_request:
* type: string
* description: BOLT11 invoice
* amount_sats:
* type: integer
* 400:
* description: Invalid input
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post('/jackpot/buy', buyRateLimiter, optionalAuth, buyTickets);
/**
* @swagger
* /jackpot/past-wins:
* get:
* summary: List recent jackpot winners
* tags: [Public]
* parameters:
* - in: query
* name: limit
* schema:
* type: integer
* default: 20
* - in: query
* name: offset
* schema:
* type: integer
* default: 0
* responses:
* 200:
* description: List of completed jackpots and their winners
* 500:
* description: Failed to load past wins
*/
router.get('/jackpot/past-wins', getPastWins);
/**
* @swagger
* /tickets/{id}:
* get:
* summary: Get ticket purchase status
* tags: [Public]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* format: uuid
* description: Ticket purchase ID
* responses:
* 200:
* description: Ticket status retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* version:
* type: string
* data:
* type: object
* properties:
* purchase:
* $ref: '#/components/schemas/TicketPurchase'
* tickets:
* type: array
* items:
* $ref: '#/components/schemas/Ticket'
* cycle:
* $ref: '#/components/schemas/JackpotCycle'
* result:
* type: object
* properties:
* has_drawn:
* type: boolean
* is_winner:
* type: boolean
* payout:
* type: object
* nullable: true
* 404:
* description: Ticket purchase not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get('/tickets/:id', ticketStatusRateLimiter, getTicketStatus);
export default router;

152
back_end/src/routes/user.ts Normal file
View File

@@ -0,0 +1,152 @@
import { Router } from 'express';
import {
nostrAuth,
getProfile,
updateLightningAddress,
getUserTickets,
getUserWins
} from '../controllers/user';
import { verifyToken } from '../middleware/auth';
const router = Router();
/**
* @swagger
* /auth/nostr:
* post:
* summary: Authenticate with Nostr
* tags: [User]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - nostr_pubkey
* - signed_message
* - nonce
* properties:
* nostr_pubkey:
* type: string
* description: Nostr public key (hex or npub)
* signed_message:
* type: string
* description: Signature of the nonce
* nonce:
* type: string
* description: Random nonce for signature verification
* responses:
* 200:
* description: Authentication successful
* content:
* application/json:
* schema:
* type: object
* properties:
* version:
* type: string
* data:
* type: object
* properties:
* token:
* type: string
* description: JWT token
* user:
* type: object
* 400:
* description: Invalid public key or signature
*/
router.post('/auth/nostr', nostrAuth);
/**
* @swagger
* /me:
* get:
* summary: Get user profile
* tags: [User]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: User profile and statistics
* 401:
* description: Unauthorized
*/
router.get('/me', verifyToken, getProfile);
/**
* @swagger
* /me/lightning-address:
* patch:
* summary: Update user's Lightning Address
* tags: [User]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - lightning_address
* properties:
* lightning_address:
* type: string
* example: "user@getalby.com"
* responses:
* 200:
* description: Lightning Address updated
* 400:
* description: Invalid Lightning Address
* 401:
* description: Unauthorized
*/
router.patch('/me/lightning-address', verifyToken, updateLightningAddress);
/**
* @swagger
* /me/tickets:
* get:
* summary: Get user's ticket purchases
* tags: [User]
* security:
* - bearerAuth: []
* parameters:
* - in: query
* name: limit
* schema:
* type: integer
* default: 50
* - in: query
* name: offset
* schema:
* type: integer
* default: 0
* responses:
* 200:
* description: User's ticket purchase history
* 401:
* description: Unauthorized
*/
router.get('/me/tickets', verifyToken, getUserTickets);
/**
* @swagger
* /me/wins:
* get:
* summary: Get user's wins and payouts
* tags: [User]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: User's wins
* 401:
* description: Unauthorized
*/
router.get('/me/wins', verifyToken, getUserWins);
export default router;

View File

@@ -0,0 +1,44 @@
import { Router } from 'express';
import { handleLNbitsPayment } from '../controllers/webhooks';
const router = Router();
/**
* @swagger
* /webhooks/lnbits/payment:
* post:
* summary: LNbits payment webhook callback
* description: LNbits calls this endpoint when a Lightning invoice is paid.
* tags: [Webhooks]
* parameters:
* - in: header
* name: X-Webhook-Secret
* schema:
* type: string
* description: Shared secret configured in LNbits (or supply `secret` query param)
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* payment_hash:
* type: string
* amount:
* type: number
* description: Amount paid in sats
* paid:
* type: boolean
* responses:
* 200:
* description: Webhook processed
* 403:
* description: Invalid secret
* 500:
* description: Internal error processing the webhook
*/
router.post('/lnbits/payment', handleLNbitsPayment);
export default router;

View File

@@ -0,0 +1,199 @@
import cron from 'node-cron';
import { db } from '../database';
import { executeDraw } from '../services/draw';
import { autoRetryFailedPayouts } from '../services/payout';
import config from '../config';
import { JackpotCycle } from '../types';
/**
* Generate future cycles for all cycle types
*/
async function generateFutureCycles(): Promise<void> {
try {
console.log('Running cycle generator...');
// Get active lottery
const lotteryResult = await db.query(
`SELECT * FROM lotteries WHERE status = 'active' LIMIT 1`
);
if (lotteryResult.rows.length === 0) {
console.log('No active lottery found');
return;
}
const lottery = lotteryResult.rows[0];
const cycleTypes: Array<'hourly' | 'daily' | 'weekly' | 'monthly'> = ['hourly', 'daily'];
for (const cycleType of cycleTypes) {
await generateCyclesForType(lottery.id, cycleType);
}
} catch (error) {
console.error('Cycle generation error:', error);
}
}
/**
* Generate cycles for a specific type
*/
async function generateCyclesForType(
lotteryId: string,
cycleType: 'hourly' | 'daily' | 'weekly' | 'monthly'
): Promise<void> {
try {
// Determine horizon (how far in the future to generate)
const horizonHours = cycleType === 'hourly' ? 48 : 168; // 48h for hourly, 1 week for daily
const horizonDate = new Date(Date.now() + horizonHours * 60 * 60 * 1000);
// Get latest cycle for this type
const latestResult = await db.query<JackpotCycle>(
`SELECT * FROM jackpot_cycles
WHERE lottery_id = $1 AND cycle_type = $2
ORDER BY sequence_number DESC
LIMIT 1`,
[lotteryId, cycleType]
);
let lastScheduledAt: Date;
let sequenceNumber: number;
if (latestResult.rows.length === 0) {
// No cycles exist, start from now
lastScheduledAt = new Date();
sequenceNumber = 0;
} else {
const latest = latestResult.rows[0];
lastScheduledAt = new Date(latest.scheduled_at);
sequenceNumber = latest.sequence_number;
}
// Generate cycles until horizon
while (lastScheduledAt < horizonDate) {
sequenceNumber++;
// Calculate next scheduled time
let nextScheduledAt: Date;
switch (cycleType) {
case 'hourly':
nextScheduledAt = new Date(lastScheduledAt.getTime() + 60 * 60 * 1000);
break;
case 'daily':
nextScheduledAt = new Date(lastScheduledAt.getTime() + 24 * 60 * 60 * 1000);
nextScheduledAt.setHours(20, 0, 0, 0); // 8 PM UTC
break;
case 'weekly':
nextScheduledAt = new Date(lastScheduledAt.getTime() + 7 * 24 * 60 * 60 * 1000);
break;
case 'monthly':
nextScheduledAt = new Date(lastScheduledAt);
nextScheduledAt.setMonth(nextScheduledAt.getMonth() + 1);
break;
}
// Sales open immediately, close at draw time
const salesOpenAt = new Date();
const salesCloseAt = nextScheduledAt;
// Check if cycle already exists
const existingResult = await db.query(
`SELECT id FROM jackpot_cycles
WHERE lottery_id = $1 AND cycle_type = $2 AND sequence_number = $3`,
[lotteryId, cycleType, sequenceNumber]
);
if (existingResult.rows.length === 0) {
// Create new cycle with explicit UUID generation
const crypto = require('crypto');
const cycleId = crypto.randomUUID();
await db.query(
`INSERT INTO jackpot_cycles (
id, lottery_id, cycle_type, sequence_number, scheduled_at,
sales_open_at, sales_close_at, status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
cycleId,
lotteryId,
cycleType,
sequenceNumber,
nextScheduledAt.toISOString(),
salesOpenAt.toISOString(),
salesCloseAt.toISOString(),
'scheduled',
]
);
console.log(`Created ${cycleType} cycle #${sequenceNumber} (${cycleId}) for ${nextScheduledAt.toISOString()}`);
}
lastScheduledAt = nextScheduledAt;
}
} catch (error) {
console.error(`Error generating ${cycleType} cycles:`, error);
}
}
/**
* Check for cycles that need to be drawn
*/
async function checkAndExecuteDraws(): Promise<void> {
try {
console.log('Checking for cycles to draw...');
const now = new Date();
// Find cycles that are ready to draw
const result = await db.query<JackpotCycle>(
`SELECT * FROM jackpot_cycles
WHERE status IN ('scheduled', 'sales_open')
AND scheduled_at <= $1
ORDER BY scheduled_at ASC
LIMIT 10`,
[now.toISOString()]
);
console.log(`Found ${result.rows.length} cycles ready for draw`);
for (const cycle of result.rows) {
console.log(`Executing draw for cycle ${cycle.id} (${cycle.cycle_type})`);
await executeDraw(cycle.id);
}
} catch (error) {
console.error('Draw execution scheduler error:', error);
}
}
/**
* Start all schedulers
*/
export function startSchedulers(): void {
console.log('Starting schedulers...');
// Cycle generator - every 5 minutes (or configured interval)
const cycleGenInterval = Math.max(config.scheduler.cycleGeneratorIntervalSeconds, 60);
cron.schedule(`*/${Math.floor(cycleGenInterval / 60)} * * * *`, generateFutureCycles);
console.log(`✓ Cycle generator scheduled (every ${cycleGenInterval}s)`);
// Draw executor - every minute (or configured interval)
const drawInterval = Math.max(config.scheduler.drawIntervalSeconds, 30);
cron.schedule(`*/${Math.floor(drawInterval / 60)} * * * *`, checkAndExecuteDraws);
console.log(`✓ Draw executor scheduled (every ${drawInterval}s)`);
// Payout retry - every 10 minutes
cron.schedule('*/10 * * * *', autoRetryFailedPayouts);
console.log(`✓ Payout retry scheduled (every 10 minutes)`);
// Run immediately on startup
setTimeout(() => {
generateFutureCycles();
checkAndExecuteDraws();
}, 5000);
}

View File

@@ -0,0 +1,443 @@
import crypto from 'crypto';
import { db } from '../database';
import { lnbitsService } from './lnbits';
import { JackpotCycle, Ticket, Payout } from '../types';
import config from '../config';
interface DrawResult {
success: boolean;
error?: string;
message?: string;
winningTicketId?: string;
potAfterFeeSats?: number;
payoutStatus?: string;
}
interface PayoutAttemptResult {
status: 'paid' | 'failed';
retryCount?: number;
}
const RANDOM_INDEX_BYTES = 8;
function pickRandomIndex(length: number): number {
if (length <= 0) {
throw new Error('Cannot pick random index from empty list');
}
const randomBytes = crypto.randomBytes(RANDOM_INDEX_BYTES);
const randomValue = BigInt('0x' + randomBytes.toString('hex'));
return Number(randomValue % BigInt(length));
}
async function attemptImmediatePayout(
client: { query: typeof db.query },
payoutId: string,
lightningAddress: string,
amountSats: number
): Promise<PayoutAttemptResult> {
try {
const paymentResult = await lnbitsService.payLightningAddress(
lightningAddress,
amountSats
);
await client.query(
`UPDATE payouts
SET status = 'paid', lnbits_payment_id = $1, updated_at = NOW()
WHERE id = $2`,
[paymentResult.payment_hash, payoutId]
);
console.log('Payout successful:', {
payout_id: payoutId,
amount_sats: amountSats,
lightning_address: lightningAddress,
});
return { status: 'paid' };
} catch (error: any) {
const updateResult = await client.query(
`UPDATE payouts
SET status = 'failed',
error_message = $1,
retry_count = retry_count + 1,
updated_at = NOW()
WHERE id = $2
RETURNING retry_count`,
[error.message, payoutId]
);
const retryCount = updateResult.rows.length > 0
? parseInt(updateResult.rows[0].retry_count.toString())
: undefined;
console.error('Payout failed:', {
payout_id: payoutId,
error: error.message,
retry_count: retryCount,
});
return { status: 'failed', retryCount };
}
}
/**
* Execute draw for a specific cycle
*/
export async function executeDraw(cycleId: string): Promise<DrawResult> {
const client = await db.getClient();
try {
await client.query('BEGIN');
// Lock the cycle row for update
const cycleResult = await client.query<JackpotCycle>(
`SELECT * FROM jackpot_cycles WHERE id = $1 FOR UPDATE`,
[cycleId]
);
if (cycleResult.rows.length === 0) {
await client.query('ROLLBACK');
return {
success: false,
error: 'CYCLE_NOT_FOUND',
message: 'Cycle not found',
};
}
const cycle = cycleResult.rows[0];
// Check if cycle can be drawn
if (cycle.status === 'completed') {
await client.query('ROLLBACK');
return {
success: false,
error: 'ALREADY_COMPLETED',
message: 'Draw already completed for this cycle',
};
}
if (cycle.status === 'cancelled') {
await client.query('ROLLBACK');
return {
success: false,
error: 'CYCLE_CANCELLED',
message: 'Cannot draw cancelled cycle',
};
}
// Check if it's time to draw
const now = new Date();
if (now < cycle.scheduled_at) {
await client.query('ROLLBACK');
return {
success: false,
error: 'TOO_EARLY',
message: 'Draw time has not arrived yet',
};
}
// Update status to drawing
await client.query(
`UPDATE jackpot_cycles SET status = 'drawing', updated_at = NOW() WHERE id = $1`,
[cycleId]
);
// Get all tickets for this cycle
const ticketsResult = await client.query<Ticket>(
`SELECT * FROM tickets WHERE cycle_id = $1 ORDER BY serial_number`,
[cycleId]
);
const tickets = ticketsResult.rows;
// Handle case with no tickets
if (tickets.length === 0) {
await client.query(
`UPDATE jackpot_cycles
SET status = 'completed', pot_after_fee_sats = 0, updated_at = NOW()
WHERE id = $1`,
[cycleId]
);
// Log draw
const emptyDrawLogId = crypto.randomUUID();
await client.query(
`INSERT INTO draw_logs (
id, cycle_id, number_of_tickets, pot_total_sats, fee_percent,
pot_after_fee_sats, rng_source
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[emptyDrawLogId, cycleId, 0, 0, 0, 0, 'crypto.randomInt']
);
await client.query('COMMIT');
console.log('Draw completed with no tickets:', { cycle_id: cycleId });
return {
success: true,
message: 'Draw completed with no tickets',
potAfterFeeSats: 0,
};
}
// Get lottery for fee percent
const lotteryResult = await client.query(
`SELECT * FROM lotteries WHERE id = $1`,
[cycle.lottery_id]
);
const lottery = lotteryResult.rows[0];
// Calculate pot after fee
const potTotalSats = parseInt(cycle.pot_total_sats.toString());
const feePercent = lottery.fee_percent;
const potAfterFeeSats = Math.floor(potTotalSats * (100 - feePercent) / 100);
// Select random winning ticket via cryptographic randomness
const randomIndex = pickRandomIndex(tickets.length);
const winningTicket = tickets[randomIndex];
console.log('Draw execution:', {
cycle_id: cycleId,
total_tickets: tickets.length,
pot_total_sats: potTotalSats,
pot_after_fee_sats: potAfterFeeSats,
random_index: randomIndex,
winning_ticket_id: winningTicket.id,
winning_serial: winningTicket.serial_number,
});
// Update cycle with winner
await client.query(
`UPDATE jackpot_cycles
SET pot_after_fee_sats = $1,
winning_ticket_id = $2,
winning_lightning_address = $3,
updated_at = NOW()
WHERE id = $4`,
[potAfterFeeSats, winningTicket.id, winningTicket.lightning_address, cycleId]
);
// Create payout record
const payoutId = crypto.randomUUID();
const payoutResult = await client.query<Payout>(
`INSERT INTO payouts (
id, lottery_id, cycle_id, ticket_id, user_id, lightning_address,
amount_sats, status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
payoutId,
cycle.lottery_id,
cycleId,
winningTicket.id,
winningTicket.user_id,
winningTicket.lightning_address,
potAfterFeeSats,
'pending',
]
);
const payout = payoutResult.rows[0];
// Log draw for audit
const drawLogId = crypto.randomUUID();
await client.query(
`INSERT INTO draw_logs (
id, cycle_id, number_of_tickets, pot_total_sats, fee_percent,
pot_after_fee_sats, winner_ticket_id, winner_lightning_address,
rng_source, selected_index
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
drawLogId,
cycleId,
tickets.length,
potTotalSats,
feePercent,
potAfterFeeSats,
winningTicket.id,
winningTicket.lightning_address,
'crypto.randomInt',
randomIndex,
]
);
const payoutAttempt = await attemptImmediatePayout(
client,
payout.id,
winningTicket.lightning_address,
potAfterFeeSats
);
const payoutStatus = payoutAttempt.status;
// Mark cycle as completed
await client.query(
`UPDATE jackpot_cycles SET status = 'completed', updated_at = NOW() WHERE id = $1`,
[cycleId]
);
await client.query('COMMIT');
return {
success: true,
winningTicketId: winningTicket.id,
potAfterFeeSats,
payoutStatus,
};
} catch (error: any) {
await client.query('ROLLBACK');
console.error('Draw execution error:', error);
return {
success: false,
error: 'DRAW_EXECUTION_ERROR',
message: error.message,
};
} finally {
if (client.release) {
client.release();
}
}
}
export async function redrawWinner(cycleId: string): Promise<boolean> {
const client = await db.getClient();
try {
await client.query('BEGIN');
const cycleResult = await client.query<JackpotCycle>(
`SELECT * FROM jackpot_cycles WHERE id = $1`,
[cycleId]
);
if (cycleResult.rows.length === 0) {
await client.query('ROLLBACK');
console.warn('Cannot redraw winner, cycle not found', { cycle_id: cycleId });
return false;
}
const cycle = cycleResult.rows[0];
const candidatesResult = await client.query<Ticket>(
`SELECT * FROM tickets
WHERE cycle_id = $1
AND id NOT IN (
SELECT ticket_id FROM payouts WHERE cycle_id = $2
)
ORDER BY serial_number`,
[cycleId, cycleId]
);
if (candidatesResult.rows.length === 0) {
await client.query('ROLLBACK');
console.warn('No eligible tickets remain for redraw', { cycle_id: cycleId });
return false;
}
const tickets = candidatesResult.rows;
const randomIndex = pickRandomIndex(tickets.length);
const winningTicket = tickets[randomIndex];
let potAfterFeeSats = cycle.pot_after_fee_sats
? parseInt(cycle.pot_after_fee_sats.toString())
: null;
const potTotalSats = parseInt(cycle.pot_total_sats.toString());
if (potAfterFeeSats === null) {
const lotteryResult = await client.query(
`SELECT fee_percent FROM lotteries WHERE id = $1`,
[cycle.lottery_id]
);
const feePercent = lotteryResult.rows.length > 0
? lotteryResult.rows[0].fee_percent
: config.lottery.defaultHouseFeePercent;
potAfterFeeSats = Math.floor(potTotalSats * (100 - feePercent) / 100);
}
await client.query(
`UPDATE jackpot_cycles
SET winning_ticket_id = $1,
winning_lightning_address = $2,
pot_after_fee_sats = $3,
updated_at = NOW()
WHERE id = $4`,
[winningTicket.id, winningTicket.lightning_address, potAfterFeeSats, cycleId]
);
const payoutId = crypto.randomUUID();
const payoutResult = await client.query<Payout>(
`INSERT INTO payouts (
id, lottery_id, cycle_id, ticket_id, user_id, lightning_address,
amount_sats, status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
payoutId,
cycle.lottery_id,
cycleId,
winningTicket.id,
winningTicket.user_id,
winningTicket.lightning_address,
potAfterFeeSats,
'pending',
]
);
const payout = payoutResult.rows[0];
const drawLogId = crypto.randomUUID();
await client.query(
`INSERT INTO draw_logs (
id, cycle_id, number_of_tickets, pot_total_sats,
pot_after_fee_sats, winner_ticket_id, winner_lightning_address,
rng_source, selected_index
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
drawLogId,
cycleId,
tickets.length,
potTotalSats,
potAfterFeeSats,
winningTicket.id,
winningTicket.lightning_address,
'redraw',
randomIndex,
]
);
const attempt = await attemptImmediatePayout(
client,
payout.id,
winningTicket.lightning_address,
potAfterFeeSats
);
await client.query('COMMIT');
console.log('Winner redrawn for cycle', {
cycle_id: cycleId,
new_winner_ticket_id: winningTicket.id,
payout_status: attempt.status,
});
return true;
} catch (error) {
await client.query('ROLLBACK');
console.error('Redraw winner error:', error);
return false;
} finally {
if (client.release) {
client.release();
}
}
}

View File

@@ -0,0 +1,156 @@
import axios, { AxiosInstance } from 'axios';
import config from '../config';
interface CreateInvoiceParams {
amount: number; // sats
memo: string;
webhook?: string;
}
interface Invoice {
payment_hash: string;
payment_request: string;
checking_id: string;
}
interface PayoutParams {
bolt11?: string;
description?: string;
out?: boolean;
}
interface PaymentResult {
payment_hash: string;
checking_id: string;
paid?: boolean;
}
class LNbitsService {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: config.lnbits.apiBaseUrl,
headers: {
'X-Api-Key': config.lnbits.adminKey,
'Content-Type': 'application/json',
},
timeout: 30000,
});
}
/**
* Create a Lightning invoice
*/
async createInvoice(params: CreateInvoiceParams): Promise<Invoice> {
try {
const response = await this.client.post('/api/v1/payments', {
out: false,
amount: params.amount,
memo: params.memo,
webhook: params.webhook,
unit: 'sat',
});
return {
payment_hash: response.data.payment_hash,
payment_request: response.data.payment_request,
checking_id: response.data.checking_id,
};
} catch (error: any) {
console.error('LNbits create invoice error:', error.response?.data || error.message);
throw new Error(`Failed to create invoice: ${error.response?.data?.detail || error.message}`);
}
}
/**
* Pay a Lightning Address or BOLT11 invoice
*/
async payLightningAddress(lightningAddress: string, amountSats: number): Promise<PaymentResult> {
try {
// First resolve the Lightning Address to get LNURL
const [username, domain] = lightningAddress.split('@');
if (!username || !domain) {
throw new Error('Invalid Lightning Address format');
}
// Fetch LNURL pay metadata
const lnurlResponse = await axios.get(
`https://${domain}/.well-known/lnurlp/${username}`
);
const { callback, minSendable, maxSendable } = lnurlResponse.data;
const amountMsats = amountSats * 1000;
if (amountMsats < minSendable || amountMsats > maxSendable) {
throw new Error('Amount out of range for this Lightning Address');
}
// Get invoice from callback
const invoiceResponse = await axios.get(callback, {
params: { amount: amountMsats },
});
const bolt11 = invoiceResponse.data.pr;
if (!bolt11) {
throw new Error('Failed to get invoice from Lightning Address');
}
// Pay the invoice using LNbits
const paymentResponse = await this.client.post('/api/v1/payments', {
out: true,
bolt11: bolt11,
});
return {
payment_hash: paymentResponse.data.payment_hash,
checking_id: paymentResponse.data.checking_id,
paid: true,
};
} catch (error: any) {
console.error('LNbits payout error:', error.response?.data || error.message);
throw new Error(`Failed to pay Lightning Address: ${error.response?.data?.detail || error.message}`);
}
}
/**
* Check payment status
*/
async checkPaymentStatus(paymentHash: string): Promise<{ paid: boolean }> {
try {
const response = await this.client.get(`/api/v1/payments/${paymentHash}`);
return {
paid: response.data.paid || false,
};
} catch (error: any) {
console.error('LNbits check payment error:', error.response?.data || error.message);
throw new Error(`Failed to check payment status: ${error.message}`);
}
}
/**
* Verify webhook signature/secret
*/
verifyWebhook(secret: string): boolean {
return secret === config.lnbits.webhookSecret;
}
/**
* Health check for LNbits connectivity
*/
async healthCheck(): Promise<boolean> {
try {
await this.client.get('/api/v1/wallet');
return true;
} catch (error) {
console.error('LNbits health check failed:', error);
return false;
}
}
}
export const lnbitsService = new LNbitsService();
export default lnbitsService;

View File

@@ -0,0 +1,130 @@
import { db } from '../database';
import { lnbitsService } from './lnbits';
import { finalizeTicketPurchase } from './paymentProcessor';
interface PendingInvoice {
purchaseId: string;
paymentHash: string;
addedAt: number;
}
const POLL_INTERVAL_MS = 3000;
const EXPIRY_MS = 20 * 60 * 1000;
class PaymentMonitor {
private pendingInvoices = new Map<string, PendingInvoice>();
private checkInterval: NodeJS.Timeout | null = null;
private isChecking = false;
async start(): Promise<void> {
if (this.checkInterval) {
console.log('Payment monitor already running');
return;
}
await this.bootstrapPendingInvoices();
console.log('✓ Payment monitor started (checking every 3 seconds)');
// Immediate check, then schedule interval
this.checkPendingPayments();
this.checkInterval = setInterval(() => {
this.checkPendingPayments();
}, POLL_INTERVAL_MS);
}
stop(): void {
if (this.checkInterval) {
clearInterval(this.checkInterval);
this.checkInterval = null;
console.log('Payment monitor stopped');
}
}
addInvoice(purchaseId: string, paymentHash?: string): void {
if (!purchaseId || !paymentHash) {
return;
}
this.pendingInvoices.set(purchaseId, {
purchaseId,
paymentHash,
addedAt: Date.now(),
});
console.log(`📋 Monitoring payment: ${purchaseId} (${this.pendingInvoices.size} pending)`);
}
removeInvoice(purchaseId: string): void {
this.pendingInvoices.delete(purchaseId);
}
getStats() {
return {
pending: this.pendingInvoices.size,
checking: this.isChecking,
};
}
private async bootstrapPendingInvoices(): Promise<void> {
const pendingResult = await db.query<{ id: string; lnbits_payment_hash: string }>(
`SELECT id, lnbits_payment_hash
FROM ticket_purchases
WHERE invoice_status = 'pending'
AND lnbits_payment_hash IS NOT NULL
AND lnbits_payment_hash <> ''`
);
pendingResult.rows.forEach(row => {
this.pendingInvoices.set(row.id, {
purchaseId: row.id,
paymentHash: row.lnbits_payment_hash,
addedAt: Date.now(),
});
});
if (pendingResult.rows.length > 0) {
console.log(`📎 Restored ${pendingResult.rows.length} pending invoice(s) into monitor`);
}
}
private async checkPendingPayments(): Promise<void> {
if (this.isChecking || this.pendingInvoices.size === 0) {
return;
}
this.isChecking = true;
try {
const now = Date.now();
const invoices = Array.from(this.pendingInvoices.values());
for (const invoice of invoices) {
if (now - invoice.addedAt > EXPIRY_MS) {
console.log(`⏰ Invoice expired: ${invoice.purchaseId}`);
this.pendingInvoices.delete(invoice.purchaseId);
continue;
}
try {
const status = await lnbitsService.checkPaymentStatus(invoice.paymentHash);
if (status.paid) {
console.log(`💰 Payment detected via monitor: ${invoice.purchaseId}`);
await finalizeTicketPurchase(invoice.purchaseId);
this.pendingInvoices.delete(invoice.purchaseId);
}
} catch (error: any) {
console.error(`Error checking payment ${invoice.purchaseId}:`, error?.message || error);
}
}
} catch (error) {
console.error('Payment monitor error:', error);
} finally {
this.isChecking = false;
}
}
}
export const paymentMonitor = new PaymentMonitor();
export default paymentMonitor;

View File

@@ -0,0 +1,129 @@
import crypto from 'crypto';
import { db } from '../database';
import { TicketPurchase } from '../types';
export type FinalizeStatus = 'success' | 'already_paid' | 'not_found';
export interface FinalizeResult {
status: FinalizeStatus;
purchaseId?: string;
ticketsIssued?: number;
}
const SERIAL_MIN = 1;
const SERIAL_MAX = 1_000_000_000; // 1 billion possible ticket numbers
function createSerialNumberGenerator(existingSerials: Set<number>) {
return () => {
for (let attempts = 0; attempts < 2000; attempts++) {
const candidate = crypto.randomInt(SERIAL_MIN, SERIAL_MAX);
if (!existingSerials.has(candidate)) {
existingSerials.add(candidate);
return candidate;
}
}
throw new Error('Unable to generate unique ticket number');
};
}
export async function finalizeTicketPurchase(purchaseId: string): Promise<FinalizeResult> {
const client = await db.getClient();
try {
await client.query('BEGIN');
const purchaseResult = await client.query<TicketPurchase>(
`SELECT * FROM ticket_purchases WHERE id = $1`,
[purchaseId]
);
if (purchaseResult.rows.length === 0) {
await client.query('ROLLBACK');
return { status: 'not_found' };
}
const purchase = purchaseResult.rows[0];
if (purchase.invoice_status === 'paid') {
await client.query('ROLLBACK');
return { status: 'already_paid', purchaseId: purchase.id, ticketsIssued: purchase.number_of_tickets };
}
await client.query(
`UPDATE ticket_purchases
SET invoice_status = 'paid', updated_at = NOW()
WHERE id = $1`,
[purchase.id]
);
let ticketsIssued = 0;
if (purchase.ticket_issue_status === 'not_issued') {
const existingSerialsResult = await client.query(
`SELECT serial_number FROM tickets WHERE cycle_id = $1`,
[purchase.cycle_id]
);
const usedSerials = new Set<number>(
existingSerialsResult.rows.map((row: any) =>
parseInt(row.serial_number?.toString() ?? '0', 10)
)
);
const generateSerialNumber = createSerialNumberGenerator(usedSerials);
for (let i = 0; i < purchase.number_of_tickets; i++) {
const serialNumber = generateSerialNumber();
ticketsIssued++;
await client.query(
`INSERT INTO tickets (
id, lottery_id, cycle_id, ticket_purchase_id, user_id,
lightning_address, serial_number
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
crypto.randomUUID(),
purchase.lottery_id,
purchase.cycle_id,
purchase.id,
purchase.user_id,
purchase.lightning_address,
serialNumber,
]
);
}
await client.query(
`UPDATE ticket_purchases
SET ticket_issue_status = 'issued', updated_at = NOW()
WHERE id = $1`,
[purchase.id]
);
await client.query(
`UPDATE jackpot_cycles
SET pot_total_sats = pot_total_sats + $1, updated_at = NOW()
WHERE id = $2`,
[purchase.amount_sats, purchase.cycle_id]
);
}
await client.query('COMMIT');
return {
status: 'success',
purchaseId: purchase.id,
ticketsIssued: ticketsIssued || purchase.number_of_tickets,
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
if (client.release) {
client.release();
}
}
}

View File

@@ -0,0 +1,176 @@
import { db } from '../database';
import { lnbitsService } from './lnbits';
import { Payout } from '../types';
import config from '../config';
import { redrawWinner } from './draw';
interface PayoutRetryResult {
success: boolean;
error?: string;
message?: string;
status?: string;
retryCount?: number;
}
const MAX_RETRY_ATTEMPTS = config.payout.maxAttemptsBeforeRedraw;
/**
* Retry a failed payout
*/
export async function retryPayoutService(payoutId: string): Promise<PayoutRetryResult> {
try {
// Get payout
const payoutResult = await db.query<Payout>(
`SELECT * FROM payouts WHERE id = $1`,
[payoutId]
);
if (payoutResult.rows.length === 0) {
return {
success: false,
error: 'PAYOUT_NOT_FOUND',
message: 'Payout not found',
};
}
const payout = payoutResult.rows[0];
// Check if payout is in failed status
if (payout.status !== 'failed') {
return {
success: false,
error: 'INVALID_STATUS',
message: `Cannot retry payout with status: ${payout.status}`,
};
}
// Check retry limit
if (payout.retry_count >= MAX_RETRY_ATTEMPTS) {
return {
success: false,
error: 'MAX_RETRIES_EXCEEDED',
message: `Maximum retry attempts (${MAX_RETRY_ATTEMPTS}) exceeded`,
};
}
const amountSats = parseInt(payout.amount_sats.toString());
console.log('Retrying payout:', {
payout_id: payoutId,
attempt: payout.retry_count + 1,
amount_sats: amountSats,
lightning_address: payout.lightning_address,
});
// Attempt payout
try {
const paymentResult = await lnbitsService.payLightningAddress(
payout.lightning_address,
amountSats
);
// Update payout as paid
await db.query(
`UPDATE payouts
SET status = 'paid',
lnbits_payment_id = $1,
retry_count = retry_count + 1,
updated_at = NOW()
WHERE id = $2`,
[paymentResult.payment_hash, payoutId]
);
console.log('Payout retry successful:', {
payout_id: payoutId,
payment_hash: paymentResult.payment_hash,
});
return {
success: true,
status: 'paid',
retryCount: payout.retry_count + 1,
};
} catch (payoutError: any) {
// Update error message and increment retry count
const updateResult = await db.query(
`UPDATE payouts
SET error_message = $1,
retry_count = retry_count + 1,
updated_at = NOW()
WHERE id = $2
RETURNING retry_count`,
[payoutError.message, payoutId]
);
const newRetryCount = updateResult.rows.length > 0
? parseInt(updateResult.rows[0].retry_count.toString())
: payout.retry_count + 1;
console.error('Payout retry failed:', {
payout_id: payoutId,
error: payoutError.message,
retry_count: newRetryCount,
});
if (newRetryCount >= MAX_RETRY_ATTEMPTS) {
console.warn('Max payout attempts reached. Drawing new winner.', {
cycle_id: payout.cycle_id,
payout_id: payoutId,
});
await redrawWinner(payout.cycle_id);
}
return {
success: false,
error: 'PAYOUT_FAILED',
message: payoutError.message,
status: 'failed',
retryCount: newRetryCount,
};
}
} catch (error: any) {
console.error('Retry payout error:', error);
return {
success: false,
error: 'INTERNAL_ERROR',
message: error.message,
};
}
}
/**
* Automatically retry all failed payouts that haven't exceeded max retries
*/
export async function autoRetryFailedPayouts(): Promise<void> {
try {
const result = await db.query<Payout>(
`SELECT * FROM payouts
WHERE status = 'failed'
AND retry_count < $1
ORDER BY created_at ASC
LIMIT 10`,
[MAX_RETRY_ATTEMPTS]
);
console.log(`Found ${result.rows.length} failed payouts to retry`);
for (const payout of result.rows) {
// Add delay between retries (exponential backoff)
const delaySeconds = Math.pow(2, payout.retry_count) * 10;
const timeSinceLastUpdate = Date.now() - payout.updated_at.getTime();
if (timeSinceLastUpdate < delaySeconds * 1000) {
console.log(`Skipping payout ${payout.id} - waiting for backoff period`);
continue;
}
await retryPayoutService(payout.id);
}
} catch (error) {
console.error('Auto retry failed payouts error:', error);
}
}

108
back_end/src/types/index.ts Normal file
View File

@@ -0,0 +1,108 @@
export interface Lottery {
id: string;
name: string;
status: 'active' | 'paused' | 'finished';
ticket_price_sats: number;
fee_percent: number;
created_at: Date;
updated_at: Date;
}
export interface JackpotCycle {
id: string;
lottery_id: string;
cycle_type: 'hourly' | 'daily' | 'weekly' | 'monthly';
sequence_number: number;
scheduled_at: Date;
sales_open_at: Date;
sales_close_at: Date;
status: 'scheduled' | 'sales_open' | 'drawing' | 'completed' | 'cancelled';
pot_total_sats: number;
pot_after_fee_sats: number | null;
winning_ticket_id: string | null;
winning_lightning_address: string | null;
created_at: Date;
updated_at: Date;
}
export interface TicketPurchase {
id: string;
lottery_id: string;
cycle_id: string;
user_id: string | null;
nostr_pubkey: string | null;
lightning_address: string;
buyer_name: string;
number_of_tickets: number;
ticket_price_sats: number;
amount_sats: number;
lnbits_invoice_id: string;
lnbits_payment_hash: string;
invoice_status: 'pending' | 'paid' | 'expired' | 'cancelled';
ticket_issue_status: 'not_issued' | 'issued';
created_at: Date;
updated_at: Date;
}
export interface Ticket {
id: string;
lottery_id: string;
cycle_id: string;
ticket_purchase_id: string;
user_id: string | null;
lightning_address: string;
serial_number: number;
created_at: Date;
}
export interface Payout {
id: string;
lottery_id: string;
cycle_id: string;
ticket_id: string;
user_id: string | null;
lightning_address: string;
amount_sats: number;
status: 'pending' | 'paid' | 'failed';
lnbits_payment_id: string | null;
error_message: string | null;
retry_count: number;
created_at: Date;
updated_at: Date;
}
export interface User {
id: string;
nostr_pubkey: string;
display_name: string | null;
lightning_address: string | null;
created_at: Date;
updated_at: Date;
}
export interface DrawLog {
id: string;
cycle_id: string;
number_of_tickets: number;
pot_total_sats: number;
fee_percent: number;
pot_after_fee_sats: number;
winner_ticket_id: string | null;
winner_lightning_address: string | null;
rng_source: string;
selected_index: number | null;
created_at: Date;
}
export interface ApiError {
error: string;
message: string;
}
export interface ApiResponse<T = any> {
version: string;
data?: T;
error?: string;
message?: string;
}

View File

@@ -0,0 +1,70 @@
/**
* Validates a Lightning Address format
*/
export function validateLightningAddress(address: string): boolean {
if (!address || typeof address !== 'string') {
return false;
}
// Must contain @ symbol
if (!address.includes('@')) {
return false;
}
// Basic email-like format check
const parts = address.split('@');
if (parts.length !== 2) {
return false;
}
const [username, domain] = parts;
// Username and domain must not be empty
if (!username || !domain) {
return false;
}
// Length check
if (address.length > 255) {
return false;
}
return true;
}
/**
* Validates Nostr public key (hex or npub format)
*/
export function validateNostrPubkey(pubkey: string): boolean {
if (!pubkey || typeof pubkey !== 'string') {
return false;
}
// Hex format: 64 characters
if (/^[0-9a-f]{64}$/i.test(pubkey)) {
return true;
}
// npub format (bech32)
if (pubkey.startsWith('npub1') && pubkey.length === 63) {
return true;
}
return false;
}
/**
* Validates number of tickets
*/
export function validateTicketCount(count: number, max: number = 100): boolean {
return Number.isInteger(count) && count > 0 && count <= max;
}
/**
* Sanitizes input string
*/
export function sanitizeString(input: string, maxLength: number = 255): string {
if (!input) return '';
return input.trim().substring(0, maxLength);
}