feat: replace view-key monitor with TX proof verification

Remove v2 view-key payment monitor (privacy concern — nobody should
enter their private view key on a website). Replace with TX proof
verification where the sender provides TX Hash + TX Key from their
wallet. The proof is cryptographically verified client-side and
stored with the invoice for persistent "Paid" status.

- Remove monitor.js and all view-key monitoring UI/logic
- Add TX proof section: sender enters TX Hash + TX Key
- Client-side verification via check_tx_key equivalent (noble-curves)
- api/verify.php stores/retrieves payment proofs per invoice
- Short URL redirect now includes invoice code for status lookup
- Invoice link shows "Paid" badge once proof is verified
- Deadline badges (7/14/30 days) for payment terms
This commit is contained in:
Alexander Schmidt
2026-03-25 09:37:09 +01:00
parent 1acf990943
commit 32245fccdf
9 changed files with 318 additions and 696 deletions

View File

@@ -67,15 +67,14 @@ Die App ist eine einzige HTML-Datei, die von überall gehostet werden kann.
- [x] Offline-fähig via Service Worker - [x] Offline-fähig via Service Worker
- [x] CoinGecko-Fallback mit Auto-Retry - [x] CoinGecko-Fallback mit Auto-Retry
### v2 — View-Key Zahlungsbestätigung (Browser-basiert) ### v2 — TX Proof Zahlungsbestätigung ✅
- [x] Private View-Key eingeben (validiert gegen Adresse, verlässt nie den Browser) - [x] Sender gibt TX Hash + TX Key ein (aus Wallet kopiert)
- [x] Browser pollt Remote Node via PHP-Proxy mit Failover (4 Nodes) - [x] Kryptografische Verifizierung im Browser (30KB noble-curves Bundle)
- [x] Live-Anzeige: "Warte auf Zahlung..." → "Zahlung eingegangen (X/10 Bestätigungen)" - [x] Zahlungsstatus wird dauerhaft mit Rechnung gespeichert
- [x] Fortschrittsbalken für Bestätigungen - [x] Rechnungs-Link zeigt "Bezahlt" Badge nach Verifizierung
- [x] Unterzahlungs-Erkennung
- [x] Standard- und Subaddress-Unterstützung - [x] Standard- und Subaddress-Unterstützung
- [x] Leichtgewichtige Krypto (30KB noble-curves Bundle, kein 5MB WASM) - [x] Kein Private View Key nötig — kein Privacy-Risiko
### v3 — Professionelle Features ### v3 — Professionelle Features

86
api/verify.php Normal file
View File

@@ -0,0 +1,86 @@
<?php
/**
* TX Proof Storage API
* POST: Store verified payment proof for an invoice
* GET: Retrieve payment status for an invoice
*/
header('Content-Type: application/json');
$dbFile = __DIR__ . '/../data/proofs.json';
$proofs = [];
if (file_exists($dbFile)) {
$proofs = json_decode(file_get_contents($dbFile), true) ?: [];
}
// GET: Retrieve proof
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$code = $_GET['code'] ?? '';
if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) {
echo json_encode(['verified' => false]);
exit;
}
if (isset($proofs[$code])) {
echo json_encode(array_merge(['verified' => true], $proofs[$code]));
} else {
echo json_encode(['verified' => false]);
}
exit;
}
// POST: Store proof
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
http_response_code(400);
echo json_encode(['error' => 'Invalid JSON']);
exit;
}
$code = $input['code'] ?? '';
$txHash = $input['tx_hash'] ?? '';
$amount = floatval($input['amount'] ?? 0);
$confirmations = intval($input['confirmations'] ?? 0);
// Validate
if (!preg_match('/^[a-z0-9]{4,10}$/', $code)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid code']);
exit;
}
if (!preg_match('/^[0-9a-fA-F]{64}$/', $txHash)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid tx_hash']);
exit;
}
// Verify the short URL code exists
$urlsFile = __DIR__ . '/../data/urls.json';
if (!file_exists($urlsFile)) {
http_response_code(404);
echo json_encode(['error' => 'Invoice not found']);
exit;
}
$urls = json_decode(file_get_contents($urlsFile), true) ?: [];
if (!isset($urls[$code])) {
http_response_code(404);
echo json_encode(['error' => 'Invoice not found']);
exit;
}
// Store proof
$proofs[$code] = [
'tx_hash' => strtolower($txHash),
'amount' => $amount,
'confirmations' => $confirmations,
'verified_at' => time()
];
file_put_contents($dbFile, json_encode($proofs, JSON_PRETTY_PRINT));
echo json_encode(['ok' => true]);

268
app.js
View File

