UNPKG

cryptocurrency-pool-server

Version:

An extremely efficient, highly scalable, all-in-one, easy to setup cryptocurrency pool server

778 lines (678 loc) 34.1 kB
var redis = require('redis'); var Stratum = require('cryptocurrency-stratum-pool'); var CreateRedisClient = require('./createRedisClient.js'); /* This module deals with handling stakes when in internal payment processing mode. It connects to a redis database and inserts shares with the database structure of: key: coin_name + ':' + block_height value: a hash with.. key: */ module.exports = function(logger, poolConfig){ var redisConfig = poolConfig.redis; var coin = poolConfig.coin.name; var forkId = process.env.forkId; var logSystem = 'Pool'; var logComponent = coin; var logSubCat = 'Thread ' + (parseInt(forkId) + 1); var connection = CreateRedisClient(redisConfig); if (redisConfig.password) { connection.auth(redisConfig.password); } connection.on('ready', function(){ logger.debug(logSystem, logComponent, logSubCat, 'Share processing setup with redis (' + redisConfig.host + ':' + redisConfig.port + ')'); }); connection.on('error', function(err){ logger.error(logSystem, logComponent, logSubCat, 'Redis client had an error: ' + JSON.stringify(err)) }); connection.on('end', function(){ logger.error(logSystem, logComponent, logSubCat, 'Connection to redis database has been ended'); }); connection.info(function(error, response){ if (error){ logger.error(logSystem, logComponent, logSubCat, 'Redis version check failed'); return; } var parts = response.split('\r\n'); var version; var versionString; for (var i = 0; i < parts.length; i++){ if (parts[i].indexOf(':') !== -1){ var valParts = parts[i].split(':'); if (valParts[0] === 'redis_version'){ versionString = valParts[1]; version = parseFloat(versionString); break; } } } if (!version){ logger.error(logSystem, logComponent, logSubCat, 'Could not detect redis version - but be super old or broken'); } else if (version < 2.6){ logger.error(logSystem, logComponent, logSubCat, "You're using redis version " + versionString + " the minimum required version is 2.6. Follow the damn usage instructions..."); } }); var opidCount = 0; var opids = []; // zcash team recommends 10 confirmations for safety from orphaned blocks var minConfShield = Math.max((poolOptions.paymentProcessing.minConf || 10), 1); // Don't allow 0 conf transactions. var minConfPayout = Math.max((poolOptions.paymentProcessing.minConf || 10), 1); if (minConfPayout < 3) { logger.warning(logSystem, logComponent, logComponent + ' minConf of 3 is recommended.'); } // minimum stakingInterval of 60 seconds var stakingIntervalSecs = Math.max((poolOptions.stakingInterval || 120), 30); if (parseInt(poolOptions.stakingInterval) < 120) { logger.warning(logSystem, logComponent, ' minimum stakingInterval of 120 seconds recommended.'); } var requireShielding = poolOptions.coin.requireShielding === true; var fee = parseFloat(poolOptions.coin.txfee) || parseFloat(0.0004); var daemon = new Stratum.daemon.interface([poolOptions.paymentProcessing.daemon], function (severity, message) { logger[severity](logSystem, logComponent, message); }); function validateAddress (callback){ daemon.cmd('validateaddress', [poolOptions.address], function (result) { if (result.error) { logger.error(logSystem, logComponent, 'Error with payment processing daemon ' + JSON.stringify(result.error)); callback(true); } else if (!result.response || result.response.ismine === false) { logger.error(logSystem, logComponent, 'Daemon does not own pool address - payment processing can not be done with this daemon, ' + JSON.stringify(result.response)); callback(true); } else { callback() } }, true); } function validateTAddress (callback) { daemon.cmd('validateaddress', [poolOptions.stakeAddress], function(result) { if (result.error){ logger.error(logSystem, logComponent, 'Error with payment processing daemon (validateTAddress) ' + JSON.stringify(result.error)); callback(true); } else if (!result.response || !result.response.ismine) { logger.error(logSystem, logComponent, 'Daemon does not own pool address - payment processing can not be done with this daemon, ' + JSON.stringify(result.response)); callback(true); } else{ callback() } }, true); } function validateZAddress (callback) { daemon.cmd('z_validateaddress', [poolOptions.izAddress], function(result) { if (result.error){ logger.error(logSystem, logComponent, 'Error with payment processing daemon (validateZAddress) ' + JSON.stringify(result.error)); callback(true); } else if (!result.response || !result.response.ismine) { logger.error(logSystem, logComponent, 'Daemon does not own pool address - payment processing can not be done with this daemon, ' + JSON.stringify(result.response)); callback(true); } else{ callback() } }, true); } function getBalance(callback) { daemon.cmd('getbalance', [], function (result) { var wasICaught = false; if (result.error) { callback(true); return; } try { var d = result.data.split('result":')[1].split(',')[0].split('.')[1]; magnitude = parseInt('10' + new Array(d.length).join('0')); minPaymentSatoshis = parseInt(poolOptions.paymentProcessing.minimumPayment * magnitude); coinPrecision = magnitude.toString().length - 1; } catch (e) { logger.error(logSystem, logComponent, 'Error detecting number of satoshis in a coin, cannot do payment processing. Tried parsing: ' + result.data); wasICaught = true; } finally { if (wasICaught) { callback(true); } else { callback(); } } }, true, true); } function asyncComplete(err){ if (err){ setupFinished(false); return; } if (stakingInterval) { clearInterval(stakingInterval); } stakingInterval = setInterval(function () { try { processStakes(); } catch (e) { throw e; } }, stakingIntervalSecs * 1000); setupFinished(true); } if (!poolOptions.coin.isZCashProtocol) { async.parallel([validateAddress, getBalance], asyncComplete); } else if (requireShielding === true) { async.parallel([validateAddress, validateTAddress, validateZAddress, getBalance], asyncComplete); } else { async.parallel([validateAddress, validateTAddress, getBalance], asyncComplete); } //get t_address coinbalance function listUnspent (addr, notAddr, minConf, displayBool, callback) { if (addr !== null) { var args = [minConf, 99999999, [addr]]; } else { addr = 'Payout wallet'; var args = [minConf, 99999999]; } daemon.cmd('listunspent', args, function (result) { if (!result || result.error || result[0].error) { logger.error(logSystem, logComponent, 'Error with RPC call listunspent '+addr+' '+JSON.stringify(result[0].error)); callback = function (){}; callback(true); } else { var transactionHashes = []; var tBalance = parseFloat(0); if (result[0].response != null && result[0].response.length > 0) { for (var i = 0, len = result[0].response.length; i < len; i++) { if (result[0].response[i].address && result[0].response[i].address !== notAddr) { transactionHashes.push(result[0].response[i].txid); tBalance += parseFloat(result[0].response[i].amount || 0); } } tBalance = coinsRound(tBalance); } if (displayBool === true) { logger.special(logSystem, logComponent, addr+' balance of ' + tBalance); } callback(function(err) { if (err) return; // TODO: Handle moving from stakesTPending to stakesZPending }, details); } }); } // get z_address coinbalance function listUnspentZ (addr, minConf, displayBool, callback) { if (addr !== null) { var args = [minConf, 99999999, [addr]]; } else { addr = 'Payout wallet'; var args = [minConf, 99999999]; } daemon.cmd('z_listunspent', args, function (result) { if (!result || result.error || result[0].error) { logger.error(logSystem, logComponent, 'Error with RPC call z_getbalance '+addr+' '+JSON.stringify(result[0].error)); callback = function (){}; callback(true); } else { var transactionHashes = []; var zBalance = parseFloat(0); if (result[0].response != null && result[0].response.length > 0) { for (var i = 0, len = result[0].response.length; i < len; i++) { transactionHashes.push(result[0].response[i].txid); zBalance += parseFloat(result[0].response[i].amount || 0); } zBalance = coinsRound(zBalance); } if (displayBool === true) { logger.special(logSystem, logComponent, addr.substring(0,14) + '...' + addr.substring(addr.length - 14) + ' balance: '+(zBalance).toFixed(8)); } callback(function(err) { if (err) return; // TODO: Handle moving from stakesZPending to stakesPendingConfirms }, coinsToSatoshies(zBalance)); } }); } //send t_address balance to z_address function sendTToZ (callback, tBalance) { if (callback === true) return; if (tBalance === NaN) { logger.error(logSystem, logComponent, 'tBalance === NaN for sendTToZ'); return; } if ((tBalance - 10000) <= 0) return; // do not allow more than a single z_sendmany operation at a time if (opidCount > 0) { logger.warning(logSystem, logComponent, 'sendTToZ is waiting, too many z_sendmany operations already in progress.'); return; } var amount = satoshisToCoins(tBalance - 10000); var params = [poolOptions.stakeAddress, [{'address': poolOptions.izAddress, 'amount': amount}]]; daemon.cmd('z_sendmany', params, function (result) { //Check if payments failed because wallet doesn't have enough coins to pay for tx fees if (!result || result.error || result[0].error || !result[0].response) { logger.error(logSystem, logComponent, 'Error trying to shield balance '+amount+' '+JSON.stringify(result[0].error)); callback = function (){}; callback(true); } else { var opid = (result.response || result[0].response); opidCount++; opids.push(opid); logger.special(logSystem, logComponent, 'Shield balance ' + amount + ' ' + opid); callback = function (){}; callback(null); } } ); } // send z_address balance to t_address function sendZToT (callback, zBalance) { if (callback === true) return; if (zBalance === NaN) { logger.error(logSystem, logComponent, 'zBalance === NaN for sendZToT'); return; } if ((zBalance - 10000) <= 0) return; // do not allow more than a single z_sendmany operation at a time if (opidCount > 0) { logger.warning(logSystem, logComponent, 'sendZToT is waiting, too many z_sendmany operations already in progress.'); return; } var amount = satoshisToCoins(zBalance - 10000); var params = [poolOptions.izAddress, [{'address': poolOptions.address, 'amount': amount}]]; daemon.cmd('z_sendmany', params, function (result) { //Check if payments failed because wallet doesn't have enough coins to pay for tx fees if (!result || result.error || result[0].error || !result[0].response) { logger.error(logSystem, logComponent, 'Error trying to send z_address coin balance to payout t_address.'+JSON.stringify(result[0].error)); callback = function (){}; callback(true); } else { var opid = (result.response || result[0].response); opidCount++; opids.push(opid); logger.special(logSystem, logComponent, 'Unshield funds for payout ' + amount + ' ' + opid); callback = function (){}; callback(null); } } ); } if (!!poolOptions.coin.isZCashProtocol) { // run shielding process every x minutes var shieldIntervalState = 0; // do not send ZtoT and TtoZ and same time, this results in operation failed! var shielding_interval = Math.max(parseInt(poolOptions.walletInterval || 1), 1) * 60 * 1000; // run every x minutes // check operation statuses every 57 seconds var opid_interval = 57 * 1000; // shielding not required for some equihash coins if (requireShielding === true) { var shieldInterval = setInterval(function() { shieldIntervalState++; switch (shieldIntervalState) { case 1: listUnspent(poolOptions.stakeAddress, null, minConfShield, false, sendTToZ); break; default: listUnspentZ(poolOptions.izAddress, minConfShield, false, sendZToT); shieldIntervalState = 0; break; } }, shielding_interval); var checkOpids = function() { clearTimeout(opidTimeout); var checkOpIdSuccessAndGetResult = function(ops) { var batchRPC = []; // if there are no op-ids if (ops.length == 0) { // and we think there is if (opidCount !== 0) { // clear them! opidCount = 0; opids = []; logger.warning(logSystem, logComponent, 'Clearing operation ids due to empty result set.'); } } // loop through op-ids checking their status ops.forEach(function(op, i){ // check operation id status if (op.status == "success" || op.status == "failed") { // clear operation id result var opid_index = opids.indexOf(op.id); if (opid_index > -1) { // clear operation id count batchRPC.push(['z_getoperationresult', [[op.id]]]); opidCount--; opids.splice(opid_index, 1); } // log status to console if (op.status == "failed") { if (op.error) { logger.error(logSystem, logComponent, "Shielding operation failed " + op.id + " " + op.error.code +", " + op.error.message); } else { logger.error(logSystem, logComponent, "Shielding operation failed " + op.id); } } else { logger.special(logSystem, logComponent, 'Shielding operation success ' + op.id + ' txid: ' + op.result.txid); } } else if (op.status == "executing") { logger.special(logSystem, logComponent, 'Shielding operation in progress ' + op.id ); } }); // if there are no completed operations if (batchRPC.length <= 0) { opidTimeout = setTimeout(checkOpids, opid_interval); return; } // clear results for completed operations daemon.batchCmd(batchRPC, function(error, results){ if (error || !results) { opidTimeout = setTimeout(checkOpids, opid_interval); logger.error(logSystem, logComponent, 'Error with RPC call z_getoperationresult ' + JSON.stringify(error)); return; } // check result execution_secs vs pool_config results.forEach(function(result, i) { if (result.result[i] && parseFloat(result.result[i].execution_secs || 0) > shielding_interval) { logger.warning(logSystem, logComponent, 'Warning, walletInverval shorter than opid execution time of '+result.result[i].execution_secs+' secs.'); } }); // keep checking operation ids opidTimeout = setTimeout(checkOpids, opid_interval); }); }; // check for completed operation ids daemon.cmd('z_getoperationstatus', null, function (result) { var err = false; if (result.error) { err = true; logger.error(logSystem, logComponent, 'Error with RPC call z_getoperationstatus ' + JSON.stringify(result.error)); } else if (result.response) { checkOpIdSuccessAndGetResult(result.response); } else { err = true; logger.error(logSystem, logComponent, 'No response from z_getoperationstatus RPC call.'); } if (err === true) { opidTimeout = setTimeout(checkOpids, opid_interval); if (opidCount !== 0) { opidCount = 0; opids = []; logger.warning(logSystem, logComponent, 'Clearing operation ids due to RPC call errors.'); } } }, true, true); } var opidTimeout = setTimeout(checkOpids, opid_interval); } } function roundTo(n, digits) { if (digits === undefined) { digits = 0; } var multiplicator = Math.pow(10, digits); n = parseFloat((n * multiplicator).toFixed(11)); var test =(Math.round(n) / multiplicator); return +(test.toFixed(digits)); } var satoshisToCoins = function (satoshis) { return roundTo((satoshis / magnitude), coinPrecision); }; var coinsToSatoshies = function (coins) { return Math.round(coins * magnitude); }; function coinsRound(number) { return roundTo(number, coinPrecision); } var processStakes = function () { var startProcess = Date.now(); var timeSpentRPC = 0; var timeSpentRedis = 0; var startTimeRedis; var startTimeRPC; var startRedisTimer = function () { startTimeRedis = Date.now() }; var endRedisTimer = function () { timeSpentRedis += Date.now() - startTimeRedis }; var startRPCTimer = function () { startTimeRPC = Date.now(); }; var endRPCTimer = function () { timeSpentRPC += Date.now() - startTimeRedis }; async.waterfall([ /* Call redis to get an array of rounds - which are coinbase transactions and block heights from submitted blocks. */ function (callback) { startRedisTimer(); redisClient.smembers(coin + ':stakesPendingConfirms', function (error, results) { endRedisTimer(); if (error) { logger.error(logSystem, logComponent, 'Could not get stakes from redis ' + JSON.stringify(error)); callback(true); return; } var stakes = results.map(function (r) { var details = r.split(':'); return { blockHash: details[0], txHash: details[1], height: details[2], stakeholder: details[3], time: details[4], serialized: r }; }); stakes.sort(function(a, b) { return a.height - b.height; }); callback(null, stakes); }); }, /* Does a batch rpc call to daemon with all the transaction hashes to see if they are confirmed yet. It also adds the block reward amount to the round object - which the daemon gives also gives us. */ function (stakes, callback) { var batchRPCcommand = rounds.map(function (r) { return ['gettransaction', [r.txHash]]; }); startRPCTimer(); daemon.batchCmd(batchRPCcommand, function (error, txDetails) { endRPCTimer(); if (error || !txDetails) { logger.error(logSystem, logComponent, 'Check finished - daemon rpc error with batch gettransactions ' + JSON.stringify(error)); callback(true); return; } txDetails.forEach(function (tx, i) { var stake = stakes[i]; if (tx && tx.result) { stake.confirmations = parseInt((tx.result.confirmations || 0)); } if (tx.error && tx.error.code === -5) { logger.warning(logSystem, logComponent, 'Daemon reports invalid transaction: ' + stake.txHash); stake.category = 'kicked'; return; } else if (!tx.result.details || (tx.result.details && tx.result.details.length === 0)) { logger.warning(logSystem, logComponent, 'Daemon reports no details for transaction: ' + stake.txHash); stake.category = 'kicked'; return; } else if (tx.error || !tx.result) { logger.error(logSystem, logComponent, 'Odd error with gettransaction ' + stake.txHash + ' ' + JSON.stringify(tx)); return; } }); stakes = stakes.filter(function (s) { switch (s.category) { case 'kicked': s.canDeleteStakes = true; case 'received': return true; default: return false; } }); callback(null, stakes); }); }, function (workers, rounds, paymentsUpdate, callback) { var totalPaid = parseFloat(0); var immatureUpdateCommands = []; var balanceUpdateCommands = []; var workerPayoutsCommand = []; for (var w in workers) { var worker = workers[w]; if ((worker.balanceChange || 0) !== 0) { balanceUpdateCommands.push([ 'hincrbyfloat', coin + ':balances', w, satoshisToCoins(worker.balanceChange) ]); } if ((worker.sent || 0) > 0) { workerPayoutsCommand.push(['hincrbyfloat', coin + ':payouts', w, coinsRound(worker.sent)]); totalPaid = coinsRound(totalPaid + worker.sent); } if ((worker.immature || 0) > 0) { immatureUpdateCommands.push(['hset', coin + ':immature', w, worker.immature]); } else { immatureUpdateCommands.push(['hset', coin + ':immature', w, 0]); } } var movePendingCommands = []; var roundsToDelete = []; var orphanMergeCommands = []; var confirmsUpdate = []; var confirmsToDelete = []; var moveSharesToCurrent = function (r) { var workerShares = r.workerShares; if (workerShares != null) { Object.keys(workerShares).forEach(function (worker) { orphanMergeCommands.push(['hincrby', coin + ':shares:roundCurrent', worker, workerShares[worker]]); }); } }; rounds.forEach(function (r) { switch (r.category) { case 'kicked': movePendingCommands.push(['smove', coin + ':blocksPending', coin + ':blocksKicked', r.serialized]); case 'orphan': movePendingCommands.push(['smove', coin + ':blocksPending', coin + ':blocksOrphaned', r.serialized]); confirmsToDelete.push(['hdel', coin + ':blocksPendingConfirms', r.blockHash]); if (!r.soloMined && r.canDeleteShares) { moveSharesToCurrent(r); roundsToDelete.push(coin + ':shares:round' + r.height); } return; case 'immature': confirmsUpdate.push(['hset', coin + ':blocksPendingConfirms', r.blockHash, (r.confirmations || 0)]); return; case 'generate': confirmsToDelete.push(['hdel', coin + ':blocksPendingConfirms', r.blockHash]); movePendingCommands.push(['smove', coin + ':blocksPending', coin + ':blocksConfirmed', r.serialized]); if (!r.soloMined) { roundsToDelete.push(coin + ':shares:round' + r.height); } return; } }); var finalRedisCommands = []; if (movePendingCommands.length > 0) finalRedisCommands = finalRedisCommands.concat(movePendingCommands); if (orphanMergeCommands.length > 0) finalRedisCommands = finalRedisCommands.concat(orphanMergeCommands); if (immatureUpdateCommands.length > 0) finalRedisCommands = finalRedisCommands.concat(immatureUpdateCommands); if (balanceUpdateCommands.length > 0) finalRedisCommands = finalRedisCommands.concat(balanceUpdateCommands); if (workerPayoutsCommand.length > 0) finalRedisCommands = finalRedisCommands.concat(workerPayoutsCommand); if (roundsToDelete.length > 0) finalRedisCommands.push(['del'].concat(roundsToDelete)); if (confirmsUpdate.length > 0) finalRedisCommands = finalRedisCommands.concat(confirmsUpdate); if (confirmsToDelete.length > 0) finalRedisCommands = finalRedisCommands.concat(confirmsToDelete); if (paymentsUpdate.length > 0) finalRedisCommands = finalRedisCommands.concat(paymentsUpdate); if (totalPaid !== 0) finalRedisCommands.push(['hincrbyfloat', coin + ':stats', 'totalPaid', totalPaid]); if (finalRedisCommands.length === 0) { callback(); return; } startRedisTimer(); redisClient.multi(finalRedisCommands).exec(function (error, results) { endRedisTimer(); if (error) { clearInterval(paymentInterval); logger.error(logSystem, logComponent, 'Payments sent but could not update redis. ' + JSON.stringify(error) + ' Disabling payment processing to prevent possible double-payouts. The redis commands in ' + coin + '_finalRedisCommands.txt must be ran manually'); fs.writeFile(coin + '_finalRedisCommands.txt', JSON.stringify(finalRedisCommands), function (err) { logger.error('Could not write finalRedisCommands.txt, you are fucked.'); }); } callback(); }); } ], function () { var paymentProcessTime = Date.now() - startPaymentProcess; logger.debug(logSystem, logComponent, 'Finished interval - time spent: ' + paymentProcessTime + 'ms total, ' + timeSpentRedis + 'ms redis, ' + timeSpentRPC + 'ms daemon RPC'); }); }; this.handleStake = function(stakeData) { var redisCommands = []; var dateNow = Date.now(); if (stakeData.confirmations < poolConfig.requiredStakeConfirmations) { redisCommands.push(['sadd', coin + ':stakesPendingConfirms', [stakeData.blockHash, stakeData.txHash, stakeData.height, stakeData.stakeholder, dateNow / 1000 | 0].join(':')]); } else { if (!shareData.isSoloMining) { if (isValidShare) { redisCommands.push(['hincrbyfloat', coin + ':shares:roundCurrent', shareData.worker, shareData.difficulty]); redisCommands.push(['hincrby', coin + ':stats', 'validShares', 1]); } else { redisCommands.push(['hincrby', coin + ':stats', 'invalidShares', 1]); } } /* Stores share diff, worker, and unique value with a score that is the timestamp. Unique value ensures it doesn't overwrite an existing entry, and timestamp as score lets us query shares from last X minutes to generate hashrate for each worker and pool. */ var dateNow = Date.now(); var hashrateData = [ isValidShare ? shareData.difficulty : -shareData.difficulty, shareData.worker, dateNow, shareData.isSoloMining ? 'SOLO' : 'PPS']; redisCommands.push(['zadd', coin + ':hashrate', dateNow / 1000 | 0, hashrateData.join(':')]); if (isValidBlock){ if (!shareData.isSoloMining) { redisCommands.push(['rename', coin + ':shares:roundCurrent', coin + ':shares:round' + shareData.height]); } redisCommands.push(['sadd', coin + ':blocksPending', [shareData.blockHash, shareData.txHash, shareData.height, shareData.worker, dateNow / 1000 | 0, shareData.isSoloMining ? 'SOLO' : 'PPS'].join(':')]); redisCommands.push(['hincrby', coin + ':stats', 'validBlocks', 1]); } else if (shareData.blockHash){ redisCommands.push(['hincrby', coin + ':stats', 'invalidBlocks', 1]); } connection.multi(redisCommands).exec(function(err, replies){ if (err) logger.error(logSystem, logComponent, logSubCat, 'Error with share processor multi ' + JSON.stringify(err)); }); }; };