The seed was the hash of the first block seen after the close, so a miner holding tickets
could withhold blocks until one made them win, the active Electrum server could pick which
real block was used by not announcing another (a failed corroboration even moved the draw to
the next block), and the participant order was the server-only `broadcast_at` with no
tie-break, so nobody could recompute the result.
- Open: every round gets a 32-byte `server_seed`, stored only Fernet-encrypted with the
master-xprv key (`server_seed_enc`), with its published `server_seed_hash`;
`draw_confirmations` is copied from round_config. The plaintext column is written only by
the reveal.
- Entering `drawing` (one transaction, `draw_committed` audit row): participants ordered by
bet txid, each `draw_position`, `participants_hash` (sha256 of `txid:vout:address\n`
lines; new `bet_vout`), `draw_height = tip_at_close + 1`. A tip of 0 keeps the round
`closing` (`draw_baseline_tip_unknown`, once per round). All persisted, so a resumed draw
reads rather than recomputes (the `_tick` branch itself is B-81).
- `_complete_draw` / `_wait_for_draw_block` replace `_wait_for_next_block`: wait for
`draw_confirmations` blocks from `draw_height`, then `ElectrumListener.agreed_block_hash`
asks every server, the active one included, for the header at that exact height (valid
work, >= 2 other answers, strict majority of all); no agreement means the same height is
asked again and `draw_header_corroboration_failed` is written once per distinct reported
hash. `draw_stalled` now counts from the last tip advance, or from when the quorum started
to fail. Seed = sha256(server_seed || block_hash || participants_hash), index = seed mod n.
- The winner, the seed and the reveal (`server_seed`, `winner_drawn` payload) are written in
one transaction; a round closing with no participants reveals too. A round opened before
this change (no commitment) is given a secret as it enters `drawing`.
- `GET /rounds/{id}/proof` (public, 404 `round_not_found`, translated in all 7 languages) and
the new fields on `GET /rounds/current`; `draw_confirmations` in `/admin/config` and the
/admin form; `server_seed_hash`/`draw_height` in `/admin/rounds`, never the secret.
- Alembic d3e8b15a7c42 (plain add_column / create_index, expression indexes untouched).
- Docs: the B-73 `*Stato:*`/`*Oggi:*` lines are gone, guides and CLAUDE.md describe the new
draw. Ambiguities resolved in api.md: the proof's participants also carry `amount_sats`
(RND-37 lists it); `seed` is decimal in the proof and hex in the audit row, as specified.
Tests changed because the spec changed: ElectrumListener.corroborate_header (asked only the
other servers about the block the active one announced) is replaced by agreed_block_hash and
its quorum tests ported; draw_winner takes the secret and the list fingerprint; the four
_wait_for_next_block scheduler tests are replaced by _wait_for_draw_block ones (fixed height,
confirmations, same-height retry, stall clock). New: test_draw_proof.py recomputes the winner
independently from the public proof, checks the plaintext secret is nowhere in the database,
the API or the log before the reveal, and that a second pass over a `drawing` round yields
the same winner. Mutation-checked: ordering by broadcast_at, an early reveal and asking the
tip instead of the draw height each fail tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
451 lines
17 KiB
JavaScript
451 lines
17 KiB
JavaScript
const SATS_PER_PLM = 100000000;
|
|
|
|
// Display formatter: a raw sats/SATS_PER_PLM division renders binary
|
|
// floating-point artefacts (0.7000000000000001) in the tables below (B-22).
|
|
// Input fields keep the raw value — they have to stay parseable.
|
|
function fmtPlm(sats) {
|
|
if (sats === null || sats === undefined) return '—';
|
|
return new Intl.NumberFormat('it-IT', { maximumFractionDigits: 8 }).format(sats / SATS_PER_PLM);
|
|
}
|
|
let adminToken = sessionStorage.getItem('plm_admin_token');
|
|
|
|
function toast(message, type) {
|
|
const container = document.getElementById('toast-container');
|
|
const el = document.createElement('div');
|
|
el.className = 'toast ' + type;
|
|
el.textContent = message;
|
|
container.appendChild(el);
|
|
setTimeout(() => el.remove(), 4000);
|
|
}
|
|
|
|
async function withLoading(button, label, fn) {
|
|
const original = button.textContent;
|
|
button.disabled = true;
|
|
button.textContent = label;
|
|
try {
|
|
await fn();
|
|
} finally {
|
|
button.disabled = false;
|
|
button.textContent = original;
|
|
}
|
|
}
|
|
|
|
async function callAdmin(method, path, body) {
|
|
const headers = { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken };
|
|
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
|
const data = await res.json().catch(() => ({}));
|
|
// detail is a bare string on the admin endpoints, but the shared dependencies
|
|
// (auth) answer with the structured {code, message} form of app/api/errors.py.
|
|
if (!res.ok) throw new Error(data.detail?.message || data.detail || res.statusText);
|
|
return data;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
}
|
|
|
|
function badge(status) {
|
|
return `<span class="badge status-${escapeHtml(status)}">${escapeHtml(status)}</span>`;
|
|
}
|
|
|
|
function fmtDate(iso) {
|
|
if (!iso) return '—';
|
|
return new Date(iso).toLocaleString('it-IT');
|
|
}
|
|
|
|
const DRAWING_STATUSES = ['closing', 'drawing', 'paying_out'];
|
|
const CHAIN_STATUS_LABELS = {
|
|
waiting: 'In attesa del prossimo round',
|
|
open: 'Round aperto',
|
|
drawing: 'Estrazione in corso',
|
|
};
|
|
let chainStatusInterval = null;
|
|
|
|
async function refreshChainStatus() {
|
|
try {
|
|
const res = await fetch('/rounds/current');
|
|
const data = await res.json();
|
|
let statusKey;
|
|
if (!data.round_id) statusKey = 'waiting';
|
|
else if (DRAWING_STATUSES.includes(data.status)) statusKey = 'drawing';
|
|
else statusKey = 'open';
|
|
document.getElementById('chain-status-dot').className = 'status-dot status-' + statusKey;
|
|
document.getElementById('chain-status-label').textContent = CHAIN_STATUS_LABELS[statusKey];
|
|
document.getElementById('chain-block').textContent =
|
|
'Blocco ' + (data.chain_tip_height != null ? '#' + data.chain_tip_height : '—');
|
|
} catch (e) {
|
|
// leave the last-known status on screen rather than blanking it out
|
|
}
|
|
}
|
|
|
|
function startChainStatusPolling() {
|
|
refreshChainStatus();
|
|
clearInterval(chainStatusInterval);
|
|
chainStatusInterval = setInterval(refreshChainStatus, 15000);
|
|
}
|
|
|
|
function stopChainStatusPolling() {
|
|
clearInterval(chainStatusInterval);
|
|
chainStatusInterval = null;
|
|
}
|
|
|
|
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit', 'bugreports'];
|
|
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog, bugreports: loadBugReports };
|
|
let currentAdminView = 'parametri';
|
|
|
|
function switchView(name) {
|
|
currentAdminView = name;
|
|
for (const key of VIEWS) {
|
|
document.getElementById('nav-' + key).classList.toggle('active', key === name);
|
|
document.getElementById('view-' + key).classList.toggle('active', key === name);
|
|
}
|
|
if (VIEW_LOADERS[name]) VIEW_LOADERS[name]();
|
|
}
|
|
|
|
function showDashboard() {
|
|
document.getElementById('login-section').classList.add('hidden');
|
|
document.getElementById('dashboard-section').classList.remove('hidden');
|
|
startChainStatusPolling();
|
|
}
|
|
|
|
async function loadDashboard() {
|
|
await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog(), loadBugReports()]);
|
|
}
|
|
|
|
async function adminLogin() {
|
|
const btn = document.getElementById('login-btn');
|
|
adminToken = document.getElementById('admin-token').value;
|
|
await withLoading(btn, 'Verifica…', async () => {
|
|
try {
|
|
await callAdmin('GET', '/admin/config');
|
|
sessionStorage.setItem('plm_admin_token', adminToken);
|
|
showDashboard();
|
|
await loadDashboard();
|
|
} catch (e) {
|
|
adminToken = null;
|
|
toast('Token non valido.', 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
function adminLogout() {
|
|
stopChainStatusPolling();
|
|
sessionStorage.removeItem('plm_admin_token');
|
|
adminToken = null;
|
|
document.getElementById('admin-token').value = '';
|
|
document.getElementById('dashboard-section').classList.add('hidden');
|
|
document.getElementById('login-section').classList.remove('hidden');
|
|
}
|
|
|
|
async function adminLoadConfig() {
|
|
try {
|
|
const data = await callAdmin('GET', '/admin/config');
|
|
document.getElementById('admin-fee-address').value = data.fee_address;
|
|
// B-66: an empty fee address blocks every future round, so say so here rather
|
|
// than leaving an empty field to be noticed.
|
|
document
|
|
.getElementById('admin-fee-address-warning')
|
|
.classList.toggle('hidden', !!(data.fee_address || '').trim());
|
|
document.getElementById('admin-bet-amount').value = data.bet_amount_sats / SATS_PER_PLM;
|
|
document.getElementById('admin-round-duration').value = data.round_duration_seconds;
|
|
document.getElementById('admin-round-cooldown').value = data.round_cooldown_seconds;
|
|
document.getElementById('admin-draw-animation').value = data.draw_animation_seconds;
|
|
document.getElementById('admin-draw-confirmations').value = data.draw_confirmations;
|
|
document.getElementById('admin-fee-rate').value = data.fee_rate_sat_vb;
|
|
document.getElementById('admin-rbf-timeout').value = data.rbf_timeout_seconds;
|
|
renderMaintenanceState(data.paused);
|
|
} catch (e) {
|
|
toast('Errore nel caricamento configurazione: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderMaintenanceState(paused) {
|
|
const dot = document.getElementById('maintenance-dot');
|
|
const label = document.getElementById('maintenance-status-label');
|
|
const btn = document.getElementById('maintenance-btn');
|
|
btn.dataset.paused = paused ? '1' : '0';
|
|
if (paused) {
|
|
dot.className = 'status-dot status-paused';
|
|
label.textContent = 'In pausa: nessun nuovo round verrà aperto';
|
|
btn.textContent = 'Riprendi lotteria';
|
|
btn.classList.remove('btn-stop');
|
|
} else {
|
|
dot.className = 'status-dot status-open';
|
|
label.textContent = 'Attiva: i round si susseguono normalmente';
|
|
btn.textContent = 'Interrompi dopo questo round';
|
|
btn.classList.add('btn-stop');
|
|
}
|
|
}
|
|
|
|
async function toggleMaintenance() {
|
|
const btn = document.getElementById('maintenance-btn');
|
|
const isPaused = btn.dataset.paused === '1';
|
|
const path = isPaused ? '/admin/resume' : '/admin/pause';
|
|
if (!isPaused && !window.confirm(
|
|
"Nessun nuovo round verrà aperto dopo quello in corso, fino a quando non riprendi la lotteria. " +
|
|
"Il round attuale (se presente) verrà comunque completato e il vincitore pagato. Continuare?"
|
|
)) {
|
|
return;
|
|
}
|
|
btn.disabled = true;
|
|
try {
|
|
const data = await callAdmin('POST', path, {});
|
|
renderMaintenanceState(data.paused);
|
|
toast(data.paused ? 'Lotteria in pausa.' : 'Lotteria ripresa.', 'success');
|
|
refreshChainStatus();
|
|
} catch (e) {
|
|
toast('Errore: ' + e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function adminSave() {
|
|
const btn = document.getElementById('save-btn');
|
|
const feeAddress = document.getElementById('admin-fee-address').value;
|
|
const betAmountPlm = parseFloat(document.getElementById('admin-bet-amount').value);
|
|
const body = {
|
|
fee_address: feeAddress,
|
|
bet_amount_sats: Math.round(betAmountPlm * SATS_PER_PLM),
|
|
round_duration_seconds: parseInt(document.getElementById('admin-round-duration').value, 10),
|
|
round_cooldown_seconds: parseInt(document.getElementById('admin-round-cooldown').value, 10),
|
|
draw_animation_seconds: parseInt(document.getElementById('admin-draw-animation').value, 10),
|
|
draw_confirmations: parseInt(document.getElementById('admin-draw-confirmations').value, 10),
|
|
fee_rate_sat_vb: parseInt(document.getElementById('admin-fee-rate').value, 10),
|
|
rbf_timeout_seconds: parseInt(document.getElementById('admin-rbf-timeout').value, 10),
|
|
};
|
|
await withLoading(btn, 'Salvataggio…', async () => {
|
|
try {
|
|
await callAdmin('PUT', '/admin/config', body);
|
|
toast('Configurazione salvata.', 'success');
|
|
} catch (e) {
|
|
toast('Errore nel salvataggio: ' + e.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadUsers() {
|
|
try {
|
|
const users = await callAdmin('GET', '/admin/users');
|
|
const tbody = document.getElementById('users-tbody');
|
|
tbody.innerHTML = users.map((u) => `
|
|
<tr>
|
|
<td>${u.id}</td>
|
|
<td>${escapeHtml(u.username)}</td>
|
|
<td class="addr">${escapeHtml(u.address)}</td>
|
|
<td>${fmtPlm(u.balance_sats)}</td>
|
|
<td>${fmtDate(u.created_at)}</td>
|
|
<td>
|
|
<button class="reveal" onclick="revealPrivkey(${u.id}, this)">Mostra</button>
|
|
<div class="privkey-box hidden" id="privkey-${u.id}"></div>
|
|
</td>
|
|
<td>
|
|
<button class="secondary" style="width:auto;margin-top:0;min-height:30px;padding:4px 10px;font-size:0.78rem" onclick="resetUserPassword(${u.id}, this)">Reset</button>
|
|
<div class="privkey-box hidden" id="newpass-${u.id}"></div>
|
|
</td>
|
|
</tr>
|
|
`).join('') || '<tr><td colspan="7" class="hint">Nessun utente registrato.</td></tr>';
|
|
} catch (e) {
|
|
toast('Errore nel caricamento utenti: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function revealPrivkey(userId, button) {
|
|
const box = document.getElementById('privkey-' + userId);
|
|
if (!box.classList.contains('hidden')) {
|
|
box.classList.add('hidden');
|
|
box.textContent = '';
|
|
button.textContent = 'Mostra';
|
|
return;
|
|
}
|
|
if (!window.confirm('Stai per visualizzare la chiave privata di questo utente. L\'accesso verrà registrato nell\'audit log. Continuare?')) {
|
|
return;
|
|
}
|
|
await withLoading(button, '…', async () => {
|
|
try {
|
|
const data = await callAdmin('GET', '/admin/users/' + userId + '/privkey');
|
|
box.textContent = data.wif;
|
|
box.classList.remove('hidden');
|
|
button.textContent = 'Nascondi';
|
|
} catch (e) {
|
|
toast('Errore: ' + e.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function resetUserPassword(userId, button) {
|
|
if (!window.confirm(
|
|
"Verrà generata una nuova password casuale per questo utente, che non potrà più accedere con quella vecchia. " +
|
|
"L'azione viene registrata nell'audit log. Continuare?"
|
|
)) {
|
|
return;
|
|
}
|
|
const box = document.getElementById('newpass-' + userId);
|
|
await withLoading(button, '…', async () => {
|
|
try {
|
|
const data = await callAdmin('POST', '/admin/users/' + userId + '/reset-password');
|
|
box.textContent = 'Nuova password per ' + data.username + ': ' + data.new_password;
|
|
box.classList.remove('hidden');
|
|
toast('Password reimpostata.', 'success');
|
|
} catch (e) {
|
|
toast('Errore: ' + e.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadRounds() {
|
|
try {
|
|
const rounds = await callAdmin('GET', '/admin/rounds');
|
|
const tbody = document.getElementById('rounds-tbody');
|
|
tbody.innerHTML = rounds.map((r) => `
|
|
<tr>
|
|
<td>${r.id}</td>
|
|
<td>${badge(r.status)}</td>
|
|
<td>${fmtDate(r.opened_at)}</td>
|
|
<td>${r.winner_username ? escapeHtml(r.winner_username) : '—'}</td>
|
|
<td>${fmtPlm(r.pool_amount_sats)}</td>
|
|
<td>${fmtPlm(r.winner_amount_sats)}</td>
|
|
<td>${fmtPlm(r.fee_amount_sats)}</td>
|
|
<td class="txid">${r.payout_txid ? escapeHtml(r.payout_txid) : '—'}</td>
|
|
</tr>
|
|
`).join('') || '<tr><td colspan="8" class="hint">Nessun round ancora.</td></tr>';
|
|
} catch (e) {
|
|
toast('Errore nel caricamento round: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function loadPending() {
|
|
try {
|
|
const items = await callAdmin('GET', '/admin/pending-transactions');
|
|
const tbody = document.getElementById('pending-tbody');
|
|
tbody.innerHTML = items.map((p) => `
|
|
<tr>
|
|
<td>${p.id}</td>
|
|
<td>${escapeHtml(p.kind)}</td>
|
|
<td>${badge(p.status)}</td>
|
|
<td class="txid">${escapeHtml(p.current_txid)}</td>
|
|
<td>${p.fee_rate_sat_vb} sat/vB</td>
|
|
<td>${p.attempt_count}</td>
|
|
<td>${fmtDate(p.broadcast_at)}</td>
|
|
</tr>
|
|
`).join('') || '<tr><td colspan="7" class="hint">Nessuna transazione pendente.</td></tr>';
|
|
} catch (e) {
|
|
toast('Errore nel caricamento transazioni pendenti: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function loadAuditLog() {
|
|
try {
|
|
const entries = await callAdmin('GET', '/admin/audit-log');
|
|
const tbody = document.getElementById('audit-tbody');
|
|
tbody.innerHTML = entries.map((e) => `
|
|
<tr>
|
|
<td>${e.id}</td>
|
|
<td>${escapeHtml(e.event_type)}</td>
|
|
<td><pre class="payload">${escapeHtml(JSON.stringify(e.payload))}</pre></td>
|
|
<td>${e.user_id ?? '—'}</td>
|
|
<td>${e.round_id ?? '—'}</td>
|
|
<td>${fmtDate(e.created_at)}</td>
|
|
</tr>
|
|
`).join('') || '<tr><td colspan="6" class="hint">Nessun evento registrato.</td></tr>';
|
|
} catch (e) {
|
|
toast('Errore nel caricamento audit log: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
const BUG_REPORT_STATUS_LABELS = { open: 'Da leggere', read: 'Presa in carico', resolved: 'Risolta' };
|
|
|
|
function bugReportBadge(status) {
|
|
return `<span class="badge bug-status-${escapeHtml(status)}">${escapeHtml(BUG_REPORT_STATUS_LABELS[status] || status)}</span>`;
|
|
}
|
|
|
|
async function loadBugReports() {
|
|
try {
|
|
const reports = await callAdmin('GET', '/admin/bug-reports');
|
|
const tbody = document.getElementById('bugreports-tbody');
|
|
tbody.innerHTML = reports.map((r) => `
|
|
<tr>
|
|
<td>${r.id}</td>
|
|
<td class="payload-cell">${escapeHtml(r.description)}</td>
|
|
<td>${r.contact ? escapeHtml(r.contact) : '—'}</td>
|
|
<td>${r.username ? escapeHtml(r.username) : '—'}</td>
|
|
<td>${fmtDate(r.created_at)}</td>
|
|
<td>
|
|
${bugReportBadge(r.status)}
|
|
<div class="bug-actions">
|
|
${r.status === 'open' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'read', this)">Segna come presa in carico</button>` : ''}
|
|
${r.status !== 'resolved' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'resolved', this)">Segna come risolta</button>` : ''}
|
|
${r.status !== 'open' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'open', this)">Riapri</button>` : ''}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`).join('') || '<tr><td colspan="6" class="hint">Nessuna segnalazione ricevuta.</td></tr>';
|
|
} catch (e) {
|
|
toast('Errore nel caricamento segnalazioni: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function setBugReportStatus(reportId, newStatus, button) {
|
|
await withLoading(button, '…', async () => {
|
|
try {
|
|
await callAdmin('POST', '/admin/bug-reports/' + reportId + '/status', { status: newStatus });
|
|
await loadBugReports();
|
|
} catch (e) {
|
|
toast('Errore: ' + e.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
document.getElementById('admin-token').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') adminLogin();
|
|
});
|
|
|
|
function initAuthState() {
|
|
adminToken = sessionStorage.getItem('plm_admin_token');
|
|
if (!adminToken) {
|
|
document.getElementById('dashboard-section').classList.add('hidden');
|
|
document.getElementById('login-section').classList.remove('hidden');
|
|
return;
|
|
}
|
|
callAdmin('GET', '/admin/config')
|
|
.then(() => { showDashboard(); return loadDashboard(); })
|
|
.catch(() => adminLogout());
|
|
}
|
|
|
|
// Bfcache can restore a frozen snapshot of this page (DOM/JS state as it was
|
|
// before navigating away) without re-running any of this script — so a stale
|
|
// view could survive across back/forward navigation, e.g. showing a dashboard
|
|
// for a token that's since been rotated or explicitly logged out of. Cache-
|
|
// Control: no-store on this response should already prevent that, but
|
|
// re-validate here too as a safety net for browsers that ignore it.
|
|
window.addEventListener('pageshow', (event) => {
|
|
if (event.persisted) initAuthState();
|
|
});
|
|
|
|
// Same server-push channel as app/static/index.html (see app/rounds/events.py):
|
|
// a content-free "something changed" ping. Here it refreshes the chain-status
|
|
// bar immediately, and reloads whichever admin section is currently open
|
|
// (Utenti/Round/Transazioni pendenti/Audit log) so it doesn't need a manual
|
|
// switch-away-and-back to pick up a new row. Polling stays in place as a
|
|
// fallback if this connection is ever blocked or drops.
|
|
let adminEventSource = null;
|
|
|
|
function onAdminServerEvent() {
|
|
if (!adminToken) return;
|
|
refreshChainStatus();
|
|
if (VIEW_LOADERS[currentAdminView]) VIEW_LOADERS[currentAdminView]();
|
|
}
|
|
|
|
function connectAdminEvents() {
|
|
if (adminEventSource) return;
|
|
adminEventSource = new EventSource('/rounds/stream');
|
|
adminEventSource.addEventListener('update', onAdminServerEvent);
|
|
// Fires on the initial connection AND every successful auto-reconnect —
|
|
// re-syncs immediately instead of waiting for the next event or poll tick
|
|
// to notice whatever changed while this connection was down.
|
|
adminEventSource.addEventListener('open', onAdminServerEvent);
|
|
}
|
|
|
|
connectAdminEvents();
|
|
initAuthState();
|