@@ -38,19 +38,14 @@
const newRequestBtn = $('#newRequest'); const newRequestBtn = $('#newRequest');
const homeLink = $('#homeLink'); const homeLink = $('#homeLink');
// Monitor DOM // TX Proof DOM
const monitorSection = $('#monitorSection'); const proofToggle = $('#proofToggle');
const monitorToggle = $('#monitorToggle'); const proofPanel = $('#proofPanel');
const monitorPanel = $('#monitorPanel'); const txHashInput = $('#txHash');
const viewKeyInput = $('#viewKey'); const txKeyInput = $('#txKey');
const startMonitorBtn = $('#startMonitor'); const verifyProofBtn = $('#verifyProof');
const stopMonitorBtn = $('#stopMonitor'); const proofResult = $('#proofResult');
const monitorStatus = $('#monitorStatus'); const paymentStatus = $('#paymentStatus');
const statusIndicator = $('#statusIndicator');
const statusText = $('#statusText');
const confirmationsBar = $('#confirmationsBar');
const confirmationsFill = $('#confirmationsFill');
const confirmationsText = $('#confirmationsText');
let cryptoLoaded = false; let cryptoLoaded = false;
// --- Init --- // --- Init ---
@@ -90,11 +85,11 @@
selectedDays = parseInt(timerCustom.value) || 0; selectedDays = parseInt(timerCustom.value) || 0;
}); });
// Monitor events // TX Proof events
monitorToggle.addEventListener('click', toggleMonitor); proofToggle.addEventListener('click', toggleProofPanel);
viewKeyInput.addEventListener('input', validateViewKey); txHashInput.addEventListener('input', validateProofInputs);
startMonitorBtn.addEventListener('click', startMonitoring); txKeyInput.addEventListener('input', validateProofInputs);
stopMonitorBtn.addEventListener('click', stopMonitoring); verifyProofBtn.addEventListener('click', verifyTxProof);
// --- Functions --- // --- Functions ---
@@ -115,12 +110,15 @@
qrContainer.innerHTML = ''; qrContainer.innerHTML = '';
uriBox.textContent = ''; uriBox.textContent = '';
shareLinkInput.value = ''; shareLinkInput.value = '';
// Reset monitor // Reset proof
stopMonitoring(); proofPanel.classList.remove('open');
monitorPanel.classList.remove('open'); txHashInput.value = '';
viewKeyInput.value = ''; txKeyInput.value = '';
viewKeyInput.classList.remove('valid', 'invalid'); verifyProofBtn.disabled = true;
startMonitorBtn.disabled = true; proofResult.innerHTML = '';
proofResult.className = 'proof-result';
paymentStatus.innerHTML = '';
paymentStatus.className = 'payment-status';
history.replaceState(null, '', location.pathname); history.replaceState(null, '', location.pathname);
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
addrInput.focus(); addrInput.focus();
@@ -308,6 +306,12 @@
} }
} }
// Check for short URL code and load payment status
const code = params.get('c');
if (code) {
setTimeout(function () { loadPaymentStatus(code); }, 200);
}
// Auto-generate // Auto-generate
setTimeout(generate, 100); setTimeout(generate, 100);
return true; return true;
@@ -413,134 +417,160 @@
} }
} }
// --- Monitor Functions (v2) --- // --- TX Proof Functions ---
function toggleMonitor() { function toggleProofPanel() {
const panel = monitorPanel; const isOpen = proofPanel.classList.contains('open');
const isOpen = panel.classList.contains('open');
if (isOpen) { if (isOpen) {
panel.classList.remove('open'); proofPanel.classList.remove('open');
return; return;
} }
// Lazy-load crypto bundle // Lazy-load crypto bundle
if (!cryptoLoaded && !window.XmrCrypto) { if (!cryptoLoaded && !window.XmrCrypto) {
loadCryptoBundle().then(function () { loadCryptoBundle().then(function () {
cryptoLoaded = true; cryptoLoaded = true;
panel.classList.add('open'); proofPanel.classList.add('open');
viewKeyInput.focus(); txHashInput.focus();
}); });
return; return;
} }
panel.classList.add('open'); proofPanel.classList.add('open');
viewKeyInput.focus(); txHashInput.focus();
} }
function loadCryptoBundle() { function loadCryptoBundle() {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
if (window.XmrCrypto) { resolve(); return; } if (window.XmrCrypto) { resolve(); return; }
statusText.textContent = I18n.t('monitor_loading');
monitorStatus.classList.add('active');
const script = document.createElement('script'); const script = document.createElement('script');
script.src = 'lib/xmr-crypto.bundle.js'; script.src = 'lib/xmr-crypto.bundle.js';
script.onload = function () { script.onload = resolve;
monitorStatus.classList.remove('active'); script.onerror = function () { reject(new Error('Failed to load crypto module')); };
resolve();
};
script.onerror = function () {
monitorStatus.classList.remove('active');
reject(new Error('Failed to load crypto module'));
};
document.head.appendChild(script); document.head.appendChild(script);
}); });
} }
function validateViewKey() { function isValidHex64(val) {
const key = viewKeyInput.value.trim(); return /^[0-9a-fA-F]{64}$/.test(val);
viewKeyInput.classList.remove('valid', 'invalid');
if (key.length === 0) {
startMonitorBtn.disabled = true;
return;
}
if (PaymentMonitor.isValidViewKey(key)) {
viewKeyInput.classList.add('valid');
startMonitorBtn.disabled = false;
} else if (key.length >= 10) {
viewKeyInput.classList.add('invalid');
startMonitorBtn.disabled = true;
}
} }
function startMonitoring() { function validateProofInputs() {
const viewKey = viewKeyInput.value.trim(); const hash = txHashInput.value.trim();
if (!PaymentMonitor.isValidViewKey(viewKey)) return; const key = txKeyInput.value.trim();
verifyProofBtn.disabled = !(isValidHex64(hash) && isValidHex64(key));
}
async function verifyTxProof() {
const txHash = txHashInput.value.trim();
const txKey = txKeyInput.value.trim();
const addr = addrInput.value.trim(); const addr = addrInput.value.trim();
const xmrAmount = getXmrAmount() || 0; if (!isValidHex64(txHash) || !isValidHex64(txKey) || !isValidAddress(addr)) return;
// Hide input, show status verifyProofBtn.disabled = true;
startMonitorBtn.style.display = 'none'; proofResult.className = 'proof-result active';
viewKeyInput.closest('.field').style.display = 'none'; proofResult.textContent = I18n.t('proof_verifying');
monitorStatus.classList.add('active');
stopMonitorBtn.classList.add('active');
PaymentMonitor.start(addr, viewKey, xmrAmount, function (newState, data) { try {
updateMonitorUI(newState, data); // Fetch TX from node
}); var res = await fetch('/api/node.php', {
} method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: 'gettransactions', params: { txs_hashes: [txHash], decode_as_json: true } })
});
var data = await res.json();
var txs = data.txs || [];
if (txs.length === 0) {
proofResult.className = 'proof-result active error';
proofResult.textContent = I18n.t('proof_tx_not_found');
verifyProofBtn.disabled = false;
return;
}
function stopMonitoring() { var tx = txs[0];
PaymentMonitor.stop(); var txJson = JSON.parse(tx.as_json);
monitorStatus.classList.remove('active');
confirmationsBar.classList.remove('active');
stopMonitorBtn.classList.remove('active');
startMonitorBtn.style.display = '';
viewKeyInput.closest('.field').style.display = '';
statusIndicator.className = 'status-indicator';
statusText.textContent = '';
}
function updateMonitorUI(monitorState, data) { // Get keys from address
const S = PaymentMonitor.STATE; var keys = XmrCrypto.getKeysFromAddress(addr);
statusIndicator.className = 'status-indicator ' + monitorState; var pubViewKey = keys.publicViewKey;
var pubSpendKey = keys.publicSpendKey;
switch (monitorState) { // Key derivation: D = 8 * txKey * pubViewKey
case S.CONNECTING: var r = XmrCrypto.bytesToScalar(XmrCrypto.hexToBytes(txKey));
statusText.textContent = I18n.t('monitor_connecting'); var A = XmrCrypto.Point.fromHex(pubViewKey);
confirmationsBar.classList.remove('active'); var D = A.multiply(r).multiply(8n);
break; var derivation = D.toBytes();
case S.SCANNING:
statusText.textContent = I18n.t('monitor_scanning'); var B = XmrCrypto.Point.fromHex(pubSpendKey);
break;
case S.WAITING: // Check each output
statusText.textContent = I18n.t('monitor_waiting'); var outputs = txJson.vout || [];
break; var ecdhInfo = (txJson.rct_signatures && txJson.rct_signatures.ecdhInfo) || [];
case S.MEMPOOL: var totalAmount = 0n;
statusText.textContent = I18n.t('monitor_mempool'); var found = false;
showConfirmations(data.confirmations);
break; for (var oi = 0; oi < outputs.length; oi++) {
case S.CONFIRMED: var out = outputs[oi];
statusText.textContent = I18n.t('monitor_confirmed'); var outputKey = out.target && out.target.tagged_key ? out.target.tagged_key.key : (out.target && out.target.key);
showConfirmations(data.confirmations); if (!outputKey) continue;
stopMonitorBtn.classList.remove('active');
break; var varint = XmrCrypto.encodeVarint(oi);
case S.UNDERPAID: var scalar = XmrCrypto.hashToScalar(XmrCrypto.concat(derivation, varint));
statusText.textContent = I18n.t('monitor_underpaid'); var scBig = XmrCrypto.bytesToScalar(scalar);
var detail = I18n.t('monitor_underpaid_detail') var expectedP = XmrCrypto.Point.BASE.multiply(scBig).add(B);
.replace('{expected}', data.expected.toFixed(6)) var expectedHex = XmrCrypto.bytesToHex(expectedP.toBytes());
.replace('{received}', data.received.toFixed(6));
statusText.textContent += '\n' + detail; if (expectedHex === outputKey) {
showConfirmations(data.confirmations); found = true;
break; // Decode amount
case S.ERROR: if (ecdhInfo[oi] && ecdhInfo[oi].amount) {
statusText.textContent = data.message || I18n.t('monitor_node_error'); var amount = XmrCrypto.decodeRctAmount(ecdhInfo[oi].amount, derivation, oi);
break; totalAmount += amount;
}
}
}
if (found) {
var xmrAmount = Number(totalAmount) / 1e12;
proofResult.className = 'proof-result active success';
proofResult.textContent = I18n.t('proof_verified').replace('{amount}', xmrAmount.toFixed(6));
// Store proof with invoice
var shareUrl = shareLinkInput.value;
var codeMatch = shareUrl.match(/\/s\/([a-z0-9]+)/);
if (codeMatch) {
await fetch('/api/verify.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: codeMatch[1],
tx_hash: txHash,
amount: xmrAmount,
confirmations: tx.confirmations || 0
})
});
}
} else {
proofResult.className = 'proof-result active error';
proofResult.textContent = I18n.t('proof_no_match');
}
} catch (e) {
proofResult.className = 'proof-result active error';
proofResult.textContent = I18n.t('proof_error');
} }
verifyProofBtn.disabled = false;
} }
function showConfirmations(n) { // Load payment status if viewing via short URL
confirmationsBar.classList.add('active'); function loadPaymentStatus(code) {
var pct = Math.min(100, (n / 10) * 100); fetch('/api/verify.php?code=' + encodeURIComponent(code))
confirmationsFill.style.width = pct + '%'; .then(function (res) { return res.json(); })
confirmationsText.textContent = I18n.t('monitor_confirmations').replace('{n}', n); .then(function (data) {
if (data.verified) {
paymentStatus.className = 'payment-status paid';
paymentStatus.innerHTML = '<div class="paid-badge">' + I18n.t('status_paid') +
'</div><div class="paid-detail">' + data.amount.toFixed(6) + ' XMR — TX ' +
data.tx_hash.substring(0, 8) + '...</div>';
}
})
.catch(function () {});
} }
})(); })();

