Files
LightningLotto/back_end/src/controllers/public.ts
Michilis 3bc067f691 feat: display truncated lightning address for winners
- Add truncateLightningAddress utility (shows first 2 chars + ******)
- Backend: Include winner_address in past-wins API response
- Frontend: Display truncated address in past winners list
- Telegram: Add truncated address to draw announcements for transparency

Example: username@blink.sv -> us******@blink.sv
2025-12-12 16:20:18 +00:00

587 lines
17 KiB
TypeScript

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';
/**
* GET /status/maintenance
* Public endpoint to check maintenance mode
*/
export async function getPublicMaintenanceStatus(req: Request, res: Response) {
try {
const result = await db.query(
`SELECT value FROM system_settings WHERE key = 'maintenance_mode'`
);
const pendingResult = await db.query(
`SELECT value FROM system_settings WHERE key = 'maintenance_pending'`
);
const messageResult = await db.query(
`SELECT value FROM system_settings WHERE key = 'maintenance_message'`
);
const enabled = result.rows[0]?.value === 'true';
const pending = pendingResult.rows[0]?.value === 'true';
const message = messageResult.rows[0]?.value || 'System is under maintenance. Please try again later.';
return res.json({
version: '1.0',
data: {
maintenance_mode: enabled,
maintenance_pending: pending,
message: enabled ? message : (pending ? 'Maintenance will begin after the current draw completes.' : null),
},
});
} catch (error: any) {
// If table doesn't exist yet, return not in maintenance
return res.json({
version: '1.0',
data: {
maintenance_mode: false,
maintenance_pending: false,
message: null,
},
});
}
}
/**
* Helper to check if system is in maintenance mode
*/
async function isMaintenanceMode(): Promise<{ enabled: boolean; message: string }> {
try {
const result = await db.query(
`SELECT value FROM system_settings WHERE key = 'maintenance_mode'`
);
const messageResult = await db.query(
`SELECT value FROM system_settings WHERE key = 'maintenance_message'`
);
return {
enabled: result.rows[0]?.value === 'true',
message: messageResult.rows[0]?.value || 'System is under maintenance. Please try again later.',
};
} catch {
return { enabled: false, message: '' };
}
}
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 - first try to find one that hasn't drawn yet
let cycleResult = await db.query<JackpotCycle>(
`SELECT * FROM jackpot_cycles
WHERE lottery_id = $1
AND status IN ('scheduled', 'sales_open', 'drawing')
ORDER BY scheduled_at ASC
LIMIT 1`,
[lottery.id]
);
// If no active cycles, get the next upcoming one
if (cycleResult.rows.length === 0) {
cycleResult = await db.query<JackpotCycle>(
`SELECT * FROM jackpot_cycles
WHERE lottery_id = $1
AND status = 'scheduled'
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 {
// Check maintenance mode first
const maintenance = await isMaintenanceMode();
if (maintenance.enabled) {
return res.status(503).json({
version: '1.0',
error: 'MAINTENANCE_MODE',
message: maintenance.message,
});
}
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;
winning_lightning_address: string | null;
}
/**
* Truncate lightning address for privacy
* "username@blink.sv" -> "us******@blink.sv"
*/
function truncateLightningAddress(address: string | null): string | null {
if (!address || !address.includes('@')) return address;
const [username, domain] = address.split('@');
// Show first 2 chars of username, then asterisks
const visibleChars = Math.min(2, username.length);
const truncatedUsername = username.substring(0, visibleChars) + '******';
return `${truncatedUsername}@${domain}`;
}
/**
* 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,
jc.winning_lightning_address,
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',
winner_address: truncateLightningAddress(row.winning_lightning_address),
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',
});
}
}