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
|
// 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])) {
|
||||||
|
$existing = $proofs[$code];
|
||||||
|
$canOverwrite = ($existing['status'] ?? 'paid') === 'pending'
|
||||||
|
&& ($status === 'paid' || $confirmations > ($existing['confirmations'] ?? 0));
|
||||||
|
if (!$canOverwrite) {
|
||||||
flock($fp, LOCK_UN);
|
flock($fp, LOCK_UN);
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
echo json_encode(['ok' => true]);
|
echo json_encode(['ok' => true]);
|
||||||
exit;
|
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()
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
125
app.js
125
app.js
@@ -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;
|
||||||
|
var confs = tx.confirmations || 0;
|
||||||
|
|
||||||
|
if (confs >= XMR_CONF_REQUIRED) {
|
||||||
proofResult.className = 'proof-result active success';
|
proofResult.className = 'proof-result active success';
|
||||||
proofResult.textContent = I18n.t('proof_verified').replace('{amount}', xmrAmount.toFixed(6));
|
proofResult.textContent = I18n.t('proof_verified').replace('{amount}', xmrAmount.toFixed(6));
|
||||||
|
|
||||||
// Store proof with invoice
|
|
||||||
if (invoiceCode) {
|
if (invoiceCode) {
|
||||||
await fetch('/api/verify.php', {
|
await fetch('/api/verify.php', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ code: invoiceCode, tx_hash: txHash, amount: xmrAmount, confirmations: confs, status: 'paid' })
|
||||||
code: invoiceCode,
|
|
||||||
tx_hash: txHash,
|
|
||||||
amount: xmrAmount,
|
|
||||||
confirmations: tx.confirmations || 0
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
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 {
|
} 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
14
i18n.js
@@ -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: 'Предупреждение: обнаружено несоответствие подписи'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
20
style.css
20
style.css
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user