56
i18n.js
View File

@@ -29,22 +29,18 @@ var I18n = (function () {
countdown_remaining_days: 'Zahlungsfrist: {d} Tage, {h}:{m}:{s}', countdown_remaining_days: 'Zahlungsfrist: {d} Tage, {h}:{m}:{s}',
countdown_remaining_hours: 'Zahlungsfrist: {h}:{m}:{s}', countdown_remaining_hours: 'Zahlungsfrist: {h}:{m}:{s}',
rates_offline: 'Kurse nicht verfügbar — nur XMR-Betrag möglich', rates_offline: 'Kurse nicht verfügbar — nur XMR-Betrag möglich',
btn_monitor: 'Zahlung überwachen', btn_prove_payment: 'Zahlung nachweisen',
label_view_key: 'Privater View-Key', label_tx_hash: 'Transaction ID (TX Hash)',
placeholder_view_key: '64 Hex-Zeichen...', placeholder_tx_hash: '64 Hex-Zeichen...',
hint_view_key: 'Der View-Key verlässt nie deinen Browser', label_tx_key: 'Transaction Key (TX Key)',
btn_start_monitor: 'Überwachung starten', placeholder_tx_key: '64 Hex-Zeichen...',
btn_stop_monitor: 'Überwachung beenden', btn_verify_proof: 'Zahlung verifizieren',
monitor_connecting: 'Verbinde mit Node...', proof_verifying: 'Verifiziere...',
monitor_scanning: 'Scanne Mempool...', proof_verified: 'Zahlung bestätigt: {amount} XMR',
monitor_waiting: 'Warte auf Zahlung...', proof_no_match: 'Kein passender Output gefunden — TX Key oder Adresse stimmt nicht',
monitor_mempool: 'Zahlung erkannt (unbestätigt)', proof_tx_not_found: 'Transaktion nicht gefunden',
monitor_confirmed: 'Zahlung bestätigt', proof_error: 'Fehler bei der Verifizierung',
monitor_confirmations: '{n}/10 Bestätigungen', status_paid: 'Bezahlt'
monitor_underpaid: 'Unterzahlung erkannt',
monitor_underpaid_detail: 'Erwartet: {expected} XMR — Erhalten: {received} XMR',
monitor_node_error: 'Node nicht erreichbar — versuche nächsten...',
monitor_loading: 'Lade Kryptografie-Modul...'
}, },
en: { en: {
subtitle: 'Monero payment request in seconds', subtitle: 'Monero payment request in seconds',
@@ -68,22 +64,18 @@ var I18n = (function () {
countdown_remaining_days: 'Deadline: {d} days, {h}:{m}:{s}', countdown_remaining_days: 'Deadline: {d} days, {h}:{m}:{s}',
countdown_remaining_hours: 'Deadline: {h}:{m}:{s}', countdown_remaining_hours: 'Deadline: {h}:{m}:{s}',
rates_offline: 'Rates unavailable — XMR amount only', rates_offline: 'Rates unavailable — XMR amount only',
btn_monitor: 'Monitor payment', btn_prove_payment: 'Prove payment',
label_view_key: 'Private view key', label_tx_hash: 'Transaction ID (TX Hash)',
placeholder_view_key: '64 hex characters...', placeholder_tx_hash: '64 hex characters...',
hint_view_key: 'Your view key never leaves your browser', label_tx_key: 'Transaction Key (TX Key)',
btn_start_monitor: 'Start monitoring', placeholder_tx_key: '64 hex characters...',
btn_stop_monitor: 'Stop monitoring', btn_verify_proof: 'Verify payment',
monitor_connecting: 'Connecting to node...', proof_verifying: 'Verifying...',
monitor_scanning: 'Scanning mempool...', proof_verified: 'Payment confirmed: {amount} XMR',
monitor_waiting: 'Waiting for payment...', proof_no_match: 'No matching output found — TX key or address mismatch',
monitor_mempool: 'Payment detected (unconfirmed)', proof_tx_not_found: 'Transaction not found',
monitor_confirmed: 'Payment confirmed', proof_error: 'Verification error',
monitor_confirmations: '{n}/10 confirmations', status_paid: 'Paid'
monitor_underpaid: 'Underpayment detected',
monitor_underpaid_detail: 'Expected: {expected} XMR — Received: {received} XMR',
monitor_node_error: 'Node unreachable — trying next...',
monitor_loading: 'Loading crypto module...'
} }
}; };

