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:
@@ -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])) {
|
||||
$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()
|
||||
];
|
||||
|
||||
|
||||
125
app.js
125
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;
|
||||
var confs = 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));
|
||||
|
||||
// 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
|
||||
})
|
||||
body: JSON.stringify({ code: invoiceCode, tx_hash: txHash, amount: xmrAmount, confirmations: confs, status: 'paid' })
|
||||
});
|
||||
}
|
||||
showPaidStatus({ amount: xmrAmount, tx_hash: txHash });
|
||||
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);
|
||||
}
|
||||
} 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;
|
||||
|
||||
14
i18n.js
14
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: 'Предупреждение: обнаружено несоответствие подписи'
|
||||
}
|
||||
};
|
||||
|
||||
20
style.css
20
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user