- 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
423 lines
11 KiB
JavaScript
423 lines
11 KiB
JavaScript
/**
|
|
* 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
|
|
};
|
|
})();
|