(function () { 'use strict'; // --- Config --- const COINGECKO_API = '/api/rates.php?c=eur,usd,chf,gbp,jpy,rub,brl'; // Standard address (4..., 95 chars), Subaddress (8..., 95 chars), Integrated address (4..., 106 chars) const XMR_STANDARD_REGEX = /^[48][1-9A-HJ-NP-Za-km-z]{94}$/; 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; let ratesTimestamp = 0; let countdownInterval = null; 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); const addrInput = $('#addr'); const amountInput = $('#amount'); const currencySelect = $('#currency'); const descInput = $('#desc'); const timerCustom = $('#timerCustom'); const deadlineBadges = $('#deadlineBadges'); let selectedDays = 0; const generateBtn = $('#generate'); const resultSection = $('#result'); const qrContainer = $('#qr'); const uriBox = $('#uri'); const openWalletBtn = $('#openWallet'); const copyAddrBtn = $('#copyAddr'); const countdownEl = $('#countdown'); const fiatHint = $('#fiatHint'); const toast = $('#toast'); const shareLinkInput = $('#shareLink'); const copyShareLinkBtn = $('#copyShareLink'); const newRequestBtn = $('#newRequest'); const homeLink = $('#homeLink'); // TX Proof DOM const proofToggle = $('#proofToggle'); const proofPanel = $('#proofPanel'); const txHashInput = $('#txHash'); const txKeyInput = $('#txKey'); const verifyProofBtn = $('#verifyProof'); const proofResult = $('#proofResult'); const paymentStatus = $('#paymentStatus'); const paymentSummary = $('#paymentSummary'); const downloadPdfBtn = $('#downloadPdf'); let cryptoLoaded = false; let pdfLoaded = false; let lastPaidData = null; // --- Currency Detection --- function detectCurrency() { var localeToCurrency = { 'de': 'EUR', 'fr': 'EUR', 'it': 'EUR', 'es': 'EUR', 'pt': 'EUR', 'nl': 'EUR', 'de-CH': 'CHF', 'fr-CH': 'CHF', 'it-CH': 'CHF', 'de-AT': 'EUR', 'en-GB': 'GBP', 'en-US': 'USD', 'en': 'USD', 'ja': 'JPY', 'ru': 'RUB', 'pt-BR': 'BRL' }; var langs = navigator.languages || [navigator.language || 'en']; for (var i = 0; i < langs.length; i++) { var tag = langs[i]; if (localeToCurrency[tag]) { currencySelect.value = localeToCurrency[tag]; return; } var short = tag.substring(0, 2).toLowerCase(); if (localeToCurrency[short]) { currencySelect.value = localeToCurrency[short]; return; } } } // --- Init --- detectCurrency(); fetchRates(); loadFromHash() || loadSaved(); registerSW(); // Re-render dynamic texts on language change I18n.onChange(function () { // QR hint var hint = qrContainer.querySelector('.qr-hint'); if (hint) hint.textContent = I18n.t('qr_hint'); // Paid stamp var stamp = qrContainer.querySelector('.paid-stamp'); if (stamp) stamp.textContent = I18n.t('status_paid'); // Paid detail if (lastPaidData) { showPaidStatus(lastPaidData); } // Summary if (resultSection.classList.contains('visible')) { var xmrAmount = getXmrAmount(); var desc = descInput.value.trim(); buildSummary(xmrAmount, desc, selectedDays); updatePageTitle(xmrAmount, desc); } // Countdown text if (countdownTick) countdownTick(); }); // --- Events --- addrInput.addEventListener('input', validateAddress); amountInput.addEventListener('input', updateFiatHint); currencySelect.addEventListener('change', updateFiatHint); generateBtn.addEventListener('click', generate); copyAddrBtn.addEventListener('click', () => copyToClipboard(addrInput.value.trim())); copyShareLinkBtn.addEventListener('click', () => copyToClipboard(shareLinkInput.value)); qrContainer.addEventListener('click', downloadQR); newRequestBtn.addEventListener('click', resetForm); homeLink.addEventListener('click', function (e) { e.preventDefault(); resetForm(); }); // Deadline badge events deadlineBadges.querySelectorAll('.badge').forEach(function (btn) { btn.addEventListener('click', function () { const days = parseInt(btn.getAttribute('data-days')); if (btn.classList.contains('active')) { btn.classList.remove('active'); selectedDays = 0; timerCustom.value = ''; } else { deadlineBadges.querySelectorAll('.badge').forEach(function (b) { b.classList.remove('active'); }); btn.classList.add('active'); selectedDays = days; timerCustom.value = ''; } }); }); timerCustom.addEventListener('input', function () { deadlineBadges.querySelectorAll('.badge').forEach(function (b) { b.classList.remove('active'); }); selectedDays = parseInt(timerCustom.value) || 0; }); // PDF downloadPdfBtn.addEventListener('click', generatePdf); // TX Proof events proofToggle.addEventListener('click', toggleProofPanel); txHashInput.addEventListener('input', validateProofInputs); txKeyInput.addEventListener('input', validateProofInputs); verifyProofBtn.addEventListener('click', verifyTxProof); // --- Functions --- function resetForm() { addrInput.value = ''; amountInput.value = ''; currencySelect.value = 'EUR'; descInput.value = ''; selectedDays = 0; timerCustom.value = ''; deadlineBadges.querySelectorAll('.badge').forEach(function (b) { b.classList.remove('active'); }); fiatHint.textContent = ''; fiatHint.classList.remove('error'); addrInput.classList.remove('valid', 'invalid'); generateBtn.disabled = true; 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 = ''; verifyProofBtn.disabled = true; proofResult.innerHTML = ''; proofResult.className = 'proof-result'; paymentStatus.innerHTML = ''; paymentStatus.className = 'payment-status'; paymentSummary.innerHTML = ''; document.title = 'xmrpay.link \u2014 Monero Invoice Generator'; history.replaceState(null, '', location.pathname); window.scrollTo({ top: 0, behavior: 'smooth' }); addrInput.focus(); } function isValidAddress(addr) { return XMR_STANDARD_REGEX.test(addr) || XMR_INTEGRATED_REGEX.test(addr); } function validateAddress() { const val = addrInput.value.trim(); addrInput.classList.remove('valid', 'invalid'); if (val.length === 0) return; if (isValidAddress(val)) { addrInput.classList.add('valid'); } else if (val.length >= 10) { addrInput.classList.add('invalid'); } updateGenerateBtn(); } function updateGenerateBtn() { const addr = addrInput.value.trim(); generateBtn.disabled = !isValidAddress(addr); } function updateFiatHint() { const amount = parseFloat(amountInput.value); const currency = currencySelect.value; if (!amount || amount <= 0) { fiatHint.textContent = ''; fiatHint.classList.remove('error'); return; } if (currency !== 'XMR' && !fiatRates) { fiatHint.textContent = ratesFailed ? I18n.t('rates_offline') : ''; fiatHint.classList.toggle('error', ratesFailed); return; } fiatHint.classList.remove('error'); if (currency === 'XMR') { if (fiatRates) { const eur = (amount * fiatRates.eur).toFixed(2); fiatHint.textContent = '\u2248 ' + eur + ' EUR'; } else { fiatHint.textContent = ''; } } else { const rate = fiatRates[currency.toLowerCase()]; if (rate && rate > 0) { const xmr = (amount / rate).toFixed(8); fiatHint.textContent = '\u2248 ' + xmr + ' XMR'; } } } function getXmrAmount() { const amount = parseFloat(amountInput.value); const currency = currencySelect.value; if (!amount || amount <= 0) return null; if (currency === 'XMR') return amount; if (fiatRates) { const rate = fiatRates[currency.toLowerCase()]; if (rate && rate > 0) return amount / rate; } return null; } function buildUri(addr, xmrAmount, desc) { let uri = 'monero:' + addr; const params = []; if (xmrAmount) params.push('tx_amount=' + xmrAmount.toFixed(12)); if (desc) params.push('tx_description=' + encodeURIComponent(desc)); if (params.length) uri += '?' + params.join('&'); return uri; } function buildHash(addr, xmrAmount, desc, timer) { const params = new URLSearchParams(); params.set('a', addr); if (xmrAmount) params.set('x', xmrAmount.toFixed(12)); if (desc) params.set('d', desc); if (timer) params.set('t', timer); return params.toString(); } async function shortenUrl(hash) { try { // Calculate expiry timestamp if deadline is set let expiryTs = null; if (selectedDays && selectedDays > 0) { expiryTs = Math.floor((Date.now() + selectedDays * 86400000) / 1000); } const res = await fetch('/api/shorten.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hash: hash, expiry_ts: expiryTs }) }); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); if (!invoiceCode) invoiceCode = data.code; return location.origin + '/s/' + data.code; } catch (e) { console.warn('Short URL failed:', e); return null; } } function generate() { const addr = addrInput.value.trim(); if (!isValidAddress(addr)) return; const xmrAmount = getXmrAmount(); const desc = descInput.value.trim(); const timer = selectedDays; const uri = buildUri(addr, xmrAmount, desc); // Show result resultSection.classList.add('visible'); uriBox.textContent = uri; openWalletBtn.onclick = function () { window.location.href = uri; }; // Payment summary + page title buildSummary(xmrAmount, desc, timer); updatePageTitle(xmrAmount, desc); // Share link — keep existing short URL if present; otherwise shorten new hash const hash = buildHash(addr, xmrAmount, desc, timer); if (invoiceCode) { shareLinkInput.value = location.origin + '/s/' + invoiceCode; } else { shareLinkInput.value = location.origin + '/#' + hash; shortenUrl(hash).then(function (shortUrl) { if (shortUrl) shareLinkInput.value = shortUrl; }); } // QR qrContainer.innerHTML = ''; new QRCode(qrContainer, { text: uri, width: 256, height: 256, colorDark: '#000000', colorLight: '#ffffff', correctLevel: QRCode.CorrectLevel.M }); const hint = document.createElement('div'); hint.className = 'qr-hint'; hint.textContent = I18n.t('qr_hint'); qrContainer.appendChild(hint); // Countdown startCountdown(); // Save to LocalStorage saveState(addr); // Scroll to result resultSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function loadFromHash() { const hash = location.hash.substring(1); if (!hash) return false; const params = new URLSearchParams(hash); const addr = params.get('a'); if (!addr || !isValidAddress(addr)) return false; addrInput.value = addr; validateAddress(); const xmr = params.get('x'); if (xmr) { amountInput.value = parseFloat(xmr); currencySelect.value = 'XMR'; } const desc = params.get('d'); if (desc) descInput.value = desc; const timer = params.get('t'); if (timer && parseInt(timer) > 0) { selectedDays = parseInt(timer); // Activate matching badge or set custom const badge = deadlineBadges.querySelector('.badge[data-days="' + selectedDays + '"]'); if (badge) { badge.classList.add('active'); } else { timerCustom.value = selectedDays; } } // Check for short URL code and load payment status const code = params.get('c'); if (code) { invoiceCode = code; // Verify short URL integrity (detect tampering) setTimeout(function () { verifyShortUrlIntegrity(code, hash); loadPaymentStatus(code); }, 200); } // Auto-generate setTimeout(generate, 100); return true; } // Verify that the redirected hash still matches the stored short URL mapping. function verifyShortUrlIntegrity(code, currentHash) { fetch('/api/check-short.php?code=' + encodeURIComponent(code)) .then(function (res) { if (!res.ok) throw new Error('Integrity check failed'); return res.json(); }) .then(function (data) { if (!data.hash) { return; } var params = new URLSearchParams(currentHash); params.delete('c'); var normalizedHash = params.toString(); if (data.hash !== normalizedHash) { console.warn('xmrpay: Short URL hash mismatch detected for code', code); showToast(I18n.t('toast_integrity_warning')); } }) .catch(function (e) { console.warn('xmrpay: Could not verify short URL integrity:', e); }); } function buildSummary(xmrAmount, desc, days) { var html = ''; if (xmrAmount) { html += '
' + xmrAmount.toFixed(8) + ' XMR
'; var amount = parseFloat(amountInput.value); var currency = currencySelect.value; if (currency !== 'XMR' && amount) { html += '
\u2248 ' + amount.toFixed(2) + ' ' + currency + '
'; } } if (desc) { html += '
' + desc.replace(/'; } paymentSummary.innerHTML = html; paymentSummary.classList.remove('paid-confirmed'); resetFavicon(); } function updatePageTitle(xmrAmount, desc) { var parts = []; if (xmrAmount) parts.push(xmrAmount.toFixed(4) + ' XMR'); if (desc) parts.push(desc); if (parts.length) { document.title = parts.join(' — ') + ' | xmrpay.link'; } } function startCountdown() { if (countdownInterval) clearInterval(countdownInterval); countdownEl.textContent = ''; countdownEl.className = 'countdown'; if (!selectedDays || selectedDays <= 0) return; const end = Date.now() + selectedDays * 86400000; countdownEl.classList.add('active'); function tick() { const remaining = end - Date.now(); if (remaining <= 0) { clearInterval(countdownInterval); countdownEl.textContent = I18n.t('countdown_expired'); countdownEl.className = 'countdown expired'; return; } const d = Math.floor(remaining / 86400000); const h = Math.floor((remaining % 86400000) / 3600000); const m = Math.floor((remaining % 3600000) / 60000); if (d > 0) { countdownEl.textContent = I18n.t('countdown_remaining_days') .replace('{d}', d).replace('{h}', h); } else { countdownEl.textContent = I18n.t('countdown_remaining_hours') .replace('{h}', pad(h)).replace('{m}', pad(m)); } } countdownTick = tick; tick(); countdownInterval = setInterval(tick, 60000); // Update every minute, not every second } function pad(n) { return n < 10 ? '0' + n : '' + n; } function downloadQR() { const canvas = qrContainer.querySelector('canvas'); if (!canvas) return; const link = document.createElement('a'); link.download = 'xmrpay-qr.png'; link.href = canvas.toDataURL('image/png'); link.click(); } function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => { showToast(I18n.t('toast_copied')); }); } function showToast(msg) { toast.textContent = msg; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 2000); } function saveState(addr) { try { localStorage.setItem('xmrpay_addr', addr); } catch (e) { /* silent */ } } function loadSaved() { try { const addr = localStorage.getItem('xmrpay_addr'); if (addr) { addrInput.value = addr; validateAddress(); } } catch (e) { /* silent */ } } async function fetchRates() { if (fiatRates && Date.now() - ratesTimestamp < CACHE_DURATION) return; try { const res = await fetch(COINGECKO_API); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); fiatRates = data.monero; ratesTimestamp = Date.now(); ratesFailed = false; updateFiatHint(); } catch (e) { console.warn('Kurse konnten nicht geladen werden:', e); ratesFailed = true; updateFiatHint(); setTimeout(fetchRates, RATE_RETRY_DELAY); } } function registerSW() { if ('serviceWorker' in navigator) { navigator.serviceWorker.register('sw.js').catch(function () {}); } } // --- PDF Invoice --- function loadJsPdf() { return new Promise(function (resolve, reject) { if (window.jspdf) { resolve(); return; } var script = document.createElement('script'); script.src = 'lib/jspdf.min.js'; script.onload = function () { pdfLoaded = true; resolve(); }; script.onerror = function () { reject(new Error('Failed to load jsPDF')); }; document.head.appendChild(script); }); } async function generatePdf() { await loadJsPdf(); var jsPDF = window.jspdf.jsPDF; var doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); var addr = addrInput.value.trim(); var xmrAmount = getXmrAmount(); var desc = descInput.value.trim(); var amount = parseFloat(amountInput.value); var currency = currencySelect.value; var pageW = doc.internal.pageSize.getWidth(); var margin = 20; var contentW = pageW - margin * 2; var y = margin; // --- Header: Orange accent bar --- doc.setFillColor(242, 104, 33); doc.rect(0, 0, pageW, 8, 'F'); // --- Title --- y = 22; doc.setFont('helvetica', 'bold'); doc.setFontSize(22); doc.setTextColor(242, 104, 33); doc.text(I18n.t('pdf_title'), margin, y); // --- Date (top right) --- doc.setFont('helvetica', 'normal'); doc.setFontSize(10); doc.setTextColor(120, 120, 120); var dateStr = new Date().toLocaleDateString(I18n.getLang() === 'de' ? 'de-CH' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric' }); doc.text(I18n.t('pdf_date') + ': ' + dateStr, pageW - margin, y, { align: 'right' }); // --- Divider --- y += 6; doc.setDrawColor(220, 220, 220); doc.setLineWidth(0.3); doc.line(margin, y, pageW - margin, y); // --- QR Code (right side) --- var qrCanvas = qrContainer.querySelector('canvas'); var qrSize = 50; var qrX = pageW - margin - qrSize; var qrY = y + 6; if (qrCanvas) { var qrData = qrCanvas.toDataURL('image/png'); doc.addImage(qrData, 'PNG', qrX, qrY, qrSize, qrSize); // QR hint doc.setFontSize(7); doc.setTextColor(150, 150, 150); doc.text(I18n.t('pdf_scan_qr'), qrX + qrSize / 2, qrY + qrSize + 4, { align: 'center' }); } // --- Invoice details (left side, next to QR) --- var detailX = margin; var detailW = qrX - margin - 10; y += 14; function addField(label, value) { doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(150, 150, 150); doc.text(label, detailX, y); y += 5; doc.setFont('helvetica', 'bold'); doc.setFontSize(11); doc.setTextColor(40, 40, 40); var lines = doc.splitTextToSize(value, detailW); doc.text(lines, detailX, y); y += lines.length * 5 + 4; } // Amount if (xmrAmount) { var amountStr = xmrAmount.toFixed(8) + ' XMR'; if (currency !== 'XMR' && amount) { amountStr += ' (~ ' + amount.toFixed(2) + ' ' + currency + ')'; } addField(I18n.t('pdf_amount'), amountStr); } // Description if (desc) { addField(I18n.t('pdf_desc'), desc); } // Deadline if (selectedDays > 0) { var deadlineDate = new Date(Date.now() + selectedDays * 86400000); var deadlineStr = deadlineDate.toLocaleDateString(I18n.getLang() === 'de' ? 'de-CH' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric' }); addField(I18n.t('pdf_deadline'), deadlineStr + ' (' + I18n.t('pdf_deadline_days').replace('{d}', selectedDays) + ')'); } // Address (below QR if needed, full width) y = Math.max(y, qrY + qrSize + 12); doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(150, 150, 150); doc.text(I18n.t('pdf_address'), margin, y); y += 5; // Address in monospace box doc.setFillColor(245, 245, 245); doc.roundedRect(margin, y - 3.5, contentW, 10, 2, 2, 'F'); doc.setFont('courier', 'normal'); doc.setFontSize(8); doc.setTextColor(60, 60, 60); doc.text(addr, margin + 3, y + 2.5); y += 14; // monero: URI var uri = uriBox.textContent; if (uri) { doc.setFillColor(245, 245, 245); doc.roundedRect(margin, y - 3.5, contentW, 10, 2, 2, 'F'); doc.setFont('courier', 'normal'); doc.setFontSize(6.5); doc.setTextColor(100, 100, 100); var uriLines = doc.splitTextToSize(uri, contentW - 6); doc.text(uriLines, margin + 3, y + 2); y += uriLines.length * 3 + 10; } // --- Payment Status --- if (lastPaidData) { y += 4; var paidDateStr = ''; if (lastPaidData.verified_at) { var pd = new Date(lastPaidData.verified_at * 1000); paidDateStr = pd.toLocaleDateString(I18n.getLang() === 'de' ? 'de-CH' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric' }); } var paidLine = lastPaidData.amount.toFixed(6) + ' XMR — TX ' + lastPaidData.tx_hash.substring(0, 8) + '...' + (paidDateStr ? ' — ' + paidDateStr : ''); doc.setFillColor(76, 175, 80); doc.roundedRect(margin, y - 4, contentW, 16, 2, 2, 'F'); doc.setFont('helvetica', 'bold'); doc.setFontSize(12); doc.setTextColor(255, 255, 255); doc.text(I18n.t('status_paid').toUpperCase(), margin + contentW / 2, y + 1, { align: 'center' }); doc.setFont('helvetica', 'normal'); doc.setFontSize(7.5); doc.text(paidLine, margin + contentW / 2, y + 7, { align: 'center' }); y += 22; } // --- Footer --- doc.setDrawColor(220, 220, 220); doc.setLineWidth(0.3); var footerY = doc.internal.pageSize.getHeight() - 15; doc.line(margin, footerY, pageW - margin, footerY); doc.setFont('helvetica', 'normal'); doc.setFontSize(7); doc.setTextColor(180, 180, 180); doc.text(I18n.t('pdf_footer'), pageW / 2, footerY + 5, { align: 'center' }); // Share link var shareLink = shareLinkInput.value; if (shareLink) { doc.text(shareLink, pageW / 2, footerY + 9, { align: 'center' }); } // Save var filename = 'xmrpay-' + (desc ? desc.replace(/[^a-zA-Z0-9]/g, '-').substring(0, 30) : 'invoice') + '.pdf'; doc.save(filename); } // --- TX Proof Functions --- function toggleProofPanel() { const isOpen = proofPanel.classList.contains('open'); if (isOpen) { proofPanel.classList.remove('open'); return; } // Lazy-load crypto bundle if (!cryptoLoaded && !window.XmrCrypto) { loadCryptoBundle().then(function () { cryptoLoaded = true; proofPanel.classList.add('open'); txHashInput.focus(); }); return; } proofPanel.classList.add('open'); txHashInput.focus(); } function loadCryptoBundle() { return new Promise(function (resolve, reject) { if (window.XmrCrypto) { resolve(); return; } const script = document.createElement('script'); script.src = 'lib/xmr-crypto.bundle.js'; script.onload = resolve; script.onerror = function () { reject(new Error('Failed to load crypto module')); }; document.head.appendChild(script); }); } function isValidHex64(val) { return /^[0-9a-fA-F]{64}$/.test(val); } 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(); if (!isValidHex64(txHash) || !isValidHex64(txKey) || !isValidAddress(addr)) return; verifyProofBtn.disabled = true; proofResult.className = 'proof-result active'; proofResult.textContent = I18n.t('proof_verifying'); 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; } var tx = txs[0]; var txJson = JSON.parse(tx.as_json); // Get keys from address var keys = XmrCrypto.getKeysFromAddress(addr); var pubViewKey = keys.publicViewKey; var pubSpendKey = keys.publicSpendKey; // 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; 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)); if (invoiceCode) { await fetch('/api/verify.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: invoiceCode, tx_hash: txHash, amount: xmrAmount, confirmations: confs, status: 'paid' }) }); } showPaidStatus({ amount: xmrAmount, tx_hash: txHash, confirmations: confs }); } else { proofResult.className = 'proof-result active warning'; proofResult.textContent = I18n.t('proof_confirmed_pending') .replace('{amount}', xmrAmount.toFixed(6)).replace('{n}', confs); if (invoiceCode) { await fetch('/api/verify.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: invoiceCode, tx_hash: txHash, amount: xmrAmount, confirmations: confs, status: 'pending' }) }); } showPendingStatus({ amount: xmrAmount, tx_hash: txHash, confirmations: confs }); startConfirmationPolling(txHash, xmrAmount); } } 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; } // 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) 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); } }) .catch(function () {}); } function showPaidStatus(data) { if (!data.verified_at) { data = Object.assign({}, data, { verified_at: Math.floor(Date.now() / 1000) }); } paymentStatus.className = 'payment-status paid'; // Stamp over QR + dim QR qrContainer.classList.add('paid'); var existingStamp = qrContainer.querySelector('.paid-stamp'); if (!existingStamp) { var stamp = document.createElement('div'); stamp.className = 'paid-stamp'; stamp.textContent = I18n.t('status_paid'); qrContainer.appendChild(stamp); } else { existingStamp.textContent = I18n.t('status_paid'); } // Replace QR hint with payment detail var hint = qrContainer.querySelector('.qr-hint'); if (hint) { var dateStr = ''; if (data.verified_at) { var d = new Date(data.verified_at * 1000); dateStr = ' — ' + d.toLocaleDateString(I18n.getLang() === 'de' ? 'de-CH' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric' }); } hint.textContent = 'TX ' + data.tx_hash.substring(0, 8) + '...' + dateStr; hint.className = 'qr-hint paid-info'; } paymentStatus.innerHTML = ''; lastPaidData = data; paymentSummary.classList.add('paid-confirmed'); // Hide unnecessary buttons when paid openWalletBtn.style.display = 'none'; document.getElementById('copyAddr').style.display = 'none'; var proofSection = document.getElementById('proofSection'); if (proofSection) proofSection.style.display = 'none'; setPaidFavicon(); } function showPendingStatus(data) { var confs = data.confirmations || 0; paymentStatus.className = 'payment-status pending'; paymentSummary.classList.remove('paid-confirmed'); qrContainer.classList.add('confirming'); setPendingFavicon(); 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.textContent = 'TX ' + data.tx_hash.substring(0, 8) + '... — ' + (confs === 0 ? I18n.t('status_pending') : (confs + '/10')); hint.className = 'qr-hint pending-info'; } } 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 getBaseFaviconHref() { var link = document.getElementById('favicon'); if (!link) return 'favicon.svg'; var baseHref = link.getAttribute('data-base-href') || link.getAttribute('href') || 'favicon.svg'; if (!link.getAttribute('data-base-href')) { link.setAttribute('data-base-href', baseHref); } return baseHref; } function resetFavicon() { var link = document.getElementById('favicon'); if (!link) return; link.href = getBaseFaviconHref(); } function setStatusFaviconBadge(color) { var canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 32; var ctx = canvas.getContext('2d'); // Draw current base favicon and overlay status badge var img = new Image(); img.onload = function () { ctx.drawImage(img, 0, 0, 32, 32); ctx.beginPath(); ctx.arc(25, 25, 7, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); ctx.beginPath(); ctx.arc(25, 25, 5.5, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); var link = document.getElementById('favicon'); link.href = canvas.toDataURL('image/png'); }; img.src = getBaseFaviconHref(); } function setPaidFavicon() { setStatusFaviconBadge('#4caf50'); } function setPendingFavicon() { setStatusFaviconBadge('#f59e0b'); } })();