diff --git a/README.md b/README.md index 899e224..b6ad3cc 100644 --- a/README.md +++ b/README.md @@ -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] 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] Browser pollt Remote Node via PHP-Proxy mit Failover (4 Nodes) -- [x] Live-Anzeige: "Warte auf Zahlung..." → "Zahlung eingegangen (X/10 Bestätigungen)" -- [x] Fortschrittsbalken für Bestätigungen -- [x] Unterzahlungs-Erkennung +- [x] Sender gibt TX Hash + TX Key ein (aus Wallet kopiert) +- [x] Kryptografische Verifizierung im Browser (30KB noble-curves Bundle) +- [x] Zahlungsstatus wird dauerhaft mit Rechnung gespeichert +- [x] Rechnungs-Link zeigt "Bezahlt" Badge nach Verifizierung - [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 diff --git a/api/verify.php b/api/verify.php new file mode 100644 index 0000000..5eacf65 --- /dev/null +++ b/api/verify.php @@ -0,0 +1,86 @@ + 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]); diff --git a/app.js b/app.js index 1bff9b8..cba8d85 100644 --- a/app.js +++ b/app.js @@ -38,19 +38,14 @@ const newRequestBtn = $('#newRequest'); const homeLink = $('#homeLink'); - // Monitor DOM - const monitorSection = $('#monitorSection'); - const monitorToggle = $('#monitorToggle'); - const monitorPanel = $('#monitorPanel'); - const viewKeyInput = $('#viewKey'); - const startMonitorBtn = $('#startMonitor'); - const stopMonitorBtn = $('#stopMonitor'); - const monitorStatus = $('#monitorStatus'); - const statusIndicator = $('#statusIndicator'); - const statusText = $('#statusText'); - const confirmationsBar = $('#confirmationsBar'); - const confirmationsFill = $('#confirmationsFill'); - const confirmationsText = $('#confirmationsText'); + // TX Proof DOM + const proofToggle = $('#proofToggle'); + const proofPanel = $('#proofPanel'); + const txHashInput = $('#txHash'); + const txKeyInput = $('#txKey'); + const verifyProofBtn = $('#verifyProof'); + const proofResult = $('#proofResult'); + const paymentStatus = $('#paymentStatus'); let cryptoLoaded = false; // --- Init --- @@ -90,11 +85,11 @@ selectedDays = parseInt(timerCustom.value) || 0; }); - // Monitor events - monitorToggle.addEventListener('click', toggleMonitor); - viewKeyInput.addEventListener('input', validateViewKey); - startMonitorBtn.addEventListener('click', startMonitoring); - stopMonitorBtn.addEventListener('click', stopMonitoring); + // TX Proof events + proofToggle.addEventListener('click', toggleProofPanel); + txHashInput.addEventListener('input', validateProofInputs); + txKeyInput.addEventListener('input', validateProofInputs); + verifyProofBtn.addEventListener('click', verifyTxProof); // --- Functions --- @@ -115,12 +110,15 @@ qrContainer.innerHTML = ''; uriBox.textContent = ''; shareLinkInput.value = ''; - // Reset monitor - stopMonitoring(); - monitorPanel.classList.remove('open'); - viewKeyInput.value = ''; - viewKeyInput.classList.remove('valid', 'invalid'); - startMonitorBtn.disabled = true; + // Reset proof + proofPanel.classList.remove('open'); + txHashInput.value = ''; + txKeyInput.value = ''; + verifyProofBtn.disabled = true; + proofResult.innerHTML = ''; + proofResult.className = 'proof-result'; + paymentStatus.innerHTML = ''; + paymentStatus.className = 'payment-status'; history.replaceState(null, '', location.pathname); window.scrollTo({ top: 0, behavior: 'smooth' }); 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 setTimeout(generate, 100); return true; @@ -413,134 +417,160 @@ } } - // --- Monitor Functions (v2) --- + // --- TX Proof Functions --- - function toggleMonitor() { - const panel = monitorPanel; - const isOpen = panel.classList.contains('open'); + function toggleProofPanel() { + const isOpen = proofPanel.classList.contains('open'); if (isOpen) { - panel.classList.remove('open'); + proofPanel.classList.remove('open'); return; } // Lazy-load crypto bundle if (!cryptoLoaded && !window.XmrCrypto) { loadCryptoBundle().then(function () { cryptoLoaded = true; - panel.classList.add('open'); - viewKeyInput.focus(); + proofPanel.classList.add('open'); + txHashInput.focus(); }); return; } - panel.classList.add('open'); - viewKeyInput.focus(); + proofPanel.classList.add('open'); + txHashInput.focus(); } function loadCryptoBundle() { return new Promise(function (resolve, reject) { if (window.XmrCrypto) { resolve(); return; } - statusText.textContent = I18n.t('monitor_loading'); - monitorStatus.classList.add('active'); const script = document.createElement('script'); script.src = 'lib/xmr-crypto.bundle.js'; - script.onload = function () { - monitorStatus.classList.remove('active'); - resolve(); - }; - script.onerror = function () { - monitorStatus.classList.remove('active'); - reject(new Error('Failed to load crypto module')); - }; + script.onload = resolve; + script.onerror = function () { reject(new Error('Failed to load crypto module')); }; document.head.appendChild(script); }); } - function validateViewKey() { - const key = viewKeyInput.value.trim(); - 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 isValidHex64(val) { + return /^[0-9a-fA-F]{64}$/.test(val); } - function startMonitoring() { - const viewKey = viewKeyInput.value.trim(); - if (!PaymentMonitor.isValidViewKey(viewKey)) return; + function validateProofInputs() { + const hash = txHashInput.value.trim(); + 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 xmrAmount = getXmrAmount() || 0; + if (!isValidHex64(txHash) || !isValidHex64(txKey) || !isValidAddress(addr)) return; - // Hide input, show status - startMonitorBtn.style.display = 'none'; - viewKeyInput.closest('.field').style.display = 'none'; - monitorStatus.classList.add('active'); - stopMonitorBtn.classList.add('active'); + verifyProofBtn.disabled = true; + proofResult.className = 'proof-result active'; + proofResult.textContent = I18n.t('proof_verifying'); - PaymentMonitor.start(addr, viewKey, xmrAmount, function (newState, data) { - updateMonitorUI(newState, data); - }); - } + try { + // 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() { - PaymentMonitor.stop(); - 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 = ''; - } + var tx = txs[0]; + var txJson = JSON.parse(tx.as_json); - function updateMonitorUI(monitorState, data) { - const S = PaymentMonitor.STATE; - statusIndicator.className = 'status-indicator ' + monitorState; + // Get keys from address + var keys = XmrCrypto.getKeysFromAddress(addr); + var pubViewKey = keys.publicViewKey; + var pubSpendKey = keys.publicSpendKey; - switch (monitorState) { - case S.CONNECTING: - statusText.textContent = I18n.t('monitor_connecting'); - confirmationsBar.classList.remove('active'); - break; - case S.SCANNING: - statusText.textContent = I18n.t('monitor_scanning'); - break; - case S.WAITING: - statusText.textContent = I18n.t('monitor_waiting'); - break; - case S.MEMPOOL: - statusText.textContent = I18n.t('monitor_mempool'); - showConfirmations(data.confirmations); - break; - case S.CONFIRMED: - statusText.textContent = I18n.t('monitor_confirmed'); - showConfirmations(data.confirmations); - stopMonitorBtn.classList.remove('active'); - break; - case S.UNDERPAID: - statusText.textContent = I18n.t('monitor_underpaid'); - var detail = I18n.t('monitor_underpaid_detail') - .replace('{expected}', data.expected.toFixed(6)) - .replace('{received}', data.received.toFixed(6)); - statusText.textContent += '\n' + detail; - showConfirmations(data.confirmations); - break; - case S.ERROR: - statusText.textContent = data.message || I18n.t('monitor_node_error'); - break; + // Key derivation: D = 8 * txKey * pubViewKey + var r = XmrCrypto.bytesToScalar(XmrCrypto.hexToBytes(txKey)); + var A = XmrCrypto.Point.fromHex(pubViewKey); + var D = A.multiply(r).multiply(8n); + var derivation = D.toBytes(); + + var B = XmrCrypto.Point.fromHex(pubSpendKey); + + // Check each output + var outputs = txJson.vout || []; + var ecdhInfo = (txJson.rct_signatures && txJson.rct_signatures.ecdhInfo) || []; + var totalAmount = 0n; + var found = false; + + for (var oi = 0; oi < outputs.length; oi++) { + var out = outputs[oi]; + var outputKey = out.target && out.target.tagged_key ? out.target.tagged_key.key : (out.target && out.target.key); + if (!outputKey) continue; + + var varint = XmrCrypto.encodeVarint(oi); + var scalar = XmrCrypto.hashToScalar(XmrCrypto.concat(derivation, varint)); + var scBig = XmrCrypto.bytesToScalar(scalar); + var expectedP = XmrCrypto.Point.BASE.multiply(scBig).add(B); + var expectedHex = XmrCrypto.bytesToHex(expectedP.toBytes()); + + if (expectedHex === outputKey) { + found = true; + // Decode amount + if (ecdhInfo[oi] && ecdhInfo[oi].amount) { + var amount = XmrCrypto.decodeRctAmount(ecdhInfo[oi].amount, derivation, oi); + 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) { - confirmationsBar.classList.add('active'); - var pct = Math.min(100, (n / 10) * 100); - confirmationsFill.style.width = pct + '%'; - confirmationsText.textContent = I18n.t('monitor_confirmations').replace('{n}', n); + // Load payment status if viewing via short URL + function loadPaymentStatus(code) { + fetch('/api/verify.php?code=' + encodeURIComponent(code)) + .then(function (res) { return res.json(); }) + .then(function (data) { + if (data.verified) { + paymentStatus.className = 'payment-status paid'; + paymentStatus.innerHTML = ''; + } + }) + .catch(function () {}); } })(); diff --git a/i18n.js b/i18n.js index c154d55..5d8bd44 100644 --- a/i18n.js +++ b/i18n.js @@ -29,22 +29,18 @@ var I18n = (function () { countdown_remaining_days: 'Zahlungsfrist: {d} Tage, {h}:{m}:{s}', countdown_remaining_hours: 'Zahlungsfrist: {h}:{m}:{s}', rates_offline: 'Kurse nicht verfügbar — nur XMR-Betrag möglich', - btn_monitor: 'Zahlung überwachen', - label_view_key: 'Privater View-Key', - placeholder_view_key: '64 Hex-Zeichen...', - hint_view_key: 'Der View-Key verlässt nie deinen Browser', - btn_start_monitor: 'Überwachung starten', - btn_stop_monitor: 'Überwachung beenden', - monitor_connecting: 'Verbinde mit Node...', - monitor_scanning: 'Scanne Mempool...', - monitor_waiting: 'Warte auf Zahlung...', - monitor_mempool: 'Zahlung erkannt (unbestätigt)', - monitor_confirmed: 'Zahlung bestätigt', - monitor_confirmations: '{n}/10 Bestätigungen', - 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...' + btn_prove_payment: 'Zahlung nachweisen', + label_tx_hash: 'Transaction ID (TX Hash)', + placeholder_tx_hash: '64 Hex-Zeichen...', + label_tx_key: 'Transaction Key (TX Key)', + placeholder_tx_key: '64 Hex-Zeichen...', + btn_verify_proof: 'Zahlung verifizieren', + proof_verifying: 'Verifiziere...', + proof_verified: 'Zahlung bestätigt: {amount} XMR', + proof_no_match: 'Kein passender Output gefunden — TX Key oder Adresse stimmt nicht', + proof_tx_not_found: 'Transaktion nicht gefunden', + proof_error: 'Fehler bei der Verifizierung', + status_paid: 'Bezahlt' }, en: { subtitle: 'Monero payment request in seconds', @@ -68,22 +64,18 @@ var I18n = (function () { countdown_remaining_days: 'Deadline: {d} days, {h}:{m}:{s}', countdown_remaining_hours: 'Deadline: {h}:{m}:{s}', rates_offline: 'Rates unavailable — XMR amount only', - btn_monitor: 'Monitor payment', - label_view_key: 'Private view key', - placeholder_view_key: '64 hex characters...', - hint_view_key: 'Your view key never leaves your browser', - btn_start_monitor: 'Start monitoring', - btn_stop_monitor: 'Stop monitoring', - monitor_connecting: 'Connecting to node...', - monitor_scanning: 'Scanning mempool...', - monitor_waiting: 'Waiting for payment...', - monitor_mempool: 'Payment detected (unconfirmed)', - monitor_confirmed: 'Payment confirmed', - monitor_confirmations: '{n}/10 confirmations', - 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...' + btn_prove_payment: 'Prove payment', + label_tx_hash: 'Transaction ID (TX Hash)', + placeholder_tx_hash: '64 hex characters...', + label_tx_key: 'Transaction Key (TX Key)', + placeholder_tx_key: '64 hex characters...', + btn_verify_proof: 'Verify payment', + proof_verifying: 'Verifying...', + proof_verified: 'Payment confirmed: {amount} XMR', + proof_no_match: 'No matching output found — TX key or address mismatch', + proof_tx_not_found: 'Transaction not found', + proof_error: 'Verification error', + status_paid: 'Paid' } }; diff --git a/index.html b/index.html index 420a0f2..42b5b15 100644 --- a/index.html +++ b/index.html @@ -73,34 +73,32 @@ - -
- -
+
- - -
+ +
- -
-
-
-
-
- -
+
+ +
- + +
+ +
+
@@ -126,6 +124,5 @@ - diff --git a/monitor.js b/monitor.js deleted file mode 100644 index 3feafdb..0000000 --- a/monitor.js +++ /dev/null @@ -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 - }; -})(); diff --git a/s.php b/s.php index 204882c..495de73 100644 --- a/s.php +++ b/s.php @@ -22,7 +22,7 @@ if (!isset($urls[$code])) { exit; } -$hash = $urls[$code]; +$hash = $urls[$code]['hash'] ?? $urls[$code]; $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; diff --git a/style.css b/style.css index 363b66a..5aa6eda 100644 --- a/style.css +++ b/style.css @@ -407,9 +407,9 @@ textarea { flex: 1; } -/* --- Monitor Section --- */ +/* --- TX Proof Section --- */ -.monitor-section { +.proof-section { margin-top: 1rem; border-top: 1px solid var(--border); padding-top: 0.8rem; @@ -438,123 +438,64 @@ textarea { color: var(--text); } -.monitor-panel { +.proof-panel { display: none; margin-top: 0.8rem; } -.monitor-panel.open { +.proof-panel.open { display: block; } -.mono-masked { - -webkit-text-security: disc; -} - -.view-key-hint { - font-size: 0.7rem; - color: var(--text-muted); - margin-top: 0.25rem; - font-style: italic; -} - -.monitor-status { +.proof-result { display: none; 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; } -.status-indicator { - width: 12px; - height: 12px; - border-radius: 50%; - margin: 0 auto 0.5rem; - background: var(--text-muted); +.proof-result.success { + background: rgba(76, 175, 80, 0.15); + color: var(--success); + border: 1px solid var(--success); } -.status-indicator.connecting, -.status-indicator.scanning { - background: var(--accent); - animation: pulse 1.5s ease-in-out infinite; +.proof-result.error { + background: rgba(244, 67, 54, 0.15); + color: var(--error); + border: 1px solid var(--error); } -.status-indicator.waiting { - background: var(--accent); - animation: pulse 2s ease-in-out infinite; +.payment-status { + display: none; } -.status-indicator.mempool { - background: #ffc107; - animation: pulse 1s ease-in-out infinite; +.payment-status.paid { + display: block; + text-align: center; + margin-top: 1rem; } -.status-indicator.confirmed { +.paid-badge { + display: inline-block; background: var(--success); - animation: none; -} - -.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); + color: #fff; + padding: 0.4rem 1.2rem; border-radius: var(--radius); - overflow: hidden; - margin-bottom: 0.8rem; + font-weight: 700; + font-size: 1rem; + margin-bottom: 0.3rem; } -.confirmations-bar.active { - 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%); +.paid-detail { font-size: 0.75rem; - font-weight: 600; - color: var(--text); -} - -#stopMonitor { - display: none; -} - -#stopMonitor.active { - display: block; + color: var(--text-muted); } .btn-new { diff --git a/sw.js b/sw.js index a3d3845..9a56707 100644 --- a/sw.js +++ b/sw.js @@ -1,10 +1,9 @@ -var CACHE_NAME = 'xmrpay-v2'; +var CACHE_NAME = 'xmrpay-v3'; var ASSETS = [ '/', '/index.html', '/app.js', '/i18n.js', - '/monitor.js', '/style.css', '/lib/qrcode.min.js' // xmr-crypto.bundle.js is lazy-loaded and runtime-cached