From 4ac12eb083f1220a61521f6c4387ea6696a44155 Mon Sep 17 00:00:00 2001 From: Alexander Schmidt Date: Thu, 26 Mar 2026 07:30:43 +0100 Subject: [PATCH] feat: confirmation-aware TX verification (10-conf threshold) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- api/verify.php | 18 +++++-- app.js | 137 +++++++++++++++++++++++++++++++++++++++++++------ i18n.js | 14 +++++ style.css | 20 ++++++++ 4 files changed, 168 insertions(+), 21 deletions(-) diff --git a/api/verify.php b/api/verify.php index c27b61c..dc8f821 100644 --- a/api/verify.php +++ b/api/verify.php @@ -93,18 +93,26 @@ if (!isset($urls[$code])) { // Store proof with atomic lock [$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])) { - flock($fp, LOCK_UN); - fclose($fp); - echo json_encode(['ok' => true]); - exit; + $existing = $proofs[$code]; + $canOverwrite = ($existing['status'] ?? 'paid') === 'pending' + && ($status === 'paid' || $confirmations > ($existing['confirmations'] ?? 0)); + if (!$canOverwrite) { + flock($fp, LOCK_UN); + fclose($fp); + echo json_encode(['ok' => true]); + exit; + } } $proofs[$code] = [ 'tx_hash' => strtolower($txHash), 'amount' => $amount, 'confirmations' => $confirmations, + 'status' => $status, 'verified_at' => time() ]; diff --git a/app.js b/app.js index 0dd280c..800d6ee 100644 --- a/app.js +++ b/app.js @@ -8,6 +8,7 @@ const XMR_INTEGRATED_REGEX = /^4[1-9A-HJ-NP-Za-km-z]{105}$/; const CACHE_DURATION = 60000; // 1 min const RATE_RETRY_DELAY = 10000; // 10s retry on failure + const XMR_CONF_REQUIRED = 10; // Monero standard output lock // --- State --- let fiatRates = null; @@ -16,6 +17,8 @@ let countdownTick = null; let ratesFailed = false; let invoiceCode = null; // short URL code for this invoice + let confirmPollInterval = null; + let pendingTxData = null; // { txHash, xmrAmount } for confirmation polling // --- DOM --- const $ = (s) => document.querySelector(s); @@ -168,10 +171,12 @@ resultSection.classList.remove('visible'); if (countdownInterval) clearInterval(countdownInterval); qrContainer.innerHTML = ''; + qrContainer.classList.remove('paid', 'confirming'); uriBox.textContent = ''; shareLinkInput.value = ''; // Reset proof invoiceCode = null; + stopConfirmationPolling(); proofPanel.classList.remove('open'); txHashInput.value = ''; txKeyInput.value = ''; @@ -864,23 +869,33 @@ if (found) { var xmrAmount = Number(totalAmount) / 1e12; - proofResult.className = 'proof-result active success'; - proofResult.textContent = I18n.t('proof_verified').replace('{amount}', xmrAmount.toFixed(6)); + var confs = tx.confirmations || 0; - // Store proof with invoice - 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: tx.confirmations || 0 - }) - }); + if (confs >= XMR_CONF_REQUIRED) { + 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 { + 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 { proofResult.className = 'proof-result active error'; proofResult.textContent = I18n.t('proof_no_match'); @@ -897,7 +912,25 @@ fetch('/api/verify.php?code=' + encodeURIComponent(code)) .then(function (res) { return res.json(); }) .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); } }) @@ -945,6 +978,78 @@ 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() { var canvas = document.createElement('canvas'); canvas.width = 32; diff --git a/i18n.js b/i18n.js index fb0166e..0b0eaf4 100644 --- a/i18n.js +++ b/i18n.js @@ -59,6 +59,8 @@ var I18n = (function () { proof_tx_not_found: 'Transaction not found', proof_error: 'Verification error', 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' }, de: { @@ -106,6 +108,8 @@ var I18n = (function () { proof_tx_not_found: 'Transaktion nicht gefunden', proof_error: 'Fehler bei der Verifizierung', 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' }, fr: { @@ -153,6 +157,8 @@ var I18n = (function () { proof_tx_not_found: 'Transaction introuvable', proof_error: 'Erreur de vérification', 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' }, it: { @@ -200,6 +206,8 @@ var I18n = (function () { proof_tx_not_found: 'Transazione non trovata', proof_error: 'Errore di verifica', 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' }, es: { @@ -247,6 +255,8 @@ var I18n = (function () { proof_tx_not_found: 'Transacción no encontrada', proof_error: 'Error de verificación', 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' }, pt: { @@ -294,6 +304,8 @@ var I18n = (function () { proof_tx_not_found: 'Transação não encontrada', proof_error: 'Erro de verificação', 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' }, ru: { @@ -341,6 +353,8 @@ var I18n = (function () { proof_tx_not_found: 'Транзакция не найдена', proof_error: 'Ошибка проверки', status_paid: 'Оплачено', + status_pending: 'Ожидание', + proof_confirmed_pending: 'Выход найден: {amount} XMR — {n}/10 подтверждений. Авт. обновление…', toast_integrity_warning: 'Предупреждение: обнаружено несоответствие подписи' } }; diff --git a/style.css b/style.css index b8da7ac..f53345b 100644 --- a/style.css +++ b/style.css @@ -556,6 +556,12 @@ textarea { border: 1px solid var(--error); } +.proof-result.warning { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; + border: 1px solid #f59e0b; +} + .payment-status { display: none; } @@ -564,6 +570,10 @@ textarea { display: block; } +.payment-status.pending { + display: block; +} + .paid-stamp { position: absolute; top: 50%; @@ -587,6 +597,16 @@ textarea { opacity: 0.3; } +.pending-stamp { + border-color: #f59e0b; + color: #f59e0b; +} + +.qr-container.confirming canvas, +.qr-container.confirming img { + opacity: 0.5; +} + .qr-container { position: relative; }