UNPKG

stratum-pool-sha256

Version:

High performance SHA-256 Stratum poolserver in Node.js - zero dependencies, pure JavaScript

934 lines (832 loc) 33.2 kB
/** * @module stratum * @description Implements the Stratum mining protocol for cryptocurrency pools. * Handles client connections, message validation, and mining operations. */ var net = require('net'); var events = require('events'); var util = require('./util.js'); // Constants for input validation var MAX_STRING_LENGTH = 1024; var MAX_ARRAY_LENGTH = 100; var ALLOWED_METHODS = [ 'mining.subscribe', 'mining.authorize', 'mining.submit', 'mining.get_transactions', 'mining.configure', 'mining.extranonce.subscribe' ]; /** * Generates unique subscription IDs for Stratum clients. * The ID consists of a fixed prefix followed by a counter. * * @class SubscriptionCounter * @private */ var SubscriptionCounter = function(){ var count = 0; var padding = 'deadbeefcafebabe'; return { next: function(){ count++; if (Number.MAX_VALUE === count) count = 0; return padding + util.packInt64LE(count).toString('hex'); } }; }; /** * Represents a connected Stratum mining client. * Handles all communication with individual miners. * * @class StratumClient * @extends {EventEmitter} * @param {Object} options - Client configuration * @param {net.Socket} options.socket - Network socket for the client * @param {Object} options.banning - Ban configuration settings * @param {string} options.subscriptionId - Unique subscription ID * @param {Object} options.authorizeFn - Function to authorize workers * * @fires StratumClient#subscription - When client subscribes * @fires StratumClient#submit - When client submits a share * @fires StratumClient#malformedMessage - On invalid message format * @fires StratumClient#socketError - On socket errors * @fires StratumClient#socketTimeout - On socket timeout * @fires StratumClient#socketDisconnect - When socket disconnects * @fires StratumClient#triggerBan - When client should be banned */ var StratumClient = function(options){ var pendingDifficulty = null; //private members this.socket = options.socket; this.remoteAddress = options.socket.remoteAddress; var banning = options.banning; var _this = this; this.lastActivity = Date.now(); this.shares = {valid: 0, invalid: 0}; var considerBan = (!banning || !banning.enabled) ? function(){ return false } : function(shareValid){ if (shareValid === true) _this.shares.valid++; else _this.shares.invalid++; var totalShares = _this.shares.valid + _this.shares.invalid; if (totalShares >= banning.checkThreshold){ var percentBad = (_this.shares.invalid / totalShares) * 100; if (percentBad < banning.invalidPercent) //reset shares this.shares = {valid: 0, invalid: 0}; else { _this.emit('triggerBan', _this.shares.invalid + ' out of the last ' + totalShares + ' shares were invalid'); _this.socket.destroy(); return true; } } return false; }; /** * Initialize the Stratum client by setting up the socket * @returns {undefined} */ this.init = function init(){ setupSocket(); }; /** * Validates a stratum message * @param {Object} message Stratum message * @returns {Object} Validation result * @property {Boolean} valid True if the message is valid * @property {String|undefined} error Error message if the message is invalid */ function validateMessage(message){ // Basic structure validation if (!message || typeof message !== 'object') { return { valid: false, error: 'Invalid message format' }; } // Validate method if (!message.method || typeof message.method !== 'string') { return { valid: false, error: 'Missing or invalid method' }; } if (!ALLOWED_METHODS.includes(message.method)) { return { valid: false, error: 'Unknown method: ' + message.method }; } // Validate id if (message.id !== null && message.id !== undefined) { if (typeof message.id !== 'string' && typeof message.id !== 'number') { return { valid: false, error: 'Invalid message id type' }; } if (typeof message.id === 'string' && message.id.length > MAX_STRING_LENGTH) { return { valid: false, error: 'Message id too long' }; } } // Validate params if (message.params !== undefined) { if (!Array.isArray(message.params)) { return { valid: false, error: 'Params must be an array' }; } if (message.params.length > MAX_ARRAY_LENGTH) { return { valid: false, error: 'Too many parameters' }; } // Validate each parameter for (var i = 0; i < message.params.length; i++) { var param = message.params[i]; // Check string parameters if (typeof param === 'string') { if (param.length > MAX_STRING_LENGTH) { return { valid: false, error: 'Parameter ' + i + ' too long' }; } // Check for null bytes or control characters if (/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(param)) { return { valid: false, error: 'Invalid characters in parameter ' + i }; } } // Check arrays if (Array.isArray(param) && param.length > MAX_ARRAY_LENGTH) { return { valid: false, error: 'Parameter ' + i + ' array too long' }; } } } return { valid: true }; } /** * Handles an incoming Stratum message. Emits a 'unknownStratumMethod' * event if the method is not implemented. * * @param {Object} message - Stratum message object * @private */ function handleMessage(message){ switch(message.method){ case 'mining.subscribe': handleSubscribe(message); break; case 'mining.authorize': handleAuthorize(message, true /*reply to socket*/); break; case 'mining.submit': _this.lastActivity = Date.now(); handleSubmit(message); break; case 'mining.get_transactions': sendJson({ id : null, result : [], error : true }); break; case 'mining.configure': // Temporarily disable ASICBoost - just ignore configure messages // Send empty response to avoid "Unknown message" errors sendJson({ id: message.id, result: {}, error: null }); break; case 'mining.extranonce.subscribe': // MRR and other proxies need extranonce subscription support _this.extranonceSubscribed = true; sendJson({ id: message.id, result: true, // Enable extranonce subscription for MRR compatibility error: null }); break; case 'mining.set_version_mask': // BIP 310: Client acknowledging version mask update // This is a notification response, no reply needed break; default: _this.emit('unknownStratumMethod', message); break; } } /** * Handles a mining.subscribe stratum message * @param {Object} message - Stratum message object * @fires StratumClient#subscription * @private */ function handleSubscribe(message){ if (! _this._authorized ) { _this.requestedSubscriptionBeforeAuth = true; } _this.emit('subscription', {}, function(error, extraNonce1, extraNonce2Size){ if (error){ sendJson({ id: message.id, result: null, error: error }); return; } _this.extraNonce1 = extraNonce1; sendJson({ id: message.id, result: [ [ ["mining.set_difficulty", options.subscriptionId], ["mining.notify", options.subscriptionId] ], extraNonce1, extraNonce2Size ], error: null }); } ); } function handleAuthorize(message, replyToSocket){ _this.workerName = message.params[0]; _this.workerPass = message.params[1]; options.authorizeFn(_this.remoteAddress, options.socket.localPort, _this.workerName, _this.workerPass, function(result) { _this.authorized = (!result.error && result.authorized); if (replyToSocket) { sendJson({ id : message.id, result : _this.authorized, error : result.error }); } // Set custom difficulty if provided in password if (result.difficulty && result.difficulty > 0) { _this.enqueueNextDifficulty(result.difficulty); // Send difficulty immediately for NiceHash compatibility sendJson({ id: null, method: "mining.set_difficulty", params: [result.difficulty] }); } // If the authorizer wants us to close the socket lets do it. if (result.disconnect === true) { options.socket.destroy(); } }); } /** * Handles mining.configure messages for ASICBoost compatibility. * Compatible with AvalonMiner, NiceHash, and other mining services. * * @param {Object} message - Stratum message with parameters * @returns {undefined} */ function handleConfigure(message){ var supported = {}; // Basic parameter validation if (!message.params || !Array.isArray(message.params) || message.params.length < 1) { sendJson({ id: message.id, result: {}, error: null }); return; } var extensions = message.params[0]; var extensionParams = message.params[1] || {}; // Extensions should be an array if (!Array.isArray(extensions)) { sendJson({ id: message.id, result: {}, error: null }); return; } // Handle version-rolling extension with proper ASICBoost compatibility if (extensions.includes("version-rolling")) { // Use a permissive mask that works with most miners var poolVersionMask = 0x1fffe000; // Standard ASICBoost mask var clientRequestedMask = extensionParams["version-rolling.mask"]; var clientMinBitCount = extensionParams["version-rolling.min-bit-count"] || 16; // Calculate negotiated mask var negotiatedMask = poolVersionMask; if (clientRequestedMask) { var clientMask = parseInt(clientRequestedMask, 16); if (!isNaN(clientMask)) { // Use intersection of pool and client masks negotiatedMask = poolVersionMask & clientMask; } } // Count bits in negotiated mask var bitCount = 0; var temp = negotiatedMask; while (temp) { bitCount += temp & 1; temp >>>= 1; } // Only enable if we have enough bits if (bitCount >= clientMinBitCount) { supported["version-rolling"] = true; supported["version-rolling.mask"] = "0x" + negotiatedMask.toString(16); supported["version-rolling.min-bit-count"] = bitCount; _this.asicboost = true; _this.versionMask = negotiatedMask; console.log('[Stratum] Client ' + (_this.workerName || 'unknown') + ' enabled version-rolling with mask: 0x' + negotiatedMask.toString(16)); } else { supported["version-rolling"] = false; console.log('[Stratum] Client ' + (_this.workerName || 'unknown') + ' version-rolling disabled - insufficient bits'); } } // Process other extensions if (extensions.includes("minimum-difficulty")) { var minDiff = extensionParams["minimum-difficulty.value"]; if (minDiff && minDiff > 0) { supported["minimum-difficulty"] = true; supported["minimum-difficulty.value"] = minDiff; _this.minimumDifficulty = minDiff; } } if (extensions.includes("subscribe-extranonce")) { supported["subscribe-extranonce"] = true; _this.supportsExtranonceSubscribe = true; } sendJson({ id: message.id, result: supported, error: null }); } /** * Handles mining.submit messages from clients. * * @param {Object} message - Stratum message with parameters * * @returns {undefined} */ function handleSubmit(message){ if (!_this.authorized){ sendJson({ id : message.id, result: null, error : [24, "unauthorized worker", null] }); considerBan(false); return; } if (!_this.extraNonce1){ sendJson({ id : message.id, result: null, error : [25, "not subscribed", null] }); considerBan(false); return; } // Validate submit parameters if (!message.params || message.params.length < 5) { sendJson({ id : message.id, result: null, error : [20, "missing submit parameters", null] }); considerBan(false); return; } // Validate each parameter var workerName = message.params[0]; var jobId = message.params[1]; var extraNonce2 = message.params[2]; var nTime = message.params[3]; var nonce = message.params[4]; var version = message.params[5]; // Optional for ASICBoost // Validate types and formats if (typeof workerName !== 'string' || workerName.length > 128) { sendJson({ id : message.id, result: null, error : [20, "invalid worker name", null] }); considerBan(false); return; } if (typeof jobId !== 'string' || !jobId.match(/^[0-9a-fA-F]+$/)) { sendJson({ id : message.id, result: null, error : [20, "invalid job id", null] }); considerBan(false); return; } if (typeof extraNonce2 !== 'string' || !extraNonce2.match(/^[0-9a-fA-F]+$/)) { sendJson({ id : message.id, result: null, error : [20, "invalid extranonce2", null] }); considerBan(false); return; } if (typeof nTime !== 'string' || !nTime.match(/^[0-9a-fA-F]{8}$/)) { sendJson({ id : message.id, result: null, error : [20, "invalid ntime", null] }); considerBan(false); return; } if (typeof nonce !== 'string' || !nonce.match(/^[0-9a-fA-F]{8}$/)) { sendJson({ id : message.id, result: null, error : [20, "invalid nonce", null] }); considerBan(false); return; } // No version validation - ASICBoost disabled _this.emit('submit', { name : message.params[0], jobId : message.params[1], extraNonce2 : message.params[2], nTime : message.params[3], nonce : message.params[4] // No version parameter - ASICBoost disabled }, function(error, result){ if (!considerBan(result)){ sendJson({ id: message.id, result: result, error: error }); } } ); } /** * Helper function to send JSON data to the stratum client. * Can be given any number of arguments, which are JSON.stringified * and written to the socket with a newline appended to each argument. * @param {...Object} data - Data to send to the client. * @return {undefined} */ function sendJson(){ var response = ''; for (var i = 0; i < arguments.length; i++){ response += JSON.stringify(arguments[i]) + '\n'; } options.socket.write(response); } /** * Set up the socket and associated event listeners. * * @emits socketDisconnect * @emits socketError * @emits socketFlooded * @emits tcpProxyError * @emits malformedMessage * @emits checkBan */ function setupSocket(){ var socket = options.socket; var dataBuffer = ''; socket.setEncoding('utf8'); if (options.tcpProxyProtocol === true) { socket.once('data', function (d) { if (d.indexOf('PROXY') === 0) { _this.remoteAddress = d.split(' ')[2]; } else{ _this.emit('tcpProxyError', d); } _this.emit('checkBan'); }); } else{ _this.emit('checkBan'); } socket.on('data', function(d){ dataBuffer += d; if (Buffer.byteLength(dataBuffer, 'utf8') > 10240){ //10KB dataBuffer = ''; _this.emit('socketFlooded'); socket.destroy(); return; } if (dataBuffer.indexOf('\n') !== -1){ var messages = dataBuffer.split('\n'); var incomplete = dataBuffer.slice(-1) === '\n' ? '' : messages.pop(); messages.forEach(function(message){ if (message === '') return; var messageJson; try { messageJson = JSON.parse(message); } catch(e) { if (options.tcpProxyProtocol !== true || d.indexOf('PROXY') !== 0){ _this.emit('malformedMessage', message); socket.destroy(); } return; } if (messageJson) { var validation = validateMessage(messageJson); if (!validation.valid) { _this.emit('malformedMessage', message + ' - ' + validation.error); sendJson({ id: messageJson.id || null, result: null, error: [20, validation.error, null] }); considerBan(false); return; } handleMessage(messageJson); } }); dataBuffer = incomplete; } }); socket.on('close', function() { _this.emit('socketDisconnect'); }); socket.on('error', function(err){ if (err.code !== 'ECONNRESET') _this.emit('socketError', err); }); } /** * Return a string identifying this connection, of the form: * <workerName> [<ipAddress>] * If the worker is unauthorized, <workerName> will be "(unauthorized)" * @return {string} */ this.getLabel = function(){ return (_this.workerName || '(unauthorized)') + ' [' + _this.remoteAddress + ']'; }; /** * Queues a new difficulty for the next time the client requests a difficulty. * This is useful for when the upstream pool changes its difficulty. * @param {number} requestedNewDifficulty - The new difficulty to send to the client * @return {boolean} - Always true */ this.enqueueNextDifficulty = function(requestedNewDifficulty) { pendingDifficulty = requestedNewDifficulty; return true; }; //public members /** * IF the given difficulty is valid and new it'll send it to the client. * returns boolean **/ this.sendDifficulty = function(difficulty){ if (difficulty === this.difficulty) return false; _this.previousDifficulty = _this.difficulty; _this.difficulty = difficulty; sendJson({ id : null, method: "mining.set_difficulty", params: [difficulty]//[512], }); return true; }; /** * Send a new mining job to the client. * * If the client hasn't submitted a share in a while, this will disconnect the client. * If there's a pending difficulty, it'll send that first. * @param {array} jobParams - The parameters for the mining.notify method, typically [jobId, prevHash, coinb1, coinb2, merkleBranch, version, bits, target, timestamp, cleanJobs] * @return {undefined} */ this.sendMiningJob = function(jobParams){ var lastActivityAgo = Date.now() - _this.lastActivity; if (lastActivityAgo > options.connectionTimeout * 1000){ _this.emit('socketTimeout', 'last submitted a share was ' + (lastActivityAgo / 1000 | 0) + ' seconds ago'); _this.socket.destroy(); return; } if (pendingDifficulty !== null){ var result = _this.sendDifficulty(pendingDifficulty); pendingDifficulty = null; if (result) { _this.emit('difficultyChanged', _this.difficulty); } } sendJson({ id : null, method: "mining.notify", params: jobParams }); }; /** * Updates the version mask for this client (BIP 310). * Sends a mining.set_version_mask notification to the client. * @param {number} newMask - The new version mask to use * @return {boolean} - True if client supports version rolling */ this.setVersionMask = function(newMask) { if (!_this.asicboost) { return false; } _this.versionMask = newMask; sendJson({ id: null, method: "mining.set_version_mask", params: [newMask.toString(16)] }); return true; }; /** * Manually authorizes the client with the given username and password. * This is useful in tests where you want to connect a client to the pool * programatically. * @param {string} username - The username to authorize with * @param {string} password - The password to authorize with */ this.manuallyAuthClient = function (username, password) { handleAuthorize({id: 1, params: [username, password]}, false /*do not reply to miner*/); }; /** * Copy the extraNonce1, previousDifficulty and difficulty from another StratumClient instance. * @param {StratumClient} otherClient - The other StratumClient instance to copy from. */ this.manuallySetValues = function (otherClient) { _this.extraNonce1 = otherClient.extraNonce1; _this.previousDifficulty = otherClient.previousDifficulty; _this.difficulty = otherClient.difficulty; }; }; StratumClient.prototype.__proto__ = events.EventEmitter.prototype; /** * The Stratum protocol server implementation. * Manages multiple ports, client connections, and mining job broadcasts. * * @class StratumServer * @extends {EventEmitter} * @param {Object} options - Server configuration * @param {Object} options.ports - Port configurations (port number -> config) * @param {number} options.connectionTimeout - Client connection timeout (ms) * @param {number} options.jobRebroadcastTimeout - Job rebroadcast timeout (seconds) * @param {Object} [options.banning] - IP banning configuration * @param {boolean} options.banning.enabled - Whether banning is enabled * @param {number} options.banning.time - Ban duration in seconds * @param {number} options.banning.purgeInterval - Interval to purge old bans * @param {boolean} [options.tcpProxyProtocol] - Whether to use HAProxy PROXY protocol * @param {Function} authorizeFn - Function to authorize workers * * @fires StratumServer#client.connected - When a new miner connects * @fires StratumServer#client.disconnected - When a miner disconnects * @fires StratumServer#started - When the server is up and running * @fires StratumServer#broadcastTimeout - When job broadcast timeout occurs * @fires StratumServer#bootedBannedWorker - When a banned worker is kicked */ var StratumServer = exports.Server = function StratumServer(options, authorizeFn){ //private members //ports, connectionTimeout, jobRebroadcastTimeout, banning, haproxy, authorizeFn // Debug log the version mask if (options.versionMask) { console.log('[Stratum] Server configured with versionMask: 0x' + options.versionMask.toString(16)); } else { console.log('[Stratum] No versionMask in options, will use default 0x3fffe000'); } var bannedMS = options.banning ? options.banning.time * 1000 : null; var _this = this; var stratumClients = {}; var subscriptionCounter = SubscriptionCounter(); var rebroadcastTimeout; var bannedIPs = {}; /** * Check if the client is banned and act accordingly. * If banned, it will be disconnected and receive a 'kickedBannedIP' event. * If the ban has expired, the client will receive a 'forgaveBannedIP' event. * @param {StratumClient} client - The stratum client to check. */ function checkBan(client){ if (options.banning && options.banning.enabled && client.remoteAddress in bannedIPs){ var bannedTime = bannedIPs[client.remoteAddress]; var bannedTimeAgo = Date.now() - bannedTime; var timeLeft = bannedMS - bannedTimeAgo; if (timeLeft > 0){ client.socket.destroy(); client.emit('kickedBannedIP', timeLeft / 1000 | 0); } else { delete bannedIPs[client.remoteAddress]; client.emit('forgaveBannedIP'); } } } /** * Handle a new incoming client connection. * This method is called for every new client and returns the subscriptionId for the client. * @param {net.Socket} socket - The new client socket. * @returns {String} The subscriptionId for the client. */ this.handleNewClient = function (socket){ socket.setKeepAlive(true); var subscriptionId = subscriptionCounter.next(); var client = new StratumClient( { subscriptionId: subscriptionId, authorizeFn: authorizeFn, socket: socket, banning: options.banning, connectionTimeout: options.connectionTimeout, tcpProxyProtocol: options.tcpProxyProtocol, versionMask: options.versionMask, // Pass the pool's version mask to the client enforcePoolVersionMask: options.enforcePoolVersionMask // Pass enforcement option } ); stratumClients[subscriptionId] = client; _this.emit('client.connected', client); client.on('socketDisconnect', function() { _this.removeStratumClientBySubId(subscriptionId); _this.emit('client.disconnected', client); }).on('checkBan', function(){ checkBan(client); }).on('triggerBan', function(){ _this.addBannedIP(client.remoteAddress); }).init(); return subscriptionId; }; /** * Broadcasts a new mining job to all connected clients. * @param {Object} jobParams - The parameters of the new mining job. * @fires StratumServer#broadcastTimeout * @see {@link StratumClient#sendMiningJob} */ this.broadcastMiningJobs = function(jobParams){ for (var clientId in stratumClients) { var client = stratumClients[clientId]; client.sendMiningJob(jobParams); } /* Some miners will consider the pool dead if it doesn't receive a job for around a minute. So every time we broadcast jobs, set a timeout to rebroadcast in X seconds unless cleared. */ clearTimeout(rebroadcastTimeout); rebroadcastTimeout = setTimeout(function(){ _this.emit('broadcastTimeout'); }, (options.jobRebroadcastTimeout || 55) * 1000); }; (function init(){ //Interval to look through bannedIPs for old bans and remove them in order to prevent a memory leak if (options.banning && options.banning.enabled){ setInterval(function(){ for (ip in bannedIPs){ var banTime = bannedIPs[ip]; if (Date.now() - banTime > options.banning.time) delete bannedIPs[ip]; } }, 1000 * options.banning.purgeInterval); } //SetupBroadcasting(); var serversStarted = 0; Object.keys(options.ports).forEach(function(port){ net.createServer({allowHalfOpen: false}, function(socket) { _this.handleNewClient(socket); }).listen(parseInt(port), function() { serversStarted++; if (serversStarted == Object.keys(options.ports).length) _this.emit('started'); }); }); })(); //public members /** * Bans a given IP address. * @param {String} ipAddress - The IP address of the client to ban. * @fires StratumServer#bootedBannedWorker */ this.addBannedIP = function(ipAddress){ bannedIPs[ipAddress] = Date.now(); /*for (var c in stratumClients){ var client = stratumClients[c]; if (client.remoteAddress === ipAddress){ _this.emit('bootedBannedWorker'); } }*/ }; /** * Returns an object with all currently connected clients, where the keys are the subscriptionIds * and the values are StratumClient instances. * @return {Object} The object with all currently connected clients. */ this.getStratumClients = function () { return stratumClients; }; /** * Removes a client from the list of connected clients by its subscriptionId. * @param {String} subscriptionId - The subscriptionId of the client to remove. */ this.removeStratumClientBySubId = function (subscriptionId) { delete stratumClients[subscriptionId]; }; /** * Manually adds a stratum client to the pool's list of connected clients. Useful for testing. * @param {Object} clientObj - An object containing the following properties: * - `socket`: The socket object of the client. * - `workerName`: The worker name of the client. * - `workerPass`: The worker password of the client. */ this.manuallyAddStratumClient = function(clientObj) { var subId = _this.handleNewClient(clientObj.socket); if (subId != null) { // not banned! stratumClients[subId].manuallyAuthClient(clientObj.workerName, clientObj.workerPass); stratumClients[subId].manuallySetValues(clientObj); } }; }; StratumServer.prototype.__proto__ = events.EventEmitter.prototype;