- 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
587 lines
17 KiB
TypeScript
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',
|
|
});
|
|
}
|
|
}
|
|
|