- Payment monitor: enter private view key to track incoming payments - Scans mempool + last 100 blocks via PHP proxy with 4-node failover - Lightweight crypto: 30KB noble-curves bundle (Ed25519 + Keccak-256) - Subaddress support (network byte 42 detection, a*D validation) - Confirmation progress bar (0-10 confirmations) - Underpayment detection - Deadline badges (7/14/30 days) replacing minutes input - QR code: standard colors (black on white) for wallet scanner compatibility - QR hint positioned below QR code - View key masked input, never stored or transmitted
547 lines
17 KiB
JavaScript
547 lines
17 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
// --- Config ---
|
|
const COINGECKO_API = 'https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=eur,usd,chf';
|
|
// 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
|
|
|
|
// --- State ---
|
|
let fiatRates = null;
|
|
let ratesTimestamp = 0;
|
|
let countdownInterval = null;
|
|
let ratesFailed = false;
|
|
|
|
// --- 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');
|
|
|
|
// 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');
|
|
let cryptoLoaded = false;
|
|
|
|
// --- Init ---
|
|
fetchRates();
|
|
loadFromHash() || loadSaved();
|
|
registerSW();
|
|
|
|
// --- 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;
|
|
});
|
|
|
|
// Monitor events
|
|
monitorToggle.addEventListener('click', toggleMonitor);
|
|
viewKeyInput.addEventListener('input', validateViewKey);
|
|
startMonitorBtn.addEventListener('click', startMonitoring);
|
|
stopMonitorBtn.addEventListener('click', stopMonitoring);
|
|
|
|
// --- 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 = '';
|
|
uriBox.textContent = '';
|
|
shareLinkInput.value = '';
|
|
// Reset monitor
|
|
stopMonitoring();
|
|
monitorPanel.classList.remove('open');
|
|
viewKeyInput.value = '';
|
|
viewKeyInput.classList.remove('valid', 'invalid');
|
|
startMonitorBtn.disabled = true;
|
|
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 {
|
|
const res = await fetch('/api/shorten.php', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ hash: hash })
|
|
});
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
const data = await res.json();
|
|
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.href = uri;
|
|
|
|
// Share link — show long URL immediately, then replace with short
|
|
const hash = buildHash(addr, xmrAmount, desc, timer);
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Auto-generate
|
|
setTimeout(generate, 100);
|
|
return true;
|
|
}
|
|
|
|
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);
|
|
const s = Math.floor((remaining % 60000) / 1000);
|
|
if (d > 0) {
|
|
countdownEl.textContent = I18n.t('countdown_remaining_days')
|
|
.replace('{d}', d).replace('{h}', pad(h)).replace('{m}', pad(m)).replace('{s}', pad(s));
|
|
} else {
|
|
countdownEl.textContent = I18n.t('countdown_remaining_hours')
|
|
.replace('{h}', pad(h)).replace('{m}', pad(m)).replace('{s}', pad(s));
|
|
}
|
|
}
|
|
|
|
tick();
|
|
countdownInterval = setInterval(tick, 1000);
|
|
}
|
|
|
|
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 () {});
|
|
}
|
|
}
|
|
|
|
// --- Monitor Functions (v2) ---
|
|
|
|
function toggleMonitor() {
|
|
const panel = monitorPanel;
|
|
const isOpen = panel.classList.contains('open');
|
|
if (isOpen) {
|
|
panel.classList.remove('open');
|
|
return;
|
|
}
|
|
// Lazy-load crypto bundle
|
|
if (!cryptoLoaded && !window.XmrCrypto) {
|
|
loadCryptoBundle().then(function () {
|
|
cryptoLoaded = true;
|
|
panel.classList.add('open');
|
|
viewKeyInput.focus();
|
|
});
|
|
return;
|
|
}
|
|
panel.classList.add('open');
|
|
viewKeyInput.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'));
|
|
};
|
|
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 startMonitoring() {
|
|
const viewKey = viewKeyInput.value.trim();
|
|
if (!PaymentMonitor.isValidViewKey(viewKey)) return;
|
|
|
|
const addr = addrInput.value.trim();
|
|
const xmrAmount = getXmrAmount() || 0;
|
|
|
|
// Hide input, show status
|
|
startMonitorBtn.style.display = 'none';
|
|
viewKeyInput.closest('.field').style.display = 'none';
|
|
monitorStatus.classList.add('active');
|
|
stopMonitorBtn.classList.add('active');
|
|
|
|
PaymentMonitor.start(addr, viewKey, xmrAmount, function (newState, data) {
|
|
updateMonitorUI(newState, data);
|
|
});
|
|
}
|
|
|
|
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 = '';
|
|
}
|
|
|
|
function updateMonitorUI(monitorState, data) {
|
|
const S = PaymentMonitor.STATE;
|
|
statusIndicator.className = 'status-indicator ' + monitorState;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
})();
|