feat: confirmation-aware TX verification (10-conf threshold)

- 0-9 confs: show amber 'Pending/N/10' stamp on QR, auto-poll every 60s
- ≥10 confs: show green 'Paid' stamp (Monero standard lock)
- verify.php: store status ('pending'|'paid'), allow upward updates
- i18n: add status_pending + proof_confirmed_pending (all 7 langs)
- style.css: add .proof-result.warning, .pending-stamp, .qr-container.confirming
- Polling stops on resetForm; short-URL viewers also poll verify.php
This commit is contained in:
Alexander Schmidt
2026-03-26 07:30:43 +01:00
parent 403a08479c
commit 4ac12eb083
4 changed files with 168 additions and 21 deletions

View File

@@ -93,18 +93,26 @@ if (!isset($urls[$code])) {
// Store proof with atomic lock // Store proof with atomic lock
[$fp, $proofs] = read_json_locked($dbFile); [$fp, $proofs] = read_json_locked($dbFile);
// Don't overwrite an already-verified proof $status = ($input['status'] ?? 'paid') === 'pending' ? 'pending' : 'paid';
// Allow overwriting a pending proof with more confirmations or a final paid status
if (isset($proofs[$code])) { if (isset($proofs[$code])) {
flock($fp, LOCK_UN); $existing = $proofs[$code];
fclose($fp); $canOverwrite = ($existing['status'] ?? 'paid') === 'pending'
echo json_encode(['ok' => true]); && ($status === 'paid' || $confirmations > ($existing['confirmations'] ?? 0));
exit; if (!$canOverwrite) {
flock($fp, LOCK_UN);
fclose($fp);
echo json_encode(['ok' => true]);
exit;
}
} }
$proofs[$code] = [ $proofs[$code] = [
'tx_hash' => strtolower($txHash), 'tx_hash' => strtolower($txHash),
'amount' => $amount, 'amount' => $amount,
'confirmations' => $confirmations, 'confirmations' => $confirmations,
'status' => $status,
'verified_at' => time() 'verified_at' => time()
]; ];

137
app.js
View File

@@ -8,6 +8,7 @@
const XMR_INTEGRATED_REGEX = /^4[1-9A-HJ-NP-Za-km-z]{105}$/; const XMR_INTEGRATED_REGEX = /^4[1-9A-HJ-NP-Za-km-z]{105}$/;
const CACHE_DURATION = 60000; // 1 min const CACHE_DURATION = 60000; // 1 min
const RATE_RETRY_DELAY = 10000; // 10s retry on failure const RATE_RETRY_DELAY = 10000; // 10s retry on failure
const XMR_CONF_REQUIRED = 10; // Monero standard output lock
// --- State --- // --- State ---
let fiatRates = null; let fiatRates = null;
@@ -16,6 +17,8 @@
let countdownTick = null; let countdownTick = null;
let ratesFailed = false; let ratesFailed = false;
let invoiceCode = null; // short URL code for this invoice let invoiceCode = null; // short URL code for this invoice
let confirmPollInterval = null;
let pendingTxData = null; // { txHash, xmrAmount } for confirmation polling
// --- DOM --- // --- DOM ---
const $ = (s) => document.querySelector(s); const $ = (s) => document.querySelector(s);
@@ -168,10 +171,12 @@
resultSection.classList.remove('visible'); resultSection.classList.remove('visible');
if (countdownInterval) clearInterval(countdownInterval); if (countdownInterval) clearInterval(countdownInterval);
qrContainer.innerHTML = ''; qrContainer.innerHTML = '';
qrContainer.classList.remove('paid', 'confirming');
uriBox.textContent = ''; uriBox.textContent = '';
shareLinkInput.value = ''; shareLinkInput.value = '';
// Reset proof // Reset proof
invoiceCode = null; invoiceCode = null;
stopConfirmationPolling();
proofPanel.classList.remove('open'); proofPanel.classList.remove('open');
txHashInput.value = ''; txHashInput.value = '';
txKeyInput.value = ''; txKeyInput.value = '';
@@ -864,23 +869,33 @@
if (found) { if (found) {
var xmrAmount = Number(totalAmount) / 1e12; var xmrAmount = Number(totalAmount) / 1e12;
proofResult.className = 'proof-result active success'; var confs = tx.confirmations || 0;
proofResult.textContent = I18n.t('proof_verified').replace('{amount}', xmrAmount.toFixed(6));
// Store proof with invoice if (confs >= XMR_CONF_REQUIRED) {
if (invoiceCode) { proofResult.className = 'proof-result active success';
await fetch('/api/verify.php', { proofResult.textContent = I18n.t('proof_verified').replace('{amount}', xmrAmount.toFixed(6));
method: 'POST', if (invoiceCode) {
headers: { 'Content-Type': 'application/json' }, await fetch('/api/verify.php', {
body: JSON.stringify({ method: 'POST',
code: invoiceCode, headers: { 'Content-Type': 'application/json' },
tx_hash: txHash, body: JSON.stringify({ code: invoiceCode, tx_hash: txHash, amount: xmrAmount, confirmations: confs, status: 'paid' })
amount: xmrAmount, });
confirmations: tx.confirmations || 0 }
}) showPaidStatus({ amount: xmrAmount, tx_hash: txHash, confirmations: confs });
}); } else {
proofResult.className = 'proof-result active warning';
proofResult.textContent = I18n.t('proof_confirmed_pending')
.replace('{amount}', xmrAmount.toFixed(6)).replace('{n}', confs);
if (invoiceCode) {
await fetch('/api/verify.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: invoiceCode, tx_hash: txHash, amount: xmrAmount, confirmations: confs, status: 'pending' })
});
}
showPendingStatus({ amount: xmrAmount, tx_hash: txHash, confirmations: confs });
startConfirmationPolling(txHash, xmrAmount);
} }
showPaidStatus({ amount: xmrAmount, tx_hash: txHash });
} else { } else {
proofResult.className = 'proof-result active error'; proofResult.className = 'proof-result active error';
proofResult.textContent = I18n.t('proof_no_match'); proofResult.textContent = I18n.t('proof_no_match');
@@ -897,7 +912,25 @@
fetch('/api/verify.php?code=' + encodeURIComponent(code)) fetch('/api/verify.php?code=' + encodeURIComponent(code))
.then(function (res) { return res.json(); }) .then(function (res) { return res.json(); })
.then(function (data) { .then(function (data) {
if (data.verified) { if (!data.verified) return;
if (data.status === 'pending') {
showPendingStatus(data);
// Poll verify.php for updates pushed by the sender's browser
confirmPollInterval = setInterval(function () {
fetch('/api/verify.php?code=' + encodeURIComponent(code))
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.verified) return;
if (d.status === 'paid') {
stopConfirmationPolling();
showPaidStatus(d);
} else {
showPendingStatus(d);
}
})
.catch(function () {});
}, 60000);
} else {
showPaidStatus(data); showPaidStatus(data);
} }
}) })
@@ -945,6 +978,78 @@
setPaidFavicon(); setPaidFavicon();
} }
function showPendingStatus(data) {
var confs = data.confirmations || 0;
paymentStatus.className = 'payment-status pending';
qrContainer.classList.add('confirming');
var existingStamp = qrContainer.querySelector('.paid-stamp');
if (!existingStamp) {
var stamp = document.createElement('div');
stamp.className = 'paid-stamp pending-stamp';
qrContainer.appendChild(stamp);
existingStamp = stamp;
}
existingStamp.textContent = confs === 0 ? I18n.t('status_pending') : (confs + '/10');
var hint = qrContainer.querySelector('.qr-hint');
if (hint && !hint.classList.contains('paid-info')) {
hint.textContent = data.amount.toFixed(6) + ' XMR — TX ' + data.tx_hash.substring(0, 8) + '...';
}
}
function startConfirmationPolling(txHash, xmrAmount) {
stopConfirmationPolling();
pendingTxData = { txHash: txHash, xmrAmount: xmrAmount };
confirmPollInterval = setInterval(pollConfirmations, 60000);
}
function stopConfirmationPolling() {
if (confirmPollInterval) {
clearInterval(confirmPollInterval);
confirmPollInterval = null;
}
pendingTxData = null;
}
async function pollConfirmations() {
if (!pendingTxData) return;
var txHash = pendingTxData.txHash;
var xmrAmount = pendingTxData.xmrAmount;
try {
var res = await fetch('/api/node.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: 'gettransactions', params: { txs_hashes: [txHash] } })
});
var data = await res.json();
var txs = data.txs || [];
if (txs.length === 0) return;
var confs = txs[0].confirmations || 0;
if (confs >= XMR_CONF_REQUIRED) {
stopConfirmationPolling();
proofResult.className = 'proof-result active success';
proofResult.textContent = I18n.t('proof_verified').replace('{amount}', xmrAmount.toFixed(6));
if (invoiceCode) {
await fetch('/api/verify.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: invoiceCode, tx_hash: txHash, amount: xmrAmount, confirmations: confs, status: 'paid' })
});
}
showPaidStatus({ amount: xmrAmount, tx_hash: txHash, confirmations: confs });
} else {
showPendingStatus({ amount: xmrAmount, tx_hash: txHash, confirmations: confs });
proofResult.className = 'proof-result active warning';
proofResult.textContent = I18n.t('proof_confirmed_pending')
.replace('{amount}', xmrAmount.toFixed(6)).replace('{n}', confs);
}
} catch (e) {
// silent — try again next interval
}
}
function setPaidFavicon() { function setPaidFavicon() {
var canvas = document.createElement('canvas'); var canvas = document.createElement('canvas');
canvas.width = 32; canvas.width = 32;

14
i18n.js
View File

@@ -59,6 +59,8 @@ var I18n = (function () {
proof_tx_not_found: 'Transaction not found', proof_tx_not_found: 'Transaction not found',
proof_error: 'Verification error', proof_error: 'Verification error',
status_paid: 'Paid', status_paid: 'Paid',
status_pending: 'Pending',
proof_confirmed_pending: 'Output found: {amount} XMR — {n}/10 confirmations. Auto-refreshing…',
toast_integrity_warning: 'Warning: signature mismatch detected' toast_integrity_warning: 'Warning: signature mismatch detected'
}, },
de: { de: {
@@ -106,6 +108,8 @@ var I18n = (function () {
proof_tx_not_found: 'Transaktion nicht gefunden', proof_tx_not_found: 'Transaktion nicht gefunden',
proof_error: 'Fehler bei der Verifizierung', proof_error: 'Fehler bei der Verifizierung',
status_paid: 'Bezahlt', status_paid: 'Bezahlt',
status_pending: 'Ausstehend',
proof_confirmed_pending: 'Output gefunden: {amount} XMR — {n}/10 Bestätigungen. Wird aktualisiert…',
toast_integrity_warning: 'Warnung: Signatur-Nichtübereinstimmung erkannt' toast_integrity_warning: 'Warnung: Signatur-Nichtübereinstimmung erkannt'
}, },
fr: { fr: {
@@ -153,6 +157,8 @@ var I18n = (function () {
proof_tx_not_found: 'Transaction introuvable', proof_tx_not_found: 'Transaction introuvable',
proof_error: 'Erreur de vérification', proof_error: 'Erreur de vérification',
status_paid: 'Payé', status_paid: 'Payé',
status_pending: 'En attente',
proof_confirmed_pending: 'Sortie trouvée : {amount} XMR — {n}/10 confirmations. Actualisation automatique…',
toast_integrity_warning: 'Avertissement : détection d\'une non-concordance de signature' toast_integrity_warning: 'Avertissement : détection d\'une non-concordance de signature'
}, },
it: { it: {
@@ -200,6 +206,8 @@ var I18n = (function () {
proof_tx_not_found: 'Transazione non trovata', proof_tx_not_found: 'Transazione non trovata',
proof_error: 'Errore di verifica', proof_error: 'Errore di verifica',
status_paid: 'Pagato', status_paid: 'Pagato',
status_pending: 'In attesa',
proof_confirmed_pending: 'Output trovato: {amount} XMR — {n}/10 conferme. Aggiornamento automatico…',
toast_integrity_warning: 'Avviso: rilevata mancata corrispondenza della firma' toast_integrity_warning: 'Avviso: rilevata mancata corrispondenza della firma'
}, },
es: { es: {
@@ -247,6 +255,8 @@ var I18n = (function () {
proof_tx_not_found: 'Transacción no encontrada', proof_tx_not_found: 'Transacción no encontrada',
proof_error: 'Error de verificación', proof_error: 'Error de verificación',
status_paid: 'Pagado', status_paid: 'Pagado',
status_pending: 'Pendiente',
proof_confirmed_pending: 'Output encontrado: {amount} XMR — {n}/10 confirmaciones. Actualización automática…',
toast_integrity_warning: 'Advertencia: desajuste de firma detectado' toast_integrity_warning: 'Advertencia: desajuste de firma detectado'
}, },
pt: { pt: {
@@ -294,6 +304,8 @@ var I18n = (function () {
proof_tx_not_found: 'Transação não encontrada', proof_tx_not_found: 'Transação não encontrada',
proof_error: 'Erro de verificação', proof_error: 'Erro de verificação',
status_paid: 'Pago', status_paid: 'Pago',
status_pending: 'Pendente',
proof_confirmed_pending: 'Output encontrado: {amount} XMR — {n}/10 confirmações. Atualização automática…',
toast_integrity_warning: 'Aviso: incompatibilidade de assinatura detectada' toast_integrity_warning: 'Aviso: incompatibilidade de assinatura detectada'
}, },
ru: { ru: {
@@ -341,6 +353,8 @@ var I18n = (function () {
proof_tx_not_found: 'Транзакция не найдена', proof_tx_not_found: 'Транзакция не найдена',
proof_error: 'Ошибка проверки', proof_error: 'Ошибка проверки',
status_paid: 'Оплачено', status_paid: 'Оплачено',
status_pending: 'Ожидание',
proof_confirmed_pending: 'Выход найден: {amount} XMR — {n}/10 подтверждений. Авт. обновление…',
toast_integrity_warning: 'Предупреждение: обнаружено несоответствие подписи' toast_integrity_warning: 'Предупреждение: обнаружено несоответствие подписи'
} }
}; };

View File

@@ -556,6 +556,12 @@ textarea {
border: 1px solid var(--error); border: 1px solid var(--error);
} }
.proof-result.warning {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
border: 1px solid #f59e0b;
}
.payment-status { .payment-status {
display: none; display: none;
} }
@@ -564,6 +570,10 @@ textarea {
display: block; display: block;
} }
.payment-status.pending {
display: block;
}
.paid-stamp { .paid-stamp {
position: absolute; position: absolute;
top: 50%; top: 50%;
@@ -587,6 +597,16 @@ textarea {
opacity: 0.3; opacity: 0.3;
} }
.pending-stamp {
border-color: #f59e0b;
color: #f59e0b;
}
.qr-container.confirming canvas,
.qr-container.confirming img {
opacity: 0.5;
}
.qr-container { .qr-container {
position: relative; position: relative;
} }