View File

@@ -73,34 +73,32 @@
<a class="btn btn-secondary" id="openWallet" href="#" data-i18n="btn_open_wallet"></a> <a class="btn btn-secondary" id="openWallet" href="#" data-i18n="btn_open_wallet"></a>
<button class="btn btn-secondary" id="copyAddr" data-i18n="btn_copy_addr"></button> <button class="btn btn-secondary" id="copyAddr" data-i18n="btn_copy_addr"></button>
</div> </div>
<!-- Payment Monitor (v2) --> <!-- TX Proof Verification -->
<div class="monitor-section" id="monitorSection"> <div class="proof-section" id="proofSection">
<button class="btn btn-monitor" id="monitorToggle"> <button class="btn btn-monitor" id="proofToggle">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/> <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/> <polyline points="22 4 12 14.01 9 11.01"/>
</svg> </svg>
<span data-i18n="btn_monitor"></span> <span data-i18n="btn_prove_payment"></span>
</button> </button>
<div class="monitor-panel" id="monitorPanel"> <div class="proof-panel" id="proofPanel">
<div class="field"> <div class="field">
<label for="viewKey" data-i18n="label_view_key"></label> <label for="txHash" data-i18n="label_tx_hash"></label>
<input type="text" id="viewKey" data-i18n-placeholder="placeholder_view_key" spellcheck="false" autocomplete="off" class="mono-masked"> <input type="text" id="txHash" data-i18n-placeholder="placeholder_tx_hash" spellcheck="false" autocomplete="off">
<div class="view-key-hint" data-i18n="hint_view_key"></div>
</div> </div>
<button class="btn btn-primary" id="startMonitor" disabled data-i18n="btn_start_monitor"></button> <div class="field">
<div class="monitor-status" id="monitorStatus"> <label for="txKey" data-i18n="label_tx_key"></label>
<div class="status-indicator" id="statusIndicator"></div> <input type="text" id="txKey" data-i18n-placeholder="placeholder_tx_key" spellcheck="false" autocomplete="off">
<div class="status-text" id="statusText"></div>
<div class="confirmations-bar" id="confirmationsBar">
<div class="confirmations-fill" id="confirmationsFill"></div>
<span class="confirmations-text" id="confirmationsText"></span>
</div>
</div> </div>
<button class="btn btn-secondary" id="stopMonitor" data-i18n="btn_stop_monitor"></button> <button class="btn btn-primary" id="verifyProof" disabled data-i18n="btn_verify_proof"></button>
<div class="proof-result" id="proofResult"></div>
</div> </div>
</div> </div>
<!-- Payment status (shown when proof is stored) -->
<div class="payment-status" id="paymentStatus"></div>
<button class="btn btn-primary btn-new" id="newRequest" data-i18n="btn_new_request"></button> <button class="btn btn-primary btn-new" id="newRequest" data-i18n="btn_new_request"></button>
</div> </div>
</main> </main>
@@ -126,6 +124,5 @@
<script src="lib/qrcode.min.js"></script> <script src="lib/qrcode.min.js"></script>
<script src="i18n.js"></script> <script src="i18n.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
<script src="monitor.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,422 +0,0 @@
/**
* monitor.js — Monero Payment Monitor (v2)
* Uses XmrCrypto (noble-curves bundle) for output scanning.
* View key never leaves the browser.
*/
var PaymentMonitor = (function () {
'use strict';
var STATE = {
IDLE: 'idle',
CONNECTING: 'connecting',
SCANNING: 'scanning',
WAITING: 'waiting',
MEMPOOL: 'mempool',
CONFIRMED: 'confirmed',
UNDERPAID: 'underpaid',
ERROR: 'error'
};
var config = {
pollInterval: 30000, // 30s mempool polling
confirmPoll: 30000, // 30s confirmation polling
maxConfirmations: 10,
proxyUrl: '/api/node.php'
};
var state = STATE.IDLE;
var pollTimer = null;
var abortController = null;
var onStateChange = null;
// Monitoring params
var monitorAddr = null;
var monitorViewKey = null;
var monitorSpendKey = null;
var monitorViewKeyPub = null;
var expectedAmount = null; // in piconero (bigint)
var startHeight = 0;
var detectedTxHash = null;
var detectedAmount = 0n;
var lastConfirmations = 0;
function setState(newState, data) {
state = newState;
if (onStateChange) onStateChange(newState, data);
}
async function rpc(method, params) {
if (abortController && abortController.signal.aborted) throw new Error('Aborted');
var res = await fetch(config.proxyUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ method: method, params: params || {} }),
signal: abortController ? abortController.signal : undefined
});
if (!res.ok) throw new Error('RPC error: HTTP ' + res.status);
return res.json();
}
function start(address, privateViewKey, expectedXmr, callback) {
if (state !== STATE.IDLE) stop();
onStateChange = callback;
monitorAddr = address;
monitorViewKey = privateViewKey;
expectedAmount = xmrToPiconero(expectedXmr);
detectedTxHash = null;
detectedAmount = 0n;
lastConfirmations = 0;
abortController = new AbortController();
// Derive public keys from address
try {
var keys = XmrCrypto.getKeysFromAddress(address);
monitorSpendKey = keys.publicSpendKey;
monitorViewKeyPub = keys.publicViewKey;
} catch (e) {
setState(STATE.ERROR, { message: 'Invalid address' });
return;
}
// Validate view key against address (works for both standard and subaddress)
try {
var valid = XmrCrypto.validateViewKey(address, privateViewKey);
if (!valid) {
setState(STATE.ERROR, { message: I18n.t('monitor_view_key_invalid') });
return;
}
} catch (e) {
console.error('[monitor] View key validation error:', e);
setState(STATE.ERROR, { message: I18n.t('monitor_view_key_invalid') });
return;
}
setState(STATE.CONNECTING);
connectAndPoll();
}
async function connectAndPoll() {
try {
// Get current height
var info = await rpc('get_info');
var result = info.result || info;
// Scan 100 blocks back (~3.3 hours) to catch confirmed payments
startHeight = (result.height || result.target_height || 0) - 100;
setState(STATE.WAITING);
poll();
pollTimer = setInterval(poll, config.pollInterval);
} catch (e) {
setState(STATE.ERROR, { message: I18n.t('monitor_node_error') });
// Retry after delay
pollTimer = setTimeout(function () {
if (state === STATE.ERROR) connectAndPoll();
}, 5000);
}
}
async function poll() {
if (state === STATE.CONFIRMED) return;
try {
if (detectedTxHash) {
// We already found a TX, check confirmations
await checkConfirmations();
} else {
setState(STATE.SCANNING);
// Scan recent blocks first (catches already confirmed payments)
await scanRecentBlocks();
// Then scan mempool for unconfirmed payments
if (!detectedTxHash) {
await scanMempool();
}
if (!detectedTxHash) {
setState(STATE.WAITING);
}
}
} catch (e) {
if (e.name !== 'AbortError') {
console.warn('Monitor poll error:', e);
}
}
}
async function scanMempool() {
var pool = await rpc('get_transaction_pool');
var transactions = pool.transactions || [];
for (var i = 0; i < transactions.length; i++) {
var tx = transactions[i];
var txJson = tx.tx_json ? JSON.parse(tx.tx_json) : null;
if (!txJson) continue;
var match = scanTransaction(txJson, tx.id_hash);
if (match) {
detectedTxHash = match.txHash;
detectedAmount = match.amount;
reportDetection(0);
// Switch to confirmation polling
clearInterval(pollTimer);
pollTimer = setInterval(poll, config.confirmPoll);
return;
}
}
}
async function scanRecentBlocks() {
try {
var info = await rpc('get_info');
var result = info.result || info;
var currentHeight = result.height || 0;
var fromHeight = Math.max(startHeight, currentHeight - 100);
var batchSize = 10;
for (var batchStart = fromHeight; batchStart < currentHeight; batchStart += batchSize) {
var batchEnd = Math.min(batchStart + batchSize, currentHeight);
var allTxHashes = [];
var txHeightMap = {};
// Fetch blocks in this batch
for (var h = batchStart; h < batchEnd; h++) {
var blockData = await rpc('get_block', { height: h });
var blockResult = blockData.result || blockData;
var hashes = blockResult.tx_hashes || [];
for (var i = 0; i < hashes.length; i++) {
allTxHashes.push(hashes[i]);
txHeightMap[hashes[i]] = h;
}
}
if (allTxHashes.length === 0) continue;
// Fetch transactions in small sub-batches (restricted nodes limit response size)
var txBatchSize = 25;
for (var tbi = 0; tbi < allTxHashes.length; tbi += txBatchSize) {
var subBatch = allTxHashes.slice(tbi, tbi + txBatchSize);
var txData = await rpc('gettransactions', {
txs_hashes: subBatch,
decode_as_json: true
});
var txs = txData.txs || [];
if (txs.length === 0) {
continue;
}
for (var j = 0; j < txs.length; j++) {
var tx = txs[j];
var txJson = tx.as_json ? JSON.parse(tx.as_json) : null;
if (!txJson) continue;
var match = scanTransaction(txJson, tx.tx_hash);
if (match) {
detectedTxHash = match.txHash;
detectedAmount = match.amount;
var txHeight = txHeightMap[tx.tx_hash] || 0;
var confirmations = txHeight > 0 ? currentHeight - txHeight : 0;
reportDetection(confirmations);
clearInterval(pollTimer);
if (confirmations < config.maxConfirmations) {
pollTimer = setInterval(poll, config.confirmPoll);
}
return;
}
}
}
}
} catch (e) {
console.warn('Block scan error:', e);
}
}
function scanTransaction(txJson, txHash) {
// Extract tx public keys from extra
var extraHex = '';
if (txJson.extra) {
if (typeof txJson.extra === 'string') {
extraHex = txJson.extra;
} else if (Array.isArray(txJson.extra)) {
extraHex = txJson.extra.map(function (b) {
return ('0' + (b & 0xff).toString(16)).slice(-2);
}).join('');
}
}
var txPubKeys = XmrCrypto.parseTxExtra(extraHex);
if (txPubKeys.length === 0) {
return null;
}
// Get outputs
var outputs = [];
if (txJson.vout) {
outputs = txJson.vout;
}
// Get encrypted amounts from RingCT
var ecdhInfo = [];
if (txJson.rct_signatures && txJson.rct_signatures.ecdhInfo) {
ecdhInfo = txJson.rct_signatures.ecdhInfo;
}
var totalAmount = 0n;
var found = false;
for (var ki = 0; ki < txPubKeys.length; ki++) {
var txPubKey = txPubKeys[ki];
for (var oi = 0; oi < outputs.length; oi++) {
var out = outputs[oi];
var outputKey = null;
if (out.target && out.target.tagged_key) {
outputKey = out.target.tagged_key.key;
} else if (out.target && out.target.key) {
outputKey = out.target.key;
}
if (!outputKey) continue;
var encryptedAmount = null;
if (ecdhInfo[oi] && ecdhInfo[oi].amount) {
encryptedAmount = ecdhInfo[oi].amount;
}
try {
var result = XmrCrypto.checkOutput(
txPubKey, oi, outputKey, encryptedAmount,
monitorViewKey, monitorSpendKey
);
if (result.match) {
totalAmount += result.amount;
found = true;
}
} catch (e) {
}
}
}
if (found) {
return { txHash: txHash, amount: totalAmount };
}
return null;
}
function reportDetection(confirmations) {
lastConfirmations = confirmations;
if (expectedAmount > 0n && detectedAmount < expectedAmount) {
setState(STATE.UNDERPAID, {
expected: piconeroToXmr(expectedAmount),
received: piconeroToXmr(detectedAmount),
confirmations: confirmations,
txHash: detectedTxHash
});
} else if (confirmations >= config.maxConfirmations) {
setState(STATE.CONFIRMED, {
amount: piconeroToXmr(detectedAmount),
confirmations: confirmations,
txHash: detectedTxHash
});
} else {
setState(STATE.MEMPOOL, {
amount: piconeroToXmr(detectedAmount),
confirmations: confirmations,
txHash: detectedTxHash
});
}
}
async function checkConfirmations() {
try {
var txData = await rpc('gettransactions', {
txs_hashes: [detectedTxHash],
decode_as_json: true
});
var txs = txData.txs || [];
if (txs.length === 0) return;
var tx = txs[0];
var confirmations = 0;
if (tx.in_pool) {
confirmations = 0;
} else if (tx.block_height) {
var info = await rpc('get_info');
var result = info.result || info;
var currentHeight = result.height || 0;
confirmations = currentHeight - tx.block_height;
}
reportDetection(confirmations);
if (confirmations >= config.maxConfirmations) {
clearInterval(pollTimer);
pollTimer = null;
}
} catch (e) {
console.warn('Confirmation check error:', e);
}
}
function stop() {
if (pollTimer) {
clearInterval(pollTimer);
clearTimeout(pollTimer);
pollTimer = null;
}
if (abortController) {
abortController.abort();
abortController = null;
}
state = STATE.IDLE;
monitorViewKey = null;
monitorSpendKey = null;
detectedTxHash = null;
detectedAmount = 0n;
onStateChange = null;
}
function xmrToPiconero(xmr) {
if (!xmr || xmr <= 0) return 0n;
return BigInt(Math.round(xmr * 1e12));
}
function piconeroToXmr(piconero) {
return Number(piconero) / 1e12;
}
function isValidViewKey(key) {
return /^[0-9a-fA-F]{64}$/.test(key);
}
function getState() {
return state;
}
return {
start: start,
stop: stop,
isValidViewKey: isValidViewKey,
getState: getState,
STATE: STATE
};
})();

