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