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:
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
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user