4
s.php
View File

@@ -22,7 +22,7 @@ if (!isset($urls[$code])) {
exit; exit;
} }
$hash = $urls[$code]; $hash = $urls[$code]['hash'] ?? $urls[$code];
$base = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST']; $base = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'];
header('Location: ' . $base . '/#' . $hash, true, 302); header('Location: ' . $base . '/#' . $hash . '&c=' . $code, true, 302);
exit; exit;

125
style.css
View File

@@ -407,9 +407,9 @@ textarea {
flex: 1; flex: 1;
} }
/* --- Monitor Section --- */ /* --- TX Proof Section --- */
.monitor-section { .proof-section {
margin-top: 1rem; margin-top: 1rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
padding-top: 0.8rem; padding-top: 0.8rem;
@@ -438,123 +438,64 @@ textarea {
color: var(--text); color: var(--text);
} }
.monitor-panel { .proof-panel {
display: none; display: none;
margin-top: 0.8rem; margin-top: 0.8rem;
} }
.monitor-panel.open { .proof-panel.open {
display: block; display: block;
} }
.mono-masked { .proof-result {
-webkit-text-security: disc;
}
.view-key-hint {
font-size: 0.7rem;
color: var(--text-muted);
margin-top: 0.25rem;
font-style: italic;
}
.monitor-status {
display: none; display: none;
text-align: center; text-align: center;
padding: 1rem 0; padding: 0.8rem;
border-radius: var(--radius);
font-size: 0.85rem;
margin-top: 0.8rem;
} }
.monitor-status.active { .proof-result.active {
display: block; display: block;
} }
.status-indicator { .proof-result.success {
width: 12px; background: rgba(76, 175, 80, 0.15);
height: 12px; color: var(--success);
border-radius: 50%; border: 1px solid var(--success);
margin: 0 auto 0.5rem;
background: var(--text-muted);
} }
.status-indicator.connecting, .proof-result.error {
.status-indicator.scanning { background: rgba(244, 67, 54, 0.15);
background: var(--accent); color: var(--error);
animation: pulse 1.5s ease-in-out infinite; border: 1px solid var(--error);
} }
.status-indicator.waiting { .payment-status {
background: var(--accent); display: none;
animation: pulse 2s ease-in-out infinite;
} }
.status-indicator.mempool { .payment-status.paid {
background: #ffc107; display: block;
animation: pulse 1s ease-in-out infinite; text-align: center;
margin-top: 1rem;
} }
.status-indicator.confirmed { .paid-badge {
display: inline-block;
background: var(--success); background: var(--success);
animation: none; color: #fff;
} padding: 0.4rem 1.2rem;
.status-indicator.underpaid {
background: var(--error);
animation: pulse 1s ease-in-out infinite;
}
.status-indicator.error {
background: var(--error);
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
}
.status-text {
font-size: 0.9rem;
color: var(--text);
margin-bottom: 0.5rem;
}
.confirmations-bar {
display: none;
position: relative;
height: 24px;
background: var(--bg-input);
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; font-weight: 700;
margin-bottom: 0.8rem; font-size: 1rem;
margin-bottom: 0.3rem;
} }
.confirmations-bar.active { .paid-detail {
display: block;
}
.confirmations-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--success));
border-radius: var(--radius);
transition: width 0.5s ease;
width: 0%;
}
.confirmations-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; color: var(--text-muted);
color: var(--text);
}
#stopMonitor {
display: none;
}
#stopMonitor.active {
display: block;
} }
.btn-new { .btn-new {

3
sw.js
View File

@@ -1,10 +1,9 @@
var CACHE_NAME = 'xmrpay-v2'; var CACHE_NAME = 'xmrpay-v3';
var ASSETS = [ var ASSETS = [
'/', '/',
'/index.html', '/index.html',
'/app.js', '/app.js',
'/i18n.js', '/i18n.js',
'/monitor.js',
'/style.css', '/style.css',
'/lib/qrcode.min.js' '/lib/qrcode.min.js'
// xmr-crypto.bundle.js is lazy-loaded and runtime-cached // xmr-crypto.bundle.js is lazy-loaded and runtime-cached