feat: v2 — view-key payment confirmation with live monitoring
- 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
This commit is contained in:
14
README.md
14
README.md
@@ -67,13 +67,15 @@ 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 — View-Key Zahlungsbestätigung (Browser-basiert) ✅
|
||||||
|
|
||||||
- [ ] View-Only-Key eingeben (privater Spend-Key bleibt lokal)
|
- [x] Private View-Key eingeben (validiert gegen Adresse, verlässt nie den Browser)
|
||||||
- [ ] Browser pollt Remote Node via Monero RPC (kein eigener Node nötig)
|
- [x] Browser pollt Remote Node via PHP-Proxy mit Failover (4 Nodes)
|
||||||
- [ ] Live-Anzeige: "Warte auf Zahlung..." → "✅ Zahlung eingegangen (X Bestätigungen)"
|
- [x] Live-Anzeige: "Warte auf Zahlung..." → "Zahlung eingegangen (X/10 Bestätigungen)"
|
||||||
- [ ] Warnhinweis bei Unterzahlung
|
- [x] Fortschrittsbalken für Bestätigungen
|
||||||
- [ ] Subaddress-Unterstützung (für mehrere parallele Rechnungen)
|
- [x] Unterzahlungs-Erkennung
|
||||||
|
- [x] Standard- und Subaddress-Unterstützung
|
||||||
|
- [x] Leichtgewichtige Krypto (30KB noble-curves Bundle, kein 5MB WASM)
|
||||||
|
|
||||||
### v3 — Professionelle Features
|
### v3 — Professionelle Features
|
||||||
|
|
||||||
|
|||||||
133
api/node.php
Normal file
133
api/node.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Monero Daemon RPC Proxy
|
||||||
|
* Forwards allowed RPC requests to Monero nodes, bypassing CORS.
|
||||||
|
* The private view key NEVER passes through this proxy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Only POST
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nodes in priority order
|
||||||
|
$NODES = [
|
||||||
|
'http://node.xmr.rocks:18089',
|
||||||
|
'http://node.community.rino.io:18081',
|
||||||
|
'http://node.sethforprivacy.com:18089',
|
||||||
|
'http://xmr-node.cakewallet.com:18081',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Allowed RPC methods (whitelist)
|
||||||
|
$ALLOWED_JSON_RPC = ['get_info', 'get_block', 'get_block_header_by_height'];
|
||||||
|
$ALLOWED_HTTP = ['get_transaction_pool', 'gettransactions', 'get_transaction_pool_hashes.bin'];
|
||||||
|
|
||||||
|
// Rate limiting (simple file-based, 60 requests/minute per IP)
|
||||||
|
$RATE_DIR = __DIR__ . '/../data/rate/';
|
||||||
|
if (!is_dir($RATE_DIR)) @mkdir($RATE_DIR, 0755, true);
|
||||||
|
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||||
|
$rateFile = $RATE_DIR . md5($ip) . '.json';
|
||||||
|
$now = time();
|
||||||
|
$rateData = [];
|
||||||
|
|
||||||
|
if (file_exists($rateFile)) {
|
||||||
|
$rateData = json_decode(file_get_contents($rateFile), true) ?: [];
|
||||||
|
// Clean old entries
|
||||||
|
$rateData = array_filter($rateData, fn($t) => $t > $now - 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($rateData) >= 1000) {
|
||||||
|
http_response_code(429);
|
||||||
|
echo json_encode(['error' => 'Rate limit exceeded']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rateData[] = $now;
|
||||||
|
file_put_contents($rateFile, json_encode($rateData));
|
||||||
|
|
||||||
|
// Parse request
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!$input || !isset($input['method'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Missing method']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = $input['method'];
|
||||||
|
$params = $input['params'] ?? [];
|
||||||
|
|
||||||
|
// Determine endpoint type
|
||||||
|
$isJsonRpc = in_array($method, $ALLOWED_JSON_RPC);
|
||||||
|
$isHttp = in_array($method, $ALLOWED_HTTP);
|
||||||
|
|
||||||
|
if (!$isJsonRpc && !$isHttp) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['error' => 'Method not allowed: ' . $method]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache last working node
|
||||||
|
$cacheFile = __DIR__ . '/../data/node_cache.json';
|
||||||
|
$cachedNode = null;
|
||||||
|
if (file_exists($cacheFile)) {
|
||||||
|
$cache = json_decode(file_get_contents($cacheFile), true);
|
||||||
|
if ($cache && ($cache['time'] ?? 0) > $now - 300) {
|
||||||
|
$cachedNode = $cache['node'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order nodes: cached first
|
||||||
|
$orderedNodes = $NODES;
|
||||||
|
if ($cachedNode && in_array($cachedNode, $NODES)) {
|
||||||
|
$orderedNodes = array_merge([$cachedNode], array_filter($NODES, fn($n) => $n !== $cachedNode));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try nodes
|
||||||
|
$lastError = '';
|
||||||
|
foreach ($orderedNodes as $node) {
|
||||||
|
if ($isJsonRpc) {
|
||||||
|
$url = $node . '/json_rpc';
|
||||||
|
$body = json_encode([
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'id' => '0',
|
||||||
|
'method' => $method,
|
||||||
|
'params' => (object)$params
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$url = $node . '/' . $method;
|
||||||
|
$body = json_encode((object)$params);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $body,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||||
|
CURLOPT_TIMEOUT => 15,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($response !== false && $httpCode >= 200 && $httpCode < 300) {
|
||||||
|
// Cache this working node
|
||||||
|
file_put_contents($cacheFile, json_encode(['node' => $node, 'time' => $now]));
|
||||||
|
echo $response;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastError = $curlError ?: "HTTP $httpCode";
|
||||||
|
}
|
||||||
|
|
||||||
|
// All nodes failed
|
||||||
|
http_response_code(502);
|
||||||
|
echo json_encode(['error' => 'All nodes unreachable', 'detail' => $lastError]);
|
||||||
221
app.js
221
app.js
@@ -21,7 +21,9 @@
|
|||||||
const amountInput = $('#amount');
|
const amountInput = $('#amount');
|
||||||
const currencySelect = $('#currency');
|
const currencySelect = $('#currency');
|
||||||
const descInput = $('#desc');
|
const descInput = $('#desc');
|
||||||
const timerInput = $('#timer');
|
const timerCustom = $('#timerCustom');
|
||||||
|
const deadlineBadges = $('#deadlineBadges');
|
||||||
|
let selectedDays = 0;
|
||||||
const generateBtn = $('#generate');
|
const generateBtn = $('#generate');
|
||||||
const resultSection = $('#result');
|
const resultSection = $('#result');
|
||||||
const qrContainer = $('#qr');
|
const qrContainer = $('#qr');
|
||||||
@@ -36,6 +38,21 @@
|
|||||||
const newRequestBtn = $('#newRequest');
|
const newRequestBtn = $('#newRequest');
|
||||||
const homeLink = $('#homeLink');
|
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 ---
|
// --- Init ---
|
||||||
fetchRates();
|
fetchRates();
|
||||||
loadFromHash() || loadSaved();
|
loadFromHash() || loadSaved();
|
||||||
@@ -52,6 +69,33 @@
|
|||||||
newRequestBtn.addEventListener('click', resetForm);
|
newRequestBtn.addEventListener('click', resetForm);
|
||||||
homeLink.addEventListener('click', function (e) { e.preventDefault(); 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 ---
|
// --- Functions ---
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
@@ -59,7 +103,9 @@
|
|||||||
amountInput.value = '';
|
amountInput.value = '';
|
||||||
currencySelect.value = 'EUR';
|
currencySelect.value = 'EUR';
|
||||||
descInput.value = '';
|
descInput.value = '';
|
||||||
timerInput.value = '';
|
selectedDays = 0;
|
||||||
|
timerCustom.value = '';
|
||||||
|
deadlineBadges.querySelectorAll('.badge').forEach(function (b) { b.classList.remove('active'); });
|
||||||
fiatHint.textContent = '';
|
fiatHint.textContent = '';
|
||||||
fiatHint.classList.remove('error');
|
fiatHint.classList.remove('error');
|
||||||
addrInput.classList.remove('valid', 'invalid');
|
addrInput.classList.remove('valid', 'invalid');
|
||||||
@@ -69,6 +115,12 @@
|
|||||||
qrContainer.innerHTML = '';
|
qrContainer.innerHTML = '';
|
||||||
uriBox.textContent = '';
|
uriBox.textContent = '';
|
||||||
shareLinkInput.value = '';
|
shareLinkInput.value = '';
|
||||||
|
// Reset monitor
|
||||||
|
stopMonitoring();
|
||||||
|
monitorPanel.classList.remove('open');
|
||||||
|
viewKeyInput.value = '';
|
||||||
|
viewKeyInput.classList.remove('valid', 'invalid');
|
||||||
|
startMonitorBtn.disabled = true;
|
||||||
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();
|
||||||
@@ -184,7 +236,7 @@
|
|||||||
|
|
||||||
const xmrAmount = getXmrAmount();
|
const xmrAmount = getXmrAmount();
|
||||||
const desc = descInput.value.trim();
|
const desc = descInput.value.trim();
|
||||||
const timer = parseInt(timerInput.value) || 0;
|
const timer = selectedDays;
|
||||||
const uri = buildUri(addr, xmrAmount, desc);
|
const uri = buildUri(addr, xmrAmount, desc);
|
||||||
|
|
||||||
// Show result
|
// Show result
|
||||||
@@ -205,8 +257,8 @@
|
|||||||
text: uri,
|
text: uri,
|
||||||
width: 256,
|
width: 256,
|
||||||
height: 256,
|
height: 256,
|
||||||
colorDark: '#ffffff',
|
colorDark: '#000000',
|
||||||
colorLight: '#1a1a1a',
|
colorLight: '#ffffff',
|
||||||
correctLevel: QRCode.CorrectLevel.M
|
correctLevel: QRCode.CorrectLevel.M
|
||||||
});
|
});
|
||||||
const hint = document.createElement('div');
|
const hint = document.createElement('div');
|
||||||
@@ -245,7 +297,16 @@
|
|||||||
if (desc) descInput.value = desc;
|
if (desc) descInput.value = desc;
|
||||||
|
|
||||||
const timer = params.get('t');
|
const timer = params.get('t');
|
||||||
if (timer && parseInt(timer) > 0) timerInput.value = timer;
|
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
|
// Auto-generate
|
||||||
setTimeout(generate, 100);
|
setTimeout(generate, 100);
|
||||||
@@ -257,10 +318,9 @@
|
|||||||
countdownEl.textContent = '';
|
countdownEl.textContent = '';
|
||||||
countdownEl.className = 'countdown';
|
countdownEl.className = 'countdown';
|
||||||
|
|
||||||
const minutes = parseInt(timerInput.value);
|
if (!selectedDays || selectedDays <= 0) return;
|
||||||
if (!minutes || minutes <= 0) return;
|
|
||||||
|
|
||||||
const end = Date.now() + minutes * 60000;
|
const end = Date.now() + selectedDays * 86400000;
|
||||||
countdownEl.classList.add('active');
|
countdownEl.classList.add('active');
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
@@ -271,9 +331,17 @@
|
|||||||
countdownEl.className = 'countdown expired';
|
countdownEl.className = 'countdown expired';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const m = Math.floor(remaining / 60000);
|
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);
|
const s = Math.floor((remaining % 60000) / 1000);
|
||||||
countdownEl.textContent = I18n.t('countdown_remaining') + pad(m) + ':' + pad(s);
|
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();
|
tick();
|
||||||
@@ -344,4 +412,135 @@
|
|||||||
navigator.serviceWorker.register('sw.js').catch(function () {});
|
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);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
52
i18n.js
52
i18n.js
@@ -14,8 +14,9 @@ var I18n = (function () {
|
|||||||
label_amount: 'Betrag',
|
label_amount: 'Betrag',
|
||||||
label_desc: 'Beschreibung (optional)',
|
label_desc: 'Beschreibung (optional)',
|
||||||
placeholder_desc: 'z.B. Rechnung #42, Freelance-Arbeit...',
|
placeholder_desc: 'z.B. Rechnung #42, Freelance-Arbeit...',
|
||||||
label_timer: 'Zahlungsfrist in Minuten (optional)',
|
label_timer: 'Zahlungsfrist (optional)',
|
||||||
placeholder_timer: 'z.B. 30',
|
days: 'Tage',
|
||||||
|
placeholder_timer_custom: 'Tage',
|
||||||
btn_generate: 'Zahlungsanforderung erstellen',
|
btn_generate: 'Zahlungsanforderung erstellen',
|
||||||
btn_open_wallet: 'In Wallet öffnen',
|
btn_open_wallet: 'In Wallet öffnen',
|
||||||
btn_copy_addr: 'Adresse kopieren',
|
btn_copy_addr: 'Adresse kopieren',
|
||||||
@@ -25,8 +26,25 @@ var I18n = (function () {
|
|||||||
btn_new_request: 'Neue Zahlungsanforderung',
|
btn_new_request: 'Neue Zahlungsanforderung',
|
||||||
toast_copied: 'Kopiert!',
|
toast_copied: 'Kopiert!',
|
||||||
countdown_expired: 'Zahlungsfrist abgelaufen',
|
countdown_expired: 'Zahlungsfrist abgelaufen',
|
||||||
countdown_remaining: 'Zahlungsfrist: ',
|
countdown_remaining_days: 'Zahlungsfrist: {d} Tage, {h}:{m}:{s}',
|
||||||
rates_offline: 'Kurse nicht verfügbar — nur XMR-Betrag möglich'
|
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...'
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
subtitle: 'Monero payment request in seconds',
|
subtitle: 'Monero payment request in seconds',
|
||||||
@@ -35,8 +53,9 @@ var I18n = (function () {
|
|||||||
label_amount: 'Amount',
|
label_amount: 'Amount',
|
||||||
label_desc: 'Description (optional)',
|
label_desc: 'Description (optional)',
|
||||||
placeholder_desc: 'e.g. Invoice #42, freelance work...',
|
placeholder_desc: 'e.g. Invoice #42, freelance work...',
|
||||||
label_timer: 'Payment deadline in minutes (optional)',
|
label_timer: 'Payment deadline (optional)',
|
||||||
placeholder_timer: 'e.g. 30',
|
days: 'days',
|
||||||
|
placeholder_timer_custom: 'Days',
|
||||||
btn_generate: 'Create payment request',
|
btn_generate: 'Create payment request',
|
||||||
btn_open_wallet: 'Open in wallet',
|
btn_open_wallet: 'Open in wallet',
|
||||||
btn_copy_addr: 'Copy address',
|
btn_copy_addr: 'Copy address',
|
||||||
@@ -46,8 +65,25 @@ var I18n = (function () {
|
|||||||
btn_new_request: 'New payment request',
|
btn_new_request: 'New payment request',
|
||||||
toast_copied: 'Copied!',
|
toast_copied: 'Copied!',
|
||||||
countdown_expired: 'Payment deadline expired',
|
countdown_expired: 'Payment deadline expired',
|
||||||
countdown_remaining: 'Payment deadline: ',
|
countdown_remaining_days: 'Deadline: {d} days, {h}:{m}:{s}',
|
||||||
rates_offline: 'Rates unavailable — XMR amount only'
|
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...'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
38
index.html
38
index.html
@@ -44,8 +44,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="timer" data-i18n="label_timer"></label>
|
<label data-i18n="label_timer"></label>
|
||||||
<input type="number" id="timer" data-i18n-placeholder="placeholder_timer" min="1" step="1">
|
<div class="deadline-badges" id="deadlineBadges">
|
||||||
|
<button type="button" class="badge" data-days="7">7 <span data-i18n="days"></span></button>
|
||||||
|
<button type="button" class="badge" data-days="14">14 <span data-i18n="days"></span></button>
|
||||||
|
<button type="button" class="badge" data-days="30">30 <span data-i18n="days"></span></button>
|
||||||
|
<input type="number" id="timerCustom" data-i18n-placeholder="placeholder_timer_custom" min="1" step="1" class="badge-input">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-primary" id="generate" disabled data-i18n="btn_generate"></button>
|
<button class="btn btn-primary" id="generate" disabled data-i18n="btn_generate"></button>
|
||||||
@@ -68,6 +73,34 @@
|
|||||||
<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) -->
|
||||||
|
<div class="monitor-section" id="monitorSection">
|
||||||
|
<button class="btn btn-monitor" id="monitorToggle">
|
||||||
|
<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="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
<span data-i18n="btn_monitor"></span>
|
||||||
|
</button>
|
||||||
|
<div class="monitor-panel" id="monitorPanel">
|
||||||
|
<div class="field">
|
||||||
|
<label for="viewKey" data-i18n="label_view_key"></label>
|
||||||
|
<input type="text" id="viewKey" data-i18n-placeholder="placeholder_view_key" spellcheck="false" autocomplete="off" class="mono-masked">
|
||||||
|
<div class="view-key-hint" data-i18n="hint_view_key"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" id="startMonitor" disabled data-i18n="btn_start_monitor"></button>
|
||||||
|
<div class="monitor-status" id="monitorStatus">
|
||||||
|
<div class="status-indicator" id="statusIndicator"></div>
|
||||||
|
<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>
|
||||||
|
<button class="btn btn-secondary" id="stopMonitor" data-i18n="btn_stop_monitor"></button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
@@ -93,5 +126,6 @@
|
|||||||
<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>
|
||||||
|
|||||||
13
lib/xmr-crypto.bundle.js
Normal file
13
lib/xmr-crypto.bundle.js
Normal file
File diff suppressed because one or more lines are too long
422
monitor.js
Normal file
422
monitor.js
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
};
|
||||||
|
})();
|
||||||
192
style.css
192
style.css
@@ -236,6 +236,45 @@ input.valid {
|
|||||||
color: var(--error);
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.deadline-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, color 0.2s, background 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-input {
|
||||||
|
width: 70px;
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
@@ -290,7 +329,8 @@ textarea {
|
|||||||
|
|
||||||
.qr-container {
|
.qr-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +407,156 @@ textarea {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Monitor Section --- */
|
||||||
|
|
||||||
|
.monitor-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-monitor {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, color 0.2s;
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-monitor:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-panel {
|
||||||
|
display: none;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-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 {
|
||||||
|
display: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-status.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 auto 0.5rem;
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.connecting,
|
||||||
|
.status-indicator.scanning {
|
||||||
|
background: var(--accent);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.waiting {
|
||||||
|
background: var(--accent);
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.mempool {
|
||||||
|
background: #ffc107;
|
||||||
|
animation: pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.confirmed {
|
||||||
|
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);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#stopMonitor {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stopMonitor.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-new {
|
.btn-new {
|
||||||
margin-top: 0.8rem;
|
margin-top: 0.8rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
8
sw.js
8
sw.js
@@ -1,11 +1,13 @@
|
|||||||
var CACHE_NAME = 'xmrpay-v1';
|
var CACHE_NAME = 'xmrpay-v2';
|
||||||
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
|
||||||
];
|
];
|
||||||
|
|
||||||
self.addEventListener('install', function (e) {
|
self.addEventListener('install', function (e) {
|
||||||
@@ -32,8 +34,8 @@ self.addEventListener('activate', function (e) {
|
|||||||
self.addEventListener('fetch', function (e) {
|
self.addEventListener('fetch', function (e) {
|
||||||
var url = new URL(e.request.url);
|
var url = new URL(e.request.url);
|
||||||
|
|
||||||
// CoinGecko API — network only, don't cache
|
// External APIs and RPC proxy — network only, don't cache
|
||||||
if (url.hostname !== location.hostname) {
|
if (url.hostname !== location.hostname || url.pathname.startsWith('/api/')) {
|
||||||
e.respondWith(fetch(e.request));
|
e.respondWith(fetch(e.request));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user