diff --git a/.gitignore b/.gitignore index 0d20b64..3065190 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,9 @@ *.pyc +conf/config.py +NOTES +run +pooldb.sqlite +archives/* +twistd.pid +.project +.pydevproject \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a247f03 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "externals/stratum-mining-proxy"] + path = externals/stratum-mining-proxy + url = https://github.com/generalfault/stratum-mining-proxy.git diff --git a/INSTALL b/INSTALL new file mode 100644 index 0000000..281a544 --- /dev/null +++ b/INSTALL @@ -0,0 +1,100 @@ +Installation Instructions +========================= + +Step 0: Install bitcoind + It MUST be a recent version of bitcoind that support getblocktemplate + Set it up and start it! + Downloading the blockchain can take a few hours to a couple days! + +Step 1: Install the stratum core + git pull https://github.com/slush0/stratum.git + sudo easy_install stratum + (or if using alternate python: sudo /usr/local/bin/easy_install stratum) + +Step 2: Pull a copy of the miner + git pull https://github.com/generalfault/stratum-mining.git + +Step 3: Configure the Miner + cp conf/config_sample.py conf/config.py + make your changes to conf/config.py + Make sure you set the values in BASIC SETTINGS! These are how to connect to bitcoind + and where your money goes! + +Step 4: Run the pool + twistd -ny launcher.tac -l - + OR - using alternate python + /usr/local/bin/twistd -ny launcher.tac -l - + +You can now set the URL on your stratum proxy (or miner that supports stratum) to: +http://YOURHOSTNAME:3333 + +Bitcoind blocknotify Setup +========================= +Although scary (for me), this is actually pretty easy. + +Step 1: Set Admin Password + Ensure that you have set the ADMIN_PASSWORD_SHA256 parameter in conf/config.py + To make life easy you can run the generateAdminHash script to make the hash + ./scripts/generateAdminHash.sh + +Step 2: Test It + Restart the pool if it's already running + run ./scripts/blocknotify.sh --password --host localhost --port 3333 + Ensure everything is ok. + +Step 3: Run bitcoind with blocknotify + Stop bitcoind if it's already running + bitcoind stop + Wait till it ends + bitcoind -blocknotify="/absolute/path/to/scripts/blocknotify.sh --password --host localhost --port 3333" + +Step 4: Adjust pool polling + Now you should be able to watch the pools debug messages for awhile and see the blocknotify come in + once you are sure it's working edit conf/config.py and set + PREVHASH_REFRESH_INTERVAL = to the same value as MERKLE_REFRESH_INTERVAL + restart the pool + +Database Setup +========================= +Table Creation: Tables are auto-created if they don't exist + +None: +Well, this doesn't do anything, so there is nothing to set up + +Sqlite: +THIS IS THE DEFAULT! +Just set the file path in the config file (or keep the default.) +Support for sqlite3 is built into recent python versions. +A couple notes for Sqlite: + - Sqlite and threading/concurancy just doesn't work right for that reason it is disabled. + - Since threading is disabled, The server will "pause" when archiving happens, this will affect + your miners. However this will not happen often (24 hours after finding a share) + +Postgresql: +1: Set up your parameters in the config file. +2: Install the postgresql libraries in your os: + Redhat and the like: + yum install postgresql-libs postgresql-devel + Ubuntu and the like: + apt-get install postgresql postgresql-devel +3: Install the python bindings + easy_install psycopg2 + +Mysql: +1: Set up your parameters in the config file. +2: Install the postgresql libraries in your os: + Redhat and the like: + yum install mysql mysql-devel + Ubuntu and the like: + apt-get install mysql mysql-devel +3: Install the python bindings + easy_install mysql-python + +Problems???? +========================= + +Is your firewall off? +Is bitcoind running? + +TODO: are there other problems? + diff --git a/README.md b/README.md index 86b7603..6ad6331 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,42 @@ stratum-mining ============== -Demo implementation of bitcoin mining pool using Stratum mining protocol. +Basic implementation of bitcoin mining pool using Stratum mining protocol. -For Stratum mining protocol specification, please visit http://mining.bitcoin.cz/stratum-mining. +This fork includes database optimisations for MySQL and password hashing using a salt. -Contact -------- +JSON API +-------- -This pool implementation is provided by http://mining.bitcoin.cz. You can contact -me by email info(at)bitcoin.cz or on IRC #stratum on freenode. +There is also a JSON API, currently just for users (db: pool_workers). Enabled if you set +ADMIN_PORT to a valid port rather than None. Once enabled, you can perform +the following on http://localhost:ADMIN_PORT/, provided you have a password set (see after commands). + +GET /users - list all users +POST /users - create a user (JSON body: {"username": "username", "password": "password"}). Password will be encrypted using the salt. + +GET /users/{id_or_username} - Get a JSON object of a specific user +DELETE /users/{id_or_username} - Remove a pool_worker. If using MySQL, any shares associated with that user will be associated with the global system account (ID: 0) +PUT /users/{id_or_username} - Update password for user, send as {"password": "password"}, as with POST /users, the password will be encrypted for you. + +### Authentication + +Access to the JSON API requires basic auth. The username does not matter; the password is the same as the ADMIN_PASSWORD_SHA256 password which +can generated using scripts/generateAdminHash.sh . + +I would strongly suggested locking down the port as well using iptables or similar. + +The Rest +-------- + +Basic worker stats are provided (and updated) + +See the INSTALL file for install instructions. + +For more info on Stratum: +http://mining.bitcoin.cz/stratum-mining. + +Original version by Slush +Modified version by GeneralFault + +This version by Wade Womersley (Media Skunk Works) ( Tips Welcome: 1FxBTbWR15WZp8vnru8N6zVsVBwigPAcdN ) diff --git a/TODO b/TODO new file mode 100644 index 0000000..c726a7d --- /dev/null +++ b/TODO @@ -0,0 +1,17 @@ +TODO File (in no particular order): + +SQL Connection pooling: sqlalchemy + +Add a "script" to add,list,disable users + +Variable difficulty should not be able to go higher than current difficulty + +Test NON-Local Coinbase with testnet in a box + +verify settings + +send e-mail on dead miner + +send e-mail on dead bitcoind + + diff --git a/conf/config_sample.py b/conf/config_sample.py index 02c7d30..5570b9d 100644 --- a/conf/config_sample.py +++ b/conf/config_sample.py @@ -1,8 +1,35 @@ ''' This is example configuration for Stratum server. -Please rename it to settings.py and fill correct values. +Please rename it to config.py and fill correct values. + +This is already setup with sane values for solomining. +You NEED to set the parameters in BASIC SETTINGS ''' +# ******************** BASIC SETTINGS *************** +# These are the MUST BE SET parameters! + +CENTRAL_WALLET = 'set_valid_addresss_in_config!' # local bitcoin address where money goes + +BITCOIN_TRUSTED_HOST = 'localhost' +BITCOIN_TRUSTED_PORT = 8332 +BITCOIN_TRUSTED_USER = 'user' +BITCOIN_TRUSTED_PASSWORD = 'somepassword' + +# ******************** BASIC SETTINGS *************** +# Backup Bitcoind connections (consider having at least 1 backup) +# You can have up to 99 + +#BITCOIN_TRUSTED_HOST_1 = 'localhost' +#BITCOIN_TRUSTED_PORT_1 = 8332 +#BITCOIN_TRUSTED_USER_1 = 'user' +#BITCOIN_TRUSTED_PASSWORD_1 = 'somepassword' + +#BITCOIN_TRUSTED_HOST_2 = 'localhost' +#BITCOIN_TRUSTED_PORT_2 = 8332 +#BITCOIN_TRUSTED_USER_2 = 'user' +#BITCOIN_TRUSTED_PASSWORD_2 = 'somepassword' + # ******************** GENERAL SETTINGS *************** # Enable some verbose debug (logging requests and responses). @@ -12,7 +39,7 @@ LOGDIR = 'log/' # Main application log file. -LOGFILE = None#'stratum.log' +LOGFILE = None # eg. 'stratum.log' # Possible values: DEBUG, INFO, WARNING, ERROR, CRITICAL LOGLEVEL = 'INFO' @@ -20,7 +47,7 @@ # How many threads use for synchronous methods (services). # 30 is enough for small installation, for real usage # it should be slightly more, say 100-300. -THREAD_POOL_SIZE = 10 +THREAD_POOL_SIZE = 30 ENABLE_EXAMPLE_SERVICE = True @@ -31,44 +58,146 @@ # Port used for Socket transport. Use 'None' for disabling the transport. LISTEN_SOCKET_TRANSPORT = 3333 - # Port used for HTTP Poll transport. Use 'None' for disabling the transport LISTEN_HTTP_TRANSPORT = None - # Port used for HTTPS Poll transport LISTEN_HTTPS_TRANSPORT = None - # Port used for WebSocket transport, 'None' for disabling WS LISTEN_WS_TRANSPORT = None - # Port used for secure WebSocket, 'None' for disabling WSS LISTEN_WSS_TRANSPORT = None # Hostname and credentials for one trusted Bitcoin node ("Satoshi's client"). # Stratum uses both P2P port (which is 8333 already) and RPC port -BITCOIN_TRUSTED_HOST = 'localhost' -BITCOIN_TRUSTED_PORT = 8332 -BITCOIN_TRUSTED_USER = 'user' -BITCOIN_TRUSTED_PASSWORD = 'somepassword' +# BITCOIN_TRUSTED_* -- in basic settings above + +IRC_NICK = None + +# Salt used when hashing passwords +PASSWORD_SALT = 'some_crazy_string' + +# ******************** Database ********************* + +DATABASE_DRIVER = 'sqlite' # Options: none, sqlite, postgresql or mysql +DATABASE_EXTEND = True # False = pushpool db layout, True = pushpool + extra columns + +# SQLite +DB_SQLITE_FILE = 'pooldb.sqlite' +# Postgresql +DB_PGSQL_HOST = 'localhost' +DB_PGSQL_DBNAME = 'pooldb' +DB_PGSQL_USER = 'pooldb' +DB_PGSQL_PASS = '**empty**' +DB_PGSQL_SCHEMA = 'public' +# MySQL +DB_MYSQL_HOST = 'localhost' +DB_MYSQL_DBNAME = 'pooldb' +DB_MYSQL_USER = 'pooldb' +DB_MYSQL_PASS = '**empty**' + +# ******************** Adv. DB Settings ********************* +# Don't change these unless you know what you are doing + +DB_LOADER_CHECKTIME = 15 # How often we check to see if we should run the loader +DB_LOADER_REC_MIN = 10 # Min Records before the bulk loader fires +DB_LOADER_REC_MAX = 50 # Max Records the bulk loader will commit at a time -# Use "echo -n '' | sha256sum | cut -f1 -d' ' " +DB_LOADER_FORCE_TIME = 300 # How often the cache should be flushed into the DB regardless of size. + +DB_STATS_AVG_TIME = 300 # When using the DATABASE_EXTEND option, average speed over X sec + # Note: this is also how often it updates +DB_USERCACHE_TIME = 600 # How long the usercache is good for before we refresh + +# ******************** Pool Settings ********************* + +# User Auth Options +USERS_AUTOADD = True # Automatically add users to db when they connect. + # This basically disables User Auth for the pool. +USERS_CHECK_PASSWORD = False # Check the workers password? (Many pools don't) + +# Transaction Settings +# CENTRAL_WALLET ---- In basic settings at top +COINBASE_EXTRAS = '/stratumPool/' # Extra Descriptive String to incorporate in solved blocks +ALLOW_NONLOCAL_WALLET = False # Allow valid, but NON-Local wallet's + +# Bitcoind communication polling settings (In Seconds) +PREVHASH_REFRESH_INTERVAL = 5 # How often to check for new Blocks + # If using the blocknotify script (recommended) set = to MERKLE_REFRESH_INTERVAL + # (No reason to poll if we're getting pushed notifications) +MERKLE_REFRESH_INTERVAL = 60 # How often check memorypool + # This effectively resets the template and incorporates new transactions. + # This should be "slow" + +INSTANCE_ID = 31 # Not a clue what this is for... :P + +# ******************** Pool Difficulty Settings ********************* +# Again, Don't change unless you know what this is for. + +# Pool Target (Base Difficulty) +POOL_TARGET = 1 # Pool-wide difficulty target int >= 1 + +# Variable Difficulty Enable +VARIABLE_DIFF = True # Master variable difficulty enable + +# Variable diff tuning variables +VDIFF_TARGET = 30 # Target time per share (i.e. try to get 1 share per this many seconds) +VDIFF_RETARGET = 300 # Check to see if we should retarget this often +VDIFF_VARIANCE_PERCENT = 50 # Allow average time to very this % from target without retarget + +# ******************** Stats Settings ********************* + +BASIC_STATS = True # Enable basic stats page. This has stats for ALL users. + # (Requires advanced database to be enabled) + # Human : http://:/ + # JSON : http://:/stats + # (Disable if you have your own frontend) + +BASIC_STATS_PORT = 8889 # Port to listen on + +# ******************** Getwork Proxy Settings ********************* +# This enables a copy of slush's getwork proxy for old clients +# It will also auto-redirect new clients to the stratum interface +# so you can point ALL clients to: http://: + +GW_ENABLE = False # Enable the Proxy (If enabled you MUST run update_submodules) +GW_PORT = 8331 # Getwork Proxy Port +GW_DISABLE_MIDSTATE = False # Disable midstate's (Faster but breaks some clients) +GW_SEND_REAL_TARGET = False # Propigate >1 difficulty to Clients (breaks some clients) + +# ******************** Archival Settings ********************* + +ARCHIVE_SHARES = False # Use share archiving? +ARCHIVE_DELAY = 86400 # Seconds after finding a share to archive all previous shares +ARCHIVE_MODE = 'file' # Do we archive to a file (file) , or to a database table (db) + +# Archive file options +ARCHIVE_FILE = 'archives/share_archive' # Name of the archive file ( .csv extension will be appended) +ARCHIVE_FILE_APPEND_TIME = True # Append the Date/Time to the end of the filename (must be true for bzip2 compress) +ARCHIVE_FILE_COMPRESS = 'none' # Method to compress file (none,gzip,bzip2) + +# ******************** E-Mail Notification Settings ********************* + +NOTIFY_EMAIL_TO = '' # Where to send Start/Found block notifications +NOTIFY_EMAIL_TO_DEADMINER = '' # Where to send dead miner notifications +NOTIFY_EMAIL_FROM = 'root@localhost' # Sender address +NOTIFY_EMAIL_SERVER = 'localhost' # E-Mail Sender +NOTIFY_EMAIL_USERNAME = '' # E-Mail server SMTP Logon +NOTIFY_EMAIL_PASSWORD = '' +NOTIFY_EMAIL_USETLS = True + + + +# ******************** Admin settings ********************* + +# Use scripts/generateAdminHash.sh to generate the hash # for calculating SHA256 of your preferred password -ADMIN_PASSWORD_SHA256 = None -#ADMIN_PASSWORD_SHA256 = '9e6c0c1db1e0dfb3fa5159deb4ecd9715b3c8cd6b06bd4a3ad77e9a8c5694219' # SHA256 of the password +ADMIN_PASSWORD_SHA256 = '9e6c0c1db1e0dfb3fa5159deb4ecd9715b3c8cd6b06bd4a3ad77e9a8c5694219' # SHA256 of the password -IRC_NICK = None +# If ADMIN_PORT is set, you can issue commands to that port to interact with +# the system for things such as user management. It's a JSON interface following +# REST principles, so '/users' returns a list of users, '/users/1' or '/users/username' +# returns a single user. POSTs are done to lists (so /users), PUTs are done to +# items (so /users/1) +ADMIN_PORT = 8085 #Port for JSON admin commands, None to disable -''' -DATABASE_DRIVER = 'MySQLdb' -DATABASE_HOST = 'localhost' -DATABASE_DBNAME = 'pooldb' -DATABASE_USER = 'pooldb' -DATABASE_PASSWORD = '**empty**' -''' -# Pool related settings -INSTANCE_ID = 31 -CENTRAL_WALLET = 'set_valid_addresss_in_config!' -PREVHASH_REFRESH_INTERVAL = 5 # in sec -MERKLE_REFRESH_INTERVAL = 60 # How often check memorypool -COINBASE_EXTRAS = '/stratum/' diff --git a/externals/stratum-mining-proxy b/externals/stratum-mining-proxy new file mode 160000 index 0000000..9b893fa --- /dev/null +++ b/externals/stratum-mining-proxy @@ -0,0 +1 @@ +Subproject commit 9b893fab15df318670cd137636df758dfcffa005 diff --git a/launcher_demo.tac b/launcher.tac similarity index 51% rename from launcher_demo.tac rename to launcher.tac index 22e3799..41f8593 100644 --- a/launcher_demo.tac +++ b/launcher.tac @@ -1,9 +1,9 @@ -# Run me with "twistd -ny launcher_demo.tac -l -" +# Run me with "twistd -ny launcher.tac -l -" # Add conf directory to python path. # Configuration file is standard python module. import os, sys -sys.path = [os.path.join(os.getcwd(), 'conf'),] + sys.path +sys.path = [os.path.join(os.getcwd(), 'conf'),os.path.join(os.getcwd(), 'externals', 'stratum-mining-proxy'),] + sys.path from twisted.internet import defer @@ -22,9 +22,26 @@ from mining.interfaces import Interfaces from mining.interfaces import WorkerManagerInterface, TimestamperInterface, \ ShareManagerInterface, ShareLimiterInterface +if settings.VARIABLE_DIFF == True: + from mining.basic_share_limiter import BasicShareLimiter + Interfaces.set_share_limiter(BasicShareLimiter()) +else: + from mining.interfaces import ShareLimiterInterface + Interfaces.set_share_limiter(ShareLimiterInterface()) + Interfaces.set_share_manager(ShareManagerInterface()) -Interfaces.set_share_limiter(ShareLimiterInterface()) Interfaces.set_worker_manager(WorkerManagerInterface()) Interfaces.set_timestamper(TimestamperInterface()) mining.setup(on_startup) + +from lib.admin_interface import AdminInterface + +if settings.DATABASE_EXTEND == True and settings.BASIC_STATS == True : + from lib.basic_stats import BasicStats + BasicStats(on_startup) + +if settings.GW_ENABLE == True : + from lib.getwork_proxy import GetworkProxy + GetworkProxy(on_startup) + diff --git a/lib/admin_interface.py b/lib/admin_interface.py new file mode 100644 index 0000000..e137439 --- /dev/null +++ b/lib/admin_interface.py @@ -0,0 +1,181 @@ +from zope.interface import implements + +from twisted.cred.portal import IRealm, Portal +from twisted.web import server, static +from twisted.web.resource import Resource +from twisted.internet import reactor +import twisted.web.error as weberror + +import json +import datetime +import hashlib +from pprint import pprint + +from stratum import settings + +import stratum.logger +log = stratum.logger.get_logger('Admin Interface') + +import mining.DBInterface +import sha +dbi = mining.DBInterface.DBInterface() + + +class JSONDateTimeEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, (datetime.date, datetime.datetime)): + return obj.isoformat() + else: + return json.JSONEncoder.default(self, obj) + + + +class RestResource(Resource): + path_or_id = ''; + + def __init__(self): + Resource.__init__(self) + self.putChild("", self) + self.putChild('favicon.ico', static.File('statics/bitcoin.ico', defaultType='image/vnd.microsoft.icon') ) + + def get_path_id(self, request): + return request.path.replace('/' + "/".join(request.prepath), '').strip('/') + + def render(self, request): + request.setHeader('Content-Type', 'application/json; charset=utf8') + + user = request.getUser() + passwd = request.getPassword() + + m = hashlib.sha256() + m.update(passwd) + + if m.hexdigest() != settings.ADMIN_PASSWORD_SHA256: + request.setResponseCode(401) + return '"Authorisation required!"' + + links = [] + + for c in self.children: + if isinstance(self.children[c], RestResource) and c != '': + links.append('; rel="%s"' % (c, c)) + + if len(links) > 0: + request.setHeader('Link', ", ".join(links)) + + self.path_or_id = self.get_path_id(request) + + if request.method == 'POST' and self.path_or_id != '': + request.setResponseCode(405) + return '"Cannot call POST on a resource with an identifier"' + + if (request.method == 'PUT' or request.method == 'DELETE') and self.path_or_id == '': + request.setResponseCode(405) + return '"Cannot call PUT or DELETE without an identifier"' + + return Resource.render(self, request) + + + def output_item(self, request, item): + if item is None: + request.setResponseCode(404) + request.write('"The resource %s was not found"' % request.path) + return '' + + request.setHeader('Allow', 'GET PUT DELETE') + + output = json.dumps(item, cls=JSONDateTimeEncoder) + request.write(output) + return '' + + + def output_list(self, request, callback): + request.setHeader('Allow', 'GET POST') + + request.write('[') + + isFirst = True + for item in callback(): + if isFirst == False: + request.write(',') + isFirst = False + + item = json.dumps(item, cls=JSONDateTimeEncoder) + request.write(item) + + request.write(']') + + return '' + + + +class AdminInterface(RestResource): + isLeaf = False + + def __init__(self): + RestResource.__init__(self) + self.putChild("users", UsersResource()) + + + def render_GET(self, request): + return '' + + + +class UsersResource(RestResource): + isLeaf = True + + def render_GET(self, request): + if self.path_or_id == '': + return self.output_list(request, dbi.list_users) + else: + user = dbi.get_user(self.path_or_id) + return self.output_item(request, user) + + + def render_DELETE(self, request): + dbi.delete_user(self.path_or_id) + return '"OK"' + + + def render_POST(self, request): + body = request.content.read() + object = json.loads(body) + + if not 'password' in object or not 'username' in object: + request.setResponseCode(400) + return '"You must specify a username and pasword"' + + try: + username = dbi.insert_user(object['username'], object['password']) + request.setHeader('Location', '/users/%s' % username) + + return '"OK"' + except: + request.setResponseCode(409) + return '"Username taken"' + + def render_PUT(self, request): + user = dbi.get_user(self.path_or_id) + + if user is None: + return self.output_item(request, None) + + body = request.content.read() + object = json.loads(body) + + if 'password' in object: + dbi.update_user(self.path_or_id, object['password']) + + return '"OK"' + + +if settings.ADMIN_PORT is not None: + root = AdminInterface() + factory = server.Site(root) + reactor.listenTCP(settings.ADMIN_PORT, factory) + + + + + \ No newline at end of file diff --git a/lib/basic_stats.py b/lib/basic_stats.py new file mode 100644 index 0000000..9762b23 --- /dev/null +++ b/lib/basic_stats.py @@ -0,0 +1,148 @@ +from twisted.internet import reactor +from twisted.web.resource import Resource +from twisted.web import static,server + +import time +from datetime import timedelta +import json +from stratum import settings + +import stratum.logger +log = stratum.logger.get_logger('Basic Stats') + +import mining.DBInterface +dbi = mining.DBInterface.DBInterface() + +import locale +locale.setlocale(locale.LC_ALL, '') + +class Site(server.Site): + def log(self, request): + pass + +class StatsPage(Resource): + isLeaf = False + cache_html = "" + cache_json = "" + last_update = 0 + + def getChild(self, name, request): + if name == '' or name == 'stats': + return self + return Resource.getChild(self, name, request) + + def render_GET(self, request): + # Cache the results for 30 seconds, takes care of high volume/ddos + if time.time() - self.last_update < 30 : + if(request.path == "/stats"): + return self.cache_json + return self.cache_html + + # Get info + pool_stats = dbi.get_pool_stats() + workers_stats = dbi.get_workers_stats() + + # Send in JSON? + self.cache_json = json.dumps({"pool":pool_stats,"workers":workers_stats}) + + #Otherwise build webpage + r= "" + r+= "" + r+= "" + r+="" + r+="

Stratum Mining Server

" + r+="" + + pool_speed = pool_stats['pool_speed'] + pool_color="#A00" + + pool_speed = 0 if pool_speed is None else int(pool_speed) + + if pool_speed > 100 and float(pool_stats['round_progress']) < 200: + pool_color="#AA0" + if pool_speed > 100 and float(pool_stats['round_progress']) < 150: + pool_color="#0A0" + r+="" + + last_update = time.time() - float(pool_stats['bitcoin_infotime']) + bitcoin_color="#A00" + if int(pool_stats['bitcoin_connections']) > 0 and last_update < 660: + bitcoin_color="yellow" + if int(pool_stats['bitcoin_connections']) > 10 and last_update < 330: + bitcoin_color="#0A0" + r+="
Pool Stats:" + r+="Speed: " + str(pool_speed) + "Mhs" + r+="Round Shares: " + format(int(pool_stats['round_shares']),"n") + " (%.2f" % float(pool_stats['round_progress']) + "%)" + r+="Round Duration: " + str(timedelta(0,(time.time() - float(pool_stats['round_start'])))).split(".")[0] + r+="Best Share: " + format(int(pool_stats['round_best_share']),"n") + r+="Total Found: " + format(int(pool_stats['pool_total_found']),"n") + r+="
Bitcoin Stats:" + r+="Connections :"+pool_stats['bitcoin_connections'] + r+="Difficulty :"+format(int(float(pool_stats['bitcoin_difficulty'])),"n") + r+="Height :"+format(int(pool_stats['bitcoin_blocks']),"n") + r+="Balance :"+pool_stats['bitcoin_balance'] + r+="" + r+="
" + + r+="
" + + r+="" + size = len(workers_stats) + if size <= 10: + r+="" + else : + colcnt = int(size/3) + 1 + colt = colcnt + r+="" + r+="
" + r+="" + r+="" + for (w, wi) in enumerate(workers_stats): + wd = workers_stats[wi] + wc = "#A00" + if wd["speed"] > 0: + wc = "yellow" + if wd["speed"] > 100: + wc = "#0A0" + r+=""%( + wc,wi,format(wd["speed"],"n"),wd["difficulty"],format(int(wd["total_shares"]),"n"), + format(int(wd["total_rejects"]),"n"),format(int(wd["total_found"]),"n")) + r+="
WorkerSpeed/DiffShares/RejFound
%s%s/%s%s/%s%s
" + r+="" + r+="" + for (w, wi) in enumerate(workers_stats): + wd = workers_stats[wi] + wc = "#A00" + if wd["speed"] > 0: + wc = "yellow" + if wd["speed"] > 100: + wc = "#0A0" + r+=""%( + wc,wi,format(int(wd["speed"]),"n"),wd["difficulty"],format(int(wd["total_shares"]),"n"), + format(int(wd["total_rejects"]),"n"),format(int(wd["total_found"]),"n")) + colt = colt - 1 + if colt <= 0: + r+="
WorkerSpeedShares/RejFound
%s%s/%s%s/%s%s
" + r+="" + r+="" + colt = colcnt + r+="
WorkerSpeedShares/RejFound
" + self.cache_html = str(r) + self.last_update = time.time() + + # Send Results + if(request.path == "/stats"): + return self.cache_json + return self.cache_html + + +def BasicStats(start_event): + start_event.addCallback(BasicStats_start) + +def BasicStats_start(cb): + root = StatsPage() + root.putChild('favicon.ico', static.File('statics/favicon.ico', defaultType='image/vnd.microsoft.icon') ) + root.putChild('basic_stats.css', static.File('statics/basic_stats.css') ) + factory = Site(root) + reactor.listenTCP(settings.BASIC_STATS_PORT, factory) + diff --git a/lib/bitcoin_rpc.py b/lib/bitcoin_rpc.py index 731e13a..fb8d33b 100644 --- a/lib/bitcoin_rpc.py +++ b/lib/bitcoin_rpc.py @@ -19,6 +19,7 @@ def __init__(self, host, port, username, password): 'Content-Type': 'text/json', 'Authorization': 'Basic %s' % self.credentials, } + client.HTTPClientFactory.noisy = False def _call_raw(self, data): return client.getPage( @@ -66,4 +67,4 @@ def prevhash(self): @defer.inlineCallbacks def validateaddress(self, address): resp = (yield self._call('validateaddress', [address,])) - defer.returnValue(json.loads(resp)['result']) \ No newline at end of file + defer.returnValue(json.loads(resp)['result']) diff --git a/lib/bitcoin_rpc_manager.py b/lib/bitcoin_rpc_manager.py new file mode 100644 index 0000000..257a949 --- /dev/null +++ b/lib/bitcoin_rpc_manager.py @@ -0,0 +1,123 @@ +''' + Implements simple interface to bitcoind's RPC. +''' + + +import simplejson as json +from twisted.internet import defer + +from stratum import settings + +import time + +import stratum.logger +log = stratum.logger.get_logger('bitcoin_rpc_manager') + +from lib.bitcoin_rpc import BitcoinRPC + + +class BitcoinRPCManager(object): + + def __init__(self): + self.conns = {} + self.conns[0] = BitcoinRPC(settings.BITCOIN_TRUSTED_HOST, + settings.BITCOIN_TRUSTED_PORT, + settings.BITCOIN_TRUSTED_USER, + settings.BITCOIN_TRUSTED_PASSWORD) + self.curr_conn = 0 + for x in range (1, 99): + if hasattr(settings, 'BITCOIN_TRUSTED_HOST_' + str(x)) and hasattr(settings, 'BITCOIN_TRUSTED_PORT_' + str(x)) and hasattr(settings, 'BITCOIN_TRUSTED_USER_' + str(x)) and hasattr(settings, 'BITCOIN_TRUSTED_PASSWORD_' + str(x)): + self.conns[len(self.conns)] = BitcoinRPC(settings.__dict__['BITCOIN_TRUSTED_HOST_' + str(x)], + settings.__dict__['BITCOIN_TRUSTED_PORT_' + str(x)], + settings.__dict__['BITCOIN_TRUSTED_USER_' + str(x)], + settings.__dict__['BITCOIN_TRUSTED_PASSWORD_' + str(x)]) + + def next_connection(self): + time.sleep(1) + if len(self.conns) <= 1: + log.error("Problem with Pool 0 -- NO ALTERNATE POOLS!!!") + time.sleep(4) + return + log.error("Problem with Pool %i Switching to Next!" % (self.curr_conn) ) + self.curr_conn = self.curr_conn + 1 + if self.curr_conn >= len(self.conns): + self.curr_conn = 0 + + @defer.inlineCallbacks + def check_height(self): + while True: + try: + resp = (yield self.conns[self.curr_conn]._call('getinfo', [])) + break + except: + log.error("Check Height -- Pool %i Down!" % (self.curr_conn) ) + self.next_connection() + curr_height = json.loads(resp)['result']['blocks'] + log.debug("Check Height -- Current Pool %i : %i" % (self.curr_conn,curr_height) ) + for i in self.conns: + if i == self.curr_conn: + continue + + try: + resp = (yield self.conns[i]._call('getinfo', [])) + except: + log.error("Check Height -- Pool %i Down!" % (i,) ) + continue + + height = json.loads(resp)['result']['blocks'] + log.debug("Check Height -- Pool %i : %i" % (i,height) ) + if height > curr_height: + self.curr_conn = i + defer.returnValue(True) + + def _call_raw(self, data): + while True: + try: + return self.conns[self.curr_conn]._call_raw(data) + except: + self.next_connection() + + def _call(self, method, params): + while True: + try: + return self.conns[self.curr_conn]._call(method,params) + except: + self.next_connection() + + def submitblock(self, block_hex): + while True: + try: + return self.conns[self.curr_conn].submitblock(block_hex) + except: + self.next_connection() + + def getinfo(self): + while True: + try: + return self.conns[self.curr_conn].getinfo() + except: + self.next_connection() + + def getblocktemplate(self): + while True: + try: + return self.conns[self.curr_conn].getblocktemplate() + except: + self.next_connection() + + def prevhash(self): + self.check_height() + while True: + try: + return self.conns[self.curr_conn].prevhash() + except: + self.next_connection() + + def validateaddress(self, address): + while True: + try: + return self.conns[self.curr_conn].validateaddress(address) + except: + self.next_connection() + + diff --git a/lib/block_updater.py b/lib/block_updater.py index b80a024..4876c4d 100644 --- a/lib/block_updater.py +++ b/lib/block_updater.py @@ -44,7 +44,8 @@ def run(self): else: current_prevhash = None - prevhash = util.reverse_hash((yield self.bitcoin_rpc.prevhash())) + log.info("Checking for new block.") + prevhash = util.reverse_hash((yield self.bitcoin_rpc.prevhash())) if prevhash and prevhash != current_prevhash: log.info("New block! Prevhash: %s" % prevhash) update = True @@ -61,4 +62,4 @@ def run(self): finally: self.schedule() - \ No newline at end of file + diff --git a/lib/coinbaser.py b/lib/coinbaser.py index f3fd14b..7647594 100644 --- a/lib/coinbaser.py +++ b/lib/coinbaser.py @@ -1,6 +1,8 @@ import util from twisted.internet import defer +from stratum import settings + import stratum.logger log = stratum.logger.get_logger('coinbaser') @@ -32,6 +34,13 @@ def _address_check(self, result): if not self.on_load.called: self.on_load.callback(True) + + elif result['isvalid'] and settings.ALLOW_NONLOCAL_WALLET == True : + self.is_valid = True + log.warning("!!! Coinbase address '%s' is valid BUT it is not local" % self.address) + + if not self.on_load.called: + self.on_load.callback(True) else: self.is_valid = False @@ -55,4 +64,4 @@ def get_script_pubkey(self): return util.script_to_address(self.address) def get_coinbase_data(self): - return '' \ No newline at end of file + return '' diff --git a/lib/getwork_proxy.py b/lib/getwork_proxy.py new file mode 100755 index 0000000..7a76182 --- /dev/null +++ b/lib/getwork_proxy.py @@ -0,0 +1,84 @@ +import stratum.logger +log = stratum.logger.get_logger('Getwork Proxy') + +from stratum import settings +from stratum.socket_transport import SocketTransportClientFactory + +from twisted.internet import reactor, defer +from twisted.web import server + +from mining_libs import getwork_listener +from mining_libs import client_service +from mining_libs import jobs +from mining_libs import worker_registry +from mining_libs import version + +class Site(server.Site): + def log(self, request): + pass + +def on_shutdown(f): + log.info("Shutting down proxy...") + f.is_reconnecting = False # Don't let stratum factory to reconnect again + +@defer.inlineCallbacks +def on_connect(f, workers, job_registry): + log.info("Connected to Stratum pool at %s:%d" % f.main_host) + + # Hook to on_connect again + f.on_connect.addCallback(on_connect, workers, job_registry) + + # Every worker have to re-autorize + workers.clear_authorizations() + + # Subscribe for receiving jobs + log.info("Subscribing for mining jobs") + (_, extranonce1, extranonce2_size) = (yield f.rpc('mining.subscribe', [])) + job_registry.set_extranonce(extranonce1, extranonce2_size) + + defer.returnValue(f) + +def on_disconnect(f, workers, job_registry): + log.info("Disconnected from Stratum pool at %s:%d" % f.main_host) + f.on_disconnect.addCallback(on_disconnect, workers, job_registry) + + # Reject miners because we don't give a *job :-) + workers.clear_authorizations() + return f + +@defer.inlineCallbacks +def GetworkProxy_main(cb): + log.info("Stratum proxy version %s Connecting to Pool..." % version.VERSION) + + # Connect to Stratum pool + f = SocketTransportClientFactory(settings.HOSTNAME, settings.LISTEN_SOCKET_TRANSPORT, + debug=False, proxy=None, event_handler=client_service.ClientMiningService) + + job_registry = jobs.JobRegistry(f, cmd='', no_midstate=settings.GW_DISABLE_MIDSTATE, real_target=settings.GW_SEND_REAL_TARGET) + client_service.ClientMiningService.job_registry = job_registry + client_service.ClientMiningService.reset_timeout() + + workers = worker_registry.WorkerRegistry(f) + f.on_connect.addCallback(on_connect, workers, job_registry) + f.on_disconnect.addCallback(on_disconnect, workers, job_registry) + + # Cleanup properly on shutdown + reactor.addSystemEventTrigger('before', 'shutdown', on_shutdown, f) + + # Block until proxy connects to the pool + yield f.on_connect + + # Setup getwork listener + gw_site = Site(getwork_listener.Root(job_registry, workers, + stratum_host=settings.HOSTNAME, stratum_port=settings.LISTEN_SOCKET_TRANSPORT, + custom_lp=False, custom_stratum=False, + custom_user=False, custom_password=False + )) + gw_site.noisy = False + reactor.listenTCP(settings.GW_PORT, gw_site, interface='0.0.0.0') + + log.info("Getwork Proxy is online, Port: %d" % (settings.GW_PORT)) + +def GetworkProxy(start_event): + start_event.addCallback(GetworkProxy_main) + diff --git a/lib/notify_email.py b/lib/notify_email.py new file mode 100644 index 0000000..2e8867f --- /dev/null +++ b/lib/notify_email.py @@ -0,0 +1,44 @@ +import smtplib +from email.mime.text import MIMEText + +from stratum import settings + +import stratum.logger +log = stratum.logger.get_logger('Notify_Email') + +class NOTIFY_EMAIL(): + + def notify_start(self): + if settings.NOTIFY_EMAIL_TO != '': + self.send_emails(settings.NOTIFY_EMAIL_TO,'Stratum Server Started','Stratum server has started!') + + def notify_found_block(self,worker_name): + if settings.NOTIFY_EMAIL_TO != '': + text = '%s on Stratum server found a block!' % worker_name + self.send_emails(settings.NOTIFY_EMAIL_TO,'Stratum Server Found Block',text) + + def send_emails(self,to,subject,message): + tos = to.split(";") + for tov in tos: + self.send_email(tov,subject,message) + + def send_email(self,to,subject,message): + msg = MIMEText(message) + msg['Subject'] = subject + msg['From'] = settings.NOTIFY_EMAIL_FROM + msg['To'] = to + try: + s = smtplib.SMTP(settings.NOTIFY_EMAIL_SERVER) + if settings.NOTIFY_EMAIL_USERNAME != '': + if settings.NOTIFY_EMAIL_USETLS: + s.ehlo() + s.starttls() + s.ehlo() + s.login(settings.NOTIFY_EMAIL_USERNAME, settings.NOTIFY_EMAIL_PASSWORD) + s.sendmail(settings.NOTIFY_EMAIL_FROM,to,msg.as_string()) + s.quit() + except smtplib.SMTPAuthenticationError as e: + log.error('Error sending Email: %s' % e[1]) + except Exception as e: + log.error('Error sending Email: %s' % e[0]) + diff --git a/lib/template_registry.py b/lib/template_registry.py index 435aeb4..5742cde 100644 --- a/lib/template_registry.py +++ b/lib/template_registry.py @@ -61,7 +61,7 @@ def get_last_broadcast_args(self): from last known template.''' return self.last_block.broadcast_args - def add_template(self, block): + def add_template(self, block,block_height): '''Adds new template to the registry. It also clean up templates which should not be used anymore.''' @@ -94,7 +94,7 @@ def add_template(self, block): if new_block: # Tell the system about new block # It is mostly important for share manager - self.on_block_callback(prevhash) + self.on_block_callback(prevhash, block_height) # Everything is ready, let's broadcast jobs! self.on_template_callback(new_block) @@ -127,7 +127,7 @@ def _update_block(self, data): template = self.block_template_class(Interfaces.timestamper, self.coinbaser, JobIdGenerator.get_new_id()) template.fill_from_rpc(data) - self.add_template(template) + self.add_template(template,data['height']) log.info("Update finished, %.03f sec, %d txes" % \ (Interfaces.timestamper.time() - start, len(template.vtx))) @@ -161,7 +161,7 @@ def get_job(self, job_id): return j - def submit_share(self, job_id, worker_name, extranonce1_bin, extranonce2, ntime, nonce, + def submit_share(self, job_id, worker_name, session, extranonce1_bin, extranonce2, ntime, nonce, difficulty): '''Check parameters and finalize block template. If it leads to valid block candidate, asynchronously submits the block @@ -225,7 +225,9 @@ def submit_share(self, job_id, worker_name, extranonce1_bin, extranonce2, ntime, header_hex = binascii.hexlify(header_bin) target_user = self.diff_to_target(difficulty) - if hash_int > target_user: + if hash_int > target_user and \ + ( 'prev_jobid' not in session or session['prev_jobid'] < job_id \ + or 'prev_diff' not in session or hash_int > self.diff_to_target(session['prev_diff']) ): raise SubmitException("Share is above target") # Mostly for debugging purposes @@ -233,6 +235,9 @@ def submit_share(self, job_id, worker_name, extranonce1_bin, extranonce2, ntime, if hash_int <= target_info: log.info("Yay, share with diff above 100000") + # Algebra tells us the diff_to_target is the same as hash_to_diff + share_diff = int(self.diff_to_target(hash_int)) + # 5. Compare hash with target of the network if hash_int <= job.target: # Yay! It is block candidate! @@ -249,6 +254,6 @@ def submit_share(self, job_id, worker_name, extranonce1_bin, extranonce2, ntime, serialized = binascii.hexlify(job.serialize()) on_submit = self.bitcoin_rpc.submitblock(serialized) - return (header_hex, block_hash_hex, on_submit) + return (header_hex, block_hash_hex, share_diff, on_submit) - return (header_hex, block_hash_hex, None) \ No newline at end of file + return (header_hex, block_hash_hex, share_diff, None) diff --git a/lib/util.py b/lib/util.py index 45da424..050876c 100644 --- a/lib/util.py +++ b/lib/util.py @@ -142,6 +142,17 @@ def b58decode(v, length): return result +def b58encode(value): + """ encode integer 'value' as a base58 string; returns string + """ + encoded = '' + while value >= __b58base: + div, mod = divmod(value, __b58base) + encoded = __b58chars[mod] + encoded # add to left + value = div + encoded = __b58chars[value] + encoded # most significant remainder + return encoded + def reverse_hash(h): # This only revert byte order, nothing more if len(h) != 64: @@ -203,4 +214,4 @@ def script_to_address(addr): if not d: raise ValueError('invalid address') (ver, pubkeyhash) = d - return b'\x76\xa9\x14' + pubkeyhash + b'\x88\xac' \ No newline at end of file + return b'\x76\xa9\x14' + pubkeyhash + b'\x88\xac' diff --git a/mining/DBInterface.py b/mining/DBInterface.py new file mode 100644 index 0000000..05a5063 --- /dev/null +++ b/mining/DBInterface.py @@ -0,0 +1,267 @@ +from twisted.internet import reactor, defer +import time +from datetime import datetime +import Queue +import signal + +from stratum import settings + +import stratum.logger +log = stratum.logger.get_logger('DBInterface') + +class DBInterface(): + def __init__(self): + self.dbi = self.connectDB() + + def init_main(self): + self.dbi.check_tables() + + self.q = Queue.Queue() + self.queueclock = None + + self.usercache = {} + self.clearusercache() + + self.nextStatsUpdate = 0 + + self.scheduleImport() + + self.next_force_import_time = time.time() + settings.DB_LOADER_FORCE_TIME + + signal.signal(signal.SIGINT, self.signal_handler) + + def signal_handler(self, signal, frame): + print "SIGINT Detected, shutting down" + self.do_import(self.dbi, True) + reactor.stop() + + def set_bitcoinrpc(self, bitcoinrpc): + self.bitcoinrpc = bitcoinrpc + + def connectDB(self): + # Choose our database driver and put it in self.dbi + if settings.DATABASE_DRIVER == "sqlite": + log.debug('DB_Sqlite INIT') + import DB_Sqlite + return DB_Sqlite.DB_Sqlite() + elif settings.DATABASE_DRIVER == "mysql": + log.debug('DB_Mysql INIT') + import DB_Mysql + return DB_Mysql.DB_Mysql() + elif settings.DATABASE_DRIVER == "postgresql": + log.debug('DB_Postgresql INIT') + import DB_Postgresql + return DB_Postgresql.DB_Postgresql() + elif settings.DATABASE_DRIVER == "none": + log.debug('DB_None INIT') + import DB_None + return DB_None.DB_None() + else: + log.error('Invalid DATABASE_DRIVER -- using NONE') + log.debug('DB_None INIT') + import DB_None + return DB_None.DB_None() + + def clearusercache(self): + log.debug("DBInterface.clearusercache called") + self.usercache = {} + self.usercacheclock = reactor.callLater(settings.DB_USERCACHE_TIME , self.clearusercache) + + def scheduleImport(self): + # This schedule's the Import + use_thread = True + if settings.DATABASE_DRIVER == "sqlite": + use_thread = False + + if use_thread: + self.queueclock = reactor.callLater(settings.DB_LOADER_CHECKTIME , self.run_import_thread) + else: + self.queueclock = reactor.callLater(settings.DB_LOADER_CHECKTIME , self.run_import) + + def run_import_thread(self): + log.debug("run_import_thread current size: %d", self.q.qsize()) + + if self.q.qsize() >= settings.DB_LOADER_REC_MIN or time.time() >= self.next_force_import_time: # Don't incur thread overhead if we're not going to run + reactor.callInThread(self.import_thread) + + self.scheduleImport() + + def run_import(self): + log.debug("DBInterface.run_import called") + + self.do_import(self.dbi, False) + + if settings.DATABASE_EXTEND and time.time() > self.nextStatsUpdate: + self.nextStatsUpdate = time.time() + settings.DB_STATS_AVG_TIME + self.dbi.updateStats(settings.DB_STATS_AVG_TIME) + d = self.bitcoinrpc.getinfo() + d.addCallback(self._update_pool_info) + + if settings.ARCHIVE_SHARES: + self.archive_shares(self.dbi) + + self.scheduleImport() + + def import_thread(self): + # Here we are in the thread. + dbi = self.connectDB() + self.do_import(dbi, False) + + if settings.DATABASE_EXTEND and time.time() > self.nextStatsUpdate: + self.nextStatsUpdate = time.time() + settings.DB_STATS_AVG_TIME + dbi.updateStats(settings.DB_STATS_AVG_TIME) + d = self.bitcoinrpc.getinfo() + d.addCallback(self._update_pool_info) + + if settings.ARCHIVE_SHARES: + self.archive_shares(dbi) + + dbi.close() + + def _update_pool_info(self, data): + self.dbi.update_pool_info({ 'blocks' : data['blocks'], 'balance' : data['balance'], + 'connections' : data['connections'], 'difficulty' : data['difficulty'] }) + + def do_import(self, dbi, force): + log.debug("DBInterface.do_import called. force: %s, queue size: %s", 'yes' if force == True else 'no', self.q.qsize()) + + # Only run if we have data + while force == True or self.q.qsize() >= settings.DB_LOADER_REC_MIN or time.time() >= self.next_force_import_time: + self.next_force_import_time = time.time() + settings.DB_LOADER_FORCE_TIME + + force = False + # Put together the data we want to import + sqldata = [] + datacnt = 0 + + while self.q.empty() == False and datacnt < settings.DB_LOADER_REC_MAX: + datacnt += 1 + data = self.q.get() + sqldata.append(data) + self.q.task_done() + + # try to do the import, if we fail, log the error and put the data back in the queue + try: + log.info("Inserting %s Share Records", datacnt) + dbi.import_shares(sqldata) + except Exception as e: + log.error("Insert Share Records Failed: %s", e.args[0]) + for k, v in enumerate(sqldata): + self.q.put(v) + break # Allows us to sleep a little + + def archive_shares(self, dbi): + log.debug("DBInterface.archive_shares called") + found_time = dbi.archive_check() + + if found_time == 0: + return False + + log.info("Archiving shares newer than timestamp %f " % found_time) + dbi.archive_found(found_time) + + if settings.ARCHIVE_MODE == 'db': + dbi.archive_to_db(found_time) + dbi.archive_cleanup(found_time) + elif settings.ARCHIVE_MODE == 'file': + shares = dbi.archive_get_shares(found_time) + + filename = settings.ARCHIVE_FILE + + if settings.ARCHIVE_FILE_APPEND_TIME : + filename = filename + "-" + datetime.fromtimestamp(found_time).strftime("%Y-%m-%d-%H-%M-%S") + + filename = filename + ".csv" + + if settings.ARCHIVE_FILE_COMPRESS == 'gzip': + import gzip + filename = filename + ".gz" + filehandle = gzip.open(filename, 'a') + elif settings.ARCHIVE_FILE_COMPRESS == 'bzip2' and settings.ARCHIVE_FILE_APPEND_TIME : + import bz2 + filename = filename + ".bz2" + filehandle = bz2.BZFile(filename, mode='wb', buffering=4096) + else: + filehandle = open(filename, "a") + + while True: + row = shares.fetchone() + if row == None: + break + str1 = '","'.join([str(x) for x in row]) + filehandle.write('"%s"\n' % str1) + + filehandle.close() + + clean = False + + while not clean: + try: + dbi.archive_cleanup(found_time) + clean = True + except Exception as e: + clean = False + log.error("Archive Cleanup Failed... will retry to cleanup in 30 seconds") + sleep(30) + + return True + + def queue_share(self, data): + self.q.put(data) + + def found_block(self, data): + try: + log.info("Updating Found Block Share Record") + self.do_import(self.dbi, True) # We can't Update if the record is not there. + self.dbi.found_block(data) + except Exception as e: + log.error("Update Found Block Share Record Failed: %s", e.args[0]) + + def check_password(self, username, password): + if username == "": + log.info("Rejected worker for blank username") + return False + + wid = username + ":-:" + password + + if wid in self.usercache: + return True + elif self.dbi.check_password(username, password): + self.usercache[wid] = 1 + return True + elif settings.USERS_AUTOADD == True: + self.insert_user(username, password) + self.usercache[wid] = 1 + return True + + return False + + def list_users(self): + return self.dbi.list_users() + + def get_user(self, id): + return self.dbi.get_user(id) + + def insert_user(self, username, password): + return self.dbi.insert_user(username, password) + + def delete_user(self, username): + self.usercache = {} + return self.dbi.delete_user(username) + + def update_user(self, username, password): + self.usercache = {} + return self.dbi.update_user(username, password) + + def update_worker_diff(self, username, diff): + return self.dbi.update_worker_diff(username, diff) + + def get_pool_stats(self): + return self.dbi.get_pool_stats() + + def get_workers_stats(self): + return self.dbi.get_workers_stats() + + def clear_worker_diff(self): + return self.dbi.clear_worker_diff() + diff --git a/mining/DB_Mysql.py b/mining/DB_Mysql.py new file mode 100644 index 0000000..5207861 --- /dev/null +++ b/mining/DB_Mysql.py @@ -0,0 +1,1071 @@ +import time +import hashlib +from stratum import settings +import stratum.logger +log = stratum.logger.get_logger('DB_Mysql') + +import MySQLdb + +class DB_Mysql(): + def __init__(self): + log.debug("Connecting to DB") + + self.dbh = MySQLdb.connect(settings.DB_MYSQL_HOST, + settings.DB_MYSQL_USER, settings.DB_MYSQL_PASS, + settings.DB_MYSQL_DBNAME) + self.dbc = self.dbh.cursor() + + if hasattr(settings, 'PASSWORD_SALT'): + self.salt = settings.PASSWORD_SALT + else: + raise ValueError("PASSWORD_SALT isn't set, please set in config.py") + + def hash_pass(self, password): + m = hashlib.sha1() + m.update(password) + m.update(self.salt) + + return m.hexdigest() + + def updateStats(self, averageOverTime): + log.debug("Updating Stats") + # Note: we are using transactions... so we can set the speed = 0 and it doesn't take affect until we are commited. + self.dbc.execute( + """ + UPDATE `pool_worker` + SET `speed` = 0, + `alive` = 0 + """ + ); + + stime = '%.0f' % (time.time() - averageOverTime); + + self.dbc.execute( + """ + UPDATE `pool_worker` pw + LEFT JOIN ( + SELECT `worker`, ROUND(ROUND(SUM(`difficulty`)) * 4294967296) / %(average)s AS 'speed' + FROM `shares` + WHERE `time` > FROM_UNIXTIME(%(time)s) + GROUP BY `worker` + ) AS leJoin + ON leJoin.`worker` = pw.`id` + SET pw.`alive` = 1, + pw.`speed` = leJoin.`speed` + WHERE pw.`id` = leJoin.`worker` + """, + { + "time": stime, + "average": int(averageOverTime) * 1000000 + } + ) + + self.dbc.execute( + """ + UPDATE `pool` + SET `value` = ( + SELECT IFNULL(SUM(`speed`), 0) + FROM `pool_worker` + WHERE `alive` = 1 + ) + WHERE `parameter` = 'pool_speed' + """ + ) + + self.dbh.commit() + + def archive_check(self): + # Check for found shares to archive + self.dbc.execute( + """ + SELECT `time` + FROM `shares` + WHERE `upstream_result` = 1 + ORDER BY `time` + LIMIT 1 + """ + ) + + data = self.dbc.fetchone() + + if data is None or (data[0] + settings.ARCHIVE_DELAY) > time.time() : + return False + + return data[0] + + def archive_found(self, found_time): + self.dbc.execute( + """ + INSERT INTO `shares_archive_found` + SELECT s.`id`, s.`time`, s.`rem_host`, pw.`id`, s.`our_result`, + s.`upstream_result`, s.`reason`, s.`solution`, s.`block_num`, + s.`prev_block_hash`, s.`useragent`, s.`difficulty` + FROM `shares` s + LEFT JOIN `pool_worker` pw + ON s.`worker` = pw.`id` + WHERE `upstream_result` = 1 + AND `time` <= FROM_UNIXTIME(%(time)s) + """, + { + "time": found_time + } + ) + + self.dbh.commit() + + def archive_to_db(self, found_time): + self.dbc.execute( + """ + INSERT INTO `shares_archive` + SELECT s.`id`, s.`time`, s.`rem_host`, pw.`id`, s.`our_result`, + s.`upstream_result`, s.`reason`, s.`solution`, s.`block_num`, + s.`prev_block_hash`, s.`useragent`, s.`difficulty` + FROM `shares` s + LEFT JOIN `pool_worker` pw + ON s.`worker` = pw.`id` + WHERE `time` <= FROM_UNIXTIME(%(time)s) + """, + { + "time": found_time + } + ) + + self.dbh.commit() + + def archive_cleanup(self, found_time): + self.dbc.execute( + """ + DELETE FROM `shares` + WHERE `time` <= FROM_UNIXTIME(%(time)s) + """, + { + "time": found_time + } + ) + + self.dbh.commit() + + def archive_get_shares(self, found_time): + self.dbc.execute( + """ + SELECT * + FROM `shares` + WHERE `time` <= FROM_UNIXTIME(%(time)s) + """, + { + "time": found_time + } + ) + + return self.dbc + + def import_shares(self, data): + log.debug("Importing Shares") +# 0 1 2 3 4 5 6 7 8 9 10 +# data: [worker_name,block_header,block_hash,difficulty,timestamp,is_valid,ip,block_height,prev_hash,invalid_reason,best_diff] + checkin_times = {} + total_shares = 0 + best_diff = 0 + + for k, v in enumerate(data): + if settings.DATABASE_EXTEND : + total_shares += v[3] + + if v[0] in checkin_times: + if v[4] > checkin_times[v[0]] : + checkin_times[v[0]]["time"] = v[4] + else: + checkin_times[v[0]] = { + "time": v[4], + "shares": 0, + "rejects": 0 + } + + if v[5] == True : + checkin_times[v[0]]["shares"] += v[3] + else : + checkin_times[v[0]]["rejects"] += v[3] + + if v[10] > best_diff: + best_diff = v[10] + + self.dbc.execute( + """ + INSERT INTO `shares` + (time, rem_host, worker, our_result, upstream_result, + reason, solution, block_num, prev_block_hash, + useragent, difficulty) + VALUES + (FROM_UNIXTIME(%(time)s), %(host)s, + (SELECT `id` FROM `pool_worker` WHERE `username` = %(uname)s), + %(lres)s, 0, %(reason)s, '', + %(blocknum)s, %(hash)s, '', %(difficulty)s) + """, + { + "time": v[4], + "host": v[6], + "uname": v[0], + "lres": v[5], + "reason": v[9], + "blocknum": v[7], + "hash": v[8], + "difficulty": v[3] + } + ) + else: + self.dbc.execute( + """ + INSERT INTO `shares` + (time, rem_host, worker, our_result, + upstream_result, reason, solution) + VALUES + (FROM_UNIXTIME(%(time)s), %(host)s, + (SELECT `id` FROM `pool_worker` WHERE `username` = %(uname)s), + %(lres)s, 0, %(reason)s, '') + """, + { + "time": v[4], + "host": v[6], + "uname": v[0], + "lres": v[5], + "reason": v[9] + } + ) + + if settings.DATABASE_EXTEND: + self.dbc.execute( + """ + SELECT `parameter`, `value` + FROM `pool` + WHERE `parameter` = 'round_best_share' + OR `parameter` = 'round_shares' + OR `parameter` = 'bitcoin_difficulty' + OR `parameter` = 'round_progress' + """ + ) + + current_parameters = {} + + for data in self.dbc.fetchall(): + current_parameters[data[0]] = data[1] + + round_best_share = int(current_parameters['round_best_share']) + difficulty = float(current_parameters['bitcoin_difficulty']) + round_shares = int(current_parameters['round_shares']) + total_shares + + updates = [ + { + "param": "round_shares", + "value": round_shares + }, + { + "param": "round_progress", + "value": 0 if difficulty == 0 else (round_shares / difficulty) * 100 + } + ] + + if best_diff > round_best_share: + updates.append({ + "param": "round_best_share", + "value": best_diff + }) + + self.dbc.executemany( + """ + UPDATE `pool` + SET `value` = %(value)s + WHERE `parameter` = %(param)s + """, + updates + ) + + for k, v in checkin_times.items(): + self.dbc.execute( + """ + UPDATE `pool_worker` + SET `last_checkin` = FROM_UNIXTIME(%(time)s), + `total_shares` = `total_shares` + %(shares)s, + `total_rejects` = `total_rejects` + %(rejects)s + WHERE `username` = %(uname)s + """, + { + "time": v["time"], + "shares": v["shares"], + "rejects": v["rejects"], + "uname": k + } + ) + + self.dbh.commit() + + + def found_block(self, data): + # Note: difficulty = -1 here + self.dbc.execute( + """ + UPDATE `shares` + SET `upstream_result` = %(result)s, + `solution` = %(solution)s + WHERE `time` = FROM_UNIXTIME(%(time)s) + AND `username` = ( + SELECT `id` + FROM `pool_worker` + WHERE `username` = %(uname)s + ) + LIMIT 1 + """, + { + "result": data[5], + "solution": data[2], + "time": data[4], + "uname": data[0] + } + ) + + if settings.DATABASE_EXTEND and data[5] == True: + self.dbc.execute( + """ + UPDATE `pool_worker` + SET `total_found` = `total_found` + 1 + WHERE `username` = %(uname)s + """, + { + "uname": data[0] + } + ) + self.dbc.execute( + """ + SELECT `value` + FROM `pool` + WHERE `parameter` = 'pool_total_found' + """ + ) + total_found = int(self.dbc.fetchone()[0]) + 1 + + self.dbc.executemany( + """ + UPDATE `pool` + SET `value` = %(value)s + WHERE `parameter` = %(param)s + """, + [ + { + "param": "round_shares", + "value": "0" + }, + { + "param": "round_progress", + "value": "0" + }, + { + "param": "round_best_share", + "value": "0" + }, + { + "param": "round_start", + "value": time.time() + }, + { + "param": "pool_total_found", + "value": total_found + } + ] + ) + + self.dbh.commit() + + def list_users(self): + cursor = self.dbh.cursor(MySQLdb.cursors.DictCursor) + cursor.execute( + """ + SELECT * + FROM `pool_worker` + WHERE `id`> 0 + """ + ) + + while True: + results = cursor.fetchmany() + if not results: + break + + for result in results: + yield result + + cursor.close() + + def get_user(self, id_or_username): + log.debug("Finding user with id or username of %s", id_or_username) + cursor = self.dbh.cursor(MySQLdb.cursors.DictCursor) + + cursor.execute( + """ + SELECT * + FROM `pool_worker` + WHERE `id` = %(id)s + OR `username` = %(uname)s + """, + { + "id": id_or_username if id_or_username.isdigit() else -1, + "uname": id_or_username + } + ) + + user = cursor.fetchone() + cursor.close() + return user + + + def delete_user(self, id_or_username): + if id_or_username.isdigit() and id_or_username == '0': + raise Exception('You cannot delete that user') + + log.debug("Deleting user with id or username of %s", id_or_username) + + self.dbc.execute( + """ + UPDATE `shares` + SET `worker` = 0 + WHERE `worker` = ( + SELECT `id` + FROM `pool_worker` + WHERE `id` = %(id)s + OR `username` = %(uname)s + LIMIT 1 + ) + """, + { + "id": id_or_username if id_or_username.isdigit() else -1, + "uname": id_or_username + } + ) + + self.dbc.execute( + """ + DELETE FROM `pool_worker` + WHERE `id` = %(id)s + OR `username` = %(uname)s + """, + { + "id": id_or_username if id_or_username.isdigit() else -1, + "uname": id_or_username + } + ) + + self.dbh.commit() + + def insert_user(self, username, password): + log.debug("Adding new user %s", username) + + self.dbc.execute( + """ + INSERT INTO `pool_worker` + (`username`, `password`) + VALUES + (%(uname)s, %(pass)s) + """, + { + "uname": username, + "pass": self.hash_pass(password) + } + ) + + self.dbh.commit() + + return str(username) + + def update_user(self, id_or_username, password): + log.debug("Updating password for user %s", id_or_username); + + self.dbc.execute( + """ + UPDATE `pool_worker` + SET `password` = %(pass)s + WHERE `id` = %(id)s + OR `username` = %(uname)s + """, + { + "id": id_or_username if id_or_username.isdigit() else -1, + "uname": id_or_username, + "pass": self.hash_pass(password) + } + ) + + self.dbh.commit() + + def update_worker_diff(self, username, diff): + log.debug("Setting difficulty for %s to %s", username, diff) + + self.dbc.execute( + """ + UPDATE `pool_worker` + SET `difficulty` = %(diff)s + WHERE `username` = %(uname)s + """, + { + "uname": username, + "diff": diff + } + ) + + self.dbh.commit() + + def clear_worker_diff(self): + if settings.DATABASE_EXTEND == True: + log.debug("Resetting difficulty for all workers") + + self.dbc.execute( + """ + UPDATE `pool_worker` + SET `difficulty` = 0 + """ + ) + + self.dbh.commit() + + def check_password(self, username, password): + log.debug("Checking username/password for %s", username) + + self.dbc.execute( + """ + SELECT COUNT(*) + FROM `pool_worker` + WHERE `username` = %(uname)s + AND `password` = %(pass)s + """, + { + "uname": username, + "pass": self.hash_pass(password) + } + ) + + data = self.dbc.fetchone() + + if data[0] > 0: + return True + + return False + + def update_pool_info(self, pi): + self.dbc.executemany( + """ + UPDATE `pool` + SET `value` = %(value)s + WHERE `parameter` = %(param)s + """, + [ + { + "param": "bitcoin_blocks", + "value": pi['blocks'] + }, + { + "param": "bitcoin_balance", + "value": pi['balance'] + }, + { + "param": "bitcoin_connections", + "value": pi['connections'] + }, + { + "param": "bitcoin_difficulty", + "value": pi['difficulty'] + }, + { + "param": "bitcoin_infotime", + "value": time.time() + } + ] + ) + + self.dbh.commit() + + def get_pool_stats(self): + self.dbc.execute( + """ + SELECT * FROM `pool` + """ + ) + + ret = {} + + for data in self.dbc.fetchall(): + ret[data[0]] = data[1] + + return ret + + def get_workers_stats(self): + self.dbc.execute( + """ + SELECT `username`, `speed`, `last_checkin`, `total_shares`, + `total_rejects`, `total_found`, `alive`, `difficulty` + FROM `pool_worker` + WHERE `id` > 0 + """ + ) + + ret = {} + + for data in self.dbc.fetchall(): + ret[data[0]] = { + "username" : data[0], + "speed" : int(data[1]), + "last_checkin" : time.mktime(data[2].timetuple()), + "total_shares" : int(data[3]), + "total_rejects" : int(data[4]), + "total_found" : int(data[5]), + "alive" : True if data[6] is 1 else False, + "difficulty" : int(data[7]) + } + + return ret + + def close(self): + self.dbh.close() + + def check_tables(self): + log.debug("Checking Tables") + + # Do we have our tables? + shares_exist = False + + self.dbc.execute( + """ + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.STATISTICS + WHERE `table_schema` = %(schema)s + AND `table_name` = 'shares' + """, + { + "schema": settings.DB_MYSQL_DBNAME + } + ) + + data = self.dbc.fetchone() + + if data[0] <= 0 : + self.update_version_1() # no, we don't, so create them + + if settings.DATABASE_EXTEND == True : + self.update_tables() + + def update_tables(self): + version = 0 + current_version = 7 + + while version < current_version: + self.dbc.execute( + """ + SELECT `value` + FROM `pool` + WHERE parameter = 'DB Version' + """ + ) + + data = self.dbc.fetchone() + version = int(data[0]) + + if version < current_version: + log.info("Updating Database from %i to %i" % (version, version +1)) + getattr(self, 'update_version_' + str(version) )() + + def update_version_1(self): + if settings.DATABASE_EXTEND == True: + self.dbc.execute( + """ + CREATE TABLE IF NOT EXISTS `shares` + ( + `id` SERIAL PRIMARY KEY, + `time` TIMESTAMP, + `rem_host` TEXT, + `username` TEXT, + `our_result` BOOLEAN, + `upstream_result` BOOLEAN, + `reason` TEXT, + `solution` TEXT, + `block_num` INTEGER, + `prev_block_hash` TEXT, + `useragent` TEXT, + `difficulty` INTEGER + ) + ENGINE=MYISAM + """ + ) + + self.dbc.execute( + """ + CREATE INDEX `shares_username` ON `shares`(`username`(10)) + """ + ) + + self.dbc.execute( + """ + CREATE TABLE IF NOT EXISTS `pool_worker` + ( + `id` SERIAL PRIMARY KEY, + `username` TEXT, + `password` TEXT, + `speed` INTEGER, + `last_checkin` TIMESTAMP + ) + ENGINE=MYISAM + """ + ) + + self.dbc.execute( + """ + CREATE INDEX `pool_worker_username` ON `pool_worker`(`username`(10)) + """ + ) + + self.dbc.execute( + """ + CREATE TABLE IF NOT EXISTS `pool` + ( + `parameter` TEXT, + `value` TEXT + ) + """ + ) + + self.dbc.execute( + """ + ALTER TABLE `pool_worker` ADD `total_shares` INTEGER DEFAULT 0 + """ + ) + + self.dbc.execute( + """ + ALTER TABLE `pool_worker` ADD `total_rejects` INTEGER DEFAULT 0 + """ + ) + + self.dbc.execute( + """ + ALTER TABLE `pool_worker` ADD `total_found` INTEGER DEFAULT 0 + """ + ) + + self.dbc.execute( + """ + INSERT INTO `pool` + (parameter, value) + VALUES + ('DB Version', 2) + """ + ) + else: + self.dbc.execute( + """ + CREATE TABLE IF NOT EXISTS `shares` + ( + `id` SERIAL, + `time` TIMESTAMP, + `rem_host` TEXT, + `username` TEXT, + `our_result` INTEGER, + `upstream_result` INTEGER, + `reason` TEXT, + `solution` TEXT + ) + ENGINE=MYISAM + """ + ) + + self.dbc.execute( + """ + CREATE INDEX `shares_username` ON `shares`(`username`(10)) + """ + ) + + self.dbc.execute( + """ + CREATE TABLE IF NOT EXISTS `pool_worker` + ( + `id` SERIAL, + `username` TEXT, + `password` TEXT + ) + ENGINE=MYISAM + """ + ) + + self.dbc.execute( + """ + CREATE INDEX `pool_worker_username` ON `pool_worker`(`username`(10)) + """ + ) + + self.dbh.commit() + + + def update_version_2(self): + log.info("running update 2") + + self.dbc.executemany( + """ + INSERT INTO `pool` (`parameter`, `value`) VALUES (%s, %s) + """, + [ + ('bitcoin_blocks', 0), + ('bitcoin_balance', 0), + ('bitcoin_connections', 0), + ('bitcoin_difficulty', 0), + ('pool_speed', 0), + ('pool_total_found', 0), + ('round_shares', 0), + ('round_progress', 0), + ('round_start', time.time()) + ] + ) + + self.dbc.execute( + """ + UPDATE `pool` + SET `value` = 3 + WHERE `parameter` = 'DB Version' + """ + ) + + self.dbh.commit() + + def update_version_3(self): + log.info("running update 3") + + self.dbc.executemany( + """ + INSERT INTO `pool` (`parameter`, `value`) VALUES (%s, %s) + """, + [ + ('round_best_share', 0), + ('bitcoin_infotime', 0) + ] + ) + + self.dbc.execute( + """ + ALTER TABLE `pool_worker` ADD `alive` BOOLEAN + """ + ) + + self.dbc.execute( + """ + UPDATE `pool` + SET `value` = 4 + WHERE `parameter` = 'DB Version' + """ + ) + + self.dbh.commit() + + def update_version_4(self): + log.info("running update 4") + + self.dbc.execute( + """ + ALTER TABLE `pool_worker` + ADD `difficulty` INTEGER DEFAULT 0 + """ + ) + + self.dbc.execute( + """ + CREATE TABLE IF NOT EXISTS `shares_archive` + ( + `id` SERIAL PRIMARY KEY, + `time` TIMESTAMP, + `rem_host` TEXT, + `username` TEXT, + `our_result` BOOLEAN, + `upstream_result` BOOLEAN, + `reason` TEXT, + `solution` TEXT, + `block_num` INTEGER, + `prev_block_hash` TEXT, + `useragent` TEXT, + `difficulty` INTEGER + ) + ENGINE = MYISAM + """ + ) + + self.dbc.execute( + """ + CREATE TABLE IF NOT EXISTS `shares_archive_found` + ( + `id` SERIAL PRIMARY KEY, + `time` TIMESTAMP, + `rem_host` TEXT, + `username` TEXT, + `our_result` BOOLEAN, + `upstream_result` BOOLEAN, + `reason` TEXT, + `solution` TEXT, + `block_num` INTEGER, + `prev_block_hash` TEXT, + `useragent` TEXT, + `difficulty` INTEGER + ) + ENGINE = MYISAM + """ + ) + + self.dbc.execute( + """ + UPDATE `pool` + SET `value` = 5 + WHERE `parameter` = 'DB Version' + """ + ) + + self.dbh.commit() + + def update_version_5(self): + log.info("running update 5") + + self.dbc.execute( + """ + ALTER TABLE `pool` + ADD PRIMARY KEY (`parameter`(100)) + """ + ) + + # Adjusting indicies on table: shares + self.dbc.execute( + """ + DROP INDEX `shares_username` ON `shares` + """ + ) + + self.dbc.execute( + """ + CREATE INDEX `shares_time_username` ON `shares`(`time`, `username`(10)) + """ + ) + + self.dbc.execute( + """ + CREATE INDEX `shares_upstreamresult` ON `shares`(`upstream_result`) + """ + ) + + self.dbc.execute( + """ + UPDATE `pool` + SET `value` = 6 + WHERE `parameter` = 'DB Version' + """ + ) + + self.dbh.commit() + + def update_version_6(self): + log.info("running update 6") + + self.dbc.execute( + """ + ALTER TABLE `pool` + CHARACTER SET = utf8, + COLLATE = utf8_general_ci, + ENGINE = InnoDB, + CHANGE COLUMN `parameter` `parameter` VARCHAR(128) CHARACTER SET 'utf8' COLLATE 'utf8_general_ci' NOT NULL, + CHANGE COLUMN `value` `value` VARCHAR(512) CHARACTER SET 'utf8' COLLATE 'utf8_general_ci' NULL, + DROP PRIMARY KEY, ADD PRIMARY KEY (`parameter`) + """ + ) + + self.dbc.execute( + """ + UPDATE `pool_worker` + SET `password` = SHA1(CONCAT(password, %(salt)s)) + WHERE id > 0 + """, + { + "salt": self.salt + } + ) + + self.dbc.execute( + """ + ALTER TABLE `pool_worker` + CHARACTER SET = utf8, + COLLATE = utf8_general_ci, + ENGINE = InnoDB, + CHANGE COLUMN `username` `username` VARCHAR(512) CHARACTER SET 'utf8' COLLATE 'utf8_general_ci' NOT NULL, + CHANGE COLUMN `password` `password` CHAR(40) CHARACTER SET 'utf8' COLLATE 'utf8_bin' NOT NULL, + CHANGE COLUMN `speed` `speed` INT(10) UNSIGNED NOT NULL DEFAULT '0', + CHANGE COLUMN `total_shares` `total_shares` INT(10) UNSIGNED NOT NULL DEFAULT '0', + CHANGE COLUMN `total_rejects` `total_rejects` INT(10) UNSIGNED NOT NULL DEFAULT '0', + CHANGE COLUMN `total_found` `total_found` INT(10) UNSIGNED NOT NULL DEFAULT '0', + CHANGE COLUMN `alive` `alive` TINYINT(1) UNSIGNED NOT NULL DEFAULT '0', + CHANGE COLUMN `difficulty` `difficulty` INT(10) UNSIGNED NOT NULL DEFAULT '0', + ADD UNIQUE INDEX `pool_worker-username` (`username`(128) ASC), + ADD INDEX `pool_worker-alive` (`alive`), + DROP INDEX `pool_worker_username`, + DROP INDEX `id` + """ + ) + + self.dbc.execute( + """ + ALTER TABLE `shares` + ADD COLUMN `worker` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 AFTER `username`, + DROP INDEX `id`, + ENGINE = InnoDB; + """ + ) + + self.dbc.execute( + """ + UPDATE `shares` + JOIN `pool_worker` + ON `pool_worker`.`username` = `shares`.`username` + SET `worker` = `pool_worker`.`id` + """ + ) + + self.dbc.execute( + """ + SET SESSION sql_mode='NO_AUTO_VALUE_ON_ZERO'; + """ + ) + + self.dbc.execute( + """ + INSERT INTO `pool_worker` + (`id`, `username`, `password`) + VALUES + (0, SHA1(RAND(CURRENT_TIMESTAMP)), SHA1(CURRENT_TIMESTAMP)) + """ + ) + + self.dbc.execute( + """ + SET SESSION sql_mode=''; + """ + ) + + self.dbc.execute( + """ + ALTER TABLE `shares` + ADD CONSTRAINT `workerid` + FOREIGN KEY (`worker` ) + REFERENCES `pool_worker` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION, + DROP INDEX `shares_time_username`, + ADD INDEX `shares_time_worker` (`time` ASC, `worker` ASC), + ADD INDEX `shares_worker` (`worker` ASC), + DROP COLUMN `username` + """ + ) + + self.dbc.execute( + """ + UPDATE `pool` + SET `value` = 7 + WHERE `parameter` = 'DB Version' + """ + ) + + self.dbh.commit() + diff --git a/mining/DB_None.py b/mining/DB_None.py new file mode 100644 index 0000000..d4e8df5 --- /dev/null +++ b/mining/DB_None.py @@ -0,0 +1,54 @@ +import stratum.logger +log = stratum.logger.get_logger('None') + +class DB_None(): + def __init__(self): + log.debug("Connecting to DB") + + def updateStats(self,averageOverTime): + log.debug("Updating Stats") + + def import_shares(self,data): + log.debug("Importing Shares") + + def found_block(self,data): + log.debug("Found Block") + + def get_user(self, id_or_username): + log.debug("Get User") + + def list_users(self): + log.debug("List Users") + + def delete_user(self,username): + log.debug("Deleting Username") + + def insert_user(self,username,password): + log.debug("Adding Username/Password") + + def update_user(self,username,password): + log.debug("Updating Username/Password") + + def check_password(self,username,password): + log.debug("Checking Username/Password") + return True + + def update_pool_info(self,pi): + log.debug("Update Pool Info") + + def get_pool_stats(self): + log.debug("Get Pool Stats") + ret = {} + return ret + + def get_workers_stats(self): + log.debug("Get Workers Stats") + ret = {} + return ret + + def check_tables(self): + log.debug("Checking Tables") + + def close(self): + log.debug("Close Connection") + diff --git a/mining/DB_Postgresql.py b/mining/DB_Postgresql.py new file mode 100644 index 0000000..6a3fda9 --- /dev/null +++ b/mining/DB_Postgresql.py @@ -0,0 +1,394 @@ +import time +import hashlib +from stratum import settings +import stratum.logger +log = stratum.logger.get_logger('DB_Postgresql') + +import psycopg2 +from psycopg2 import extras + +class DB_Postgresql(): + def __init__(self): + log.debug("Connecting to DB") + self.dbh = psycopg2.connect("host='"+settings.DB_PGSQL_HOST+"' dbname='"+settings.DB_PGSQL_DBNAME+"' user='"+settings.DB_PGSQL_USER+\ + "' password='"+settings.DB_PGSQL_PASS+"'") + # TODO -- set the schema + self.dbc = self.dbh.cursor() + + if hasattr(settings, 'PASSWORD_SALT'): + self.salt = settings.PASSWORD_SALT + else: + raise ValueError("PASSWORD_SALT isn't set, please set in config.py") + + def updateStats(self,averageOverTime): + log.debug("Updating Stats") + # Note: we are using transactions... so we can set the speed = 0 and it doesn't take affect until we are commited. + self.dbc.execute("update pool_worker set speed = 0, alive = 'f'"); + stime = '%.2f' % ( time.time() - averageOverTime ); + self.dbc.execute("select username,SUM(difficulty) from shares where time > to_timestamp(%s) group by username", [stime]) + total_speed = 0 + for name,shares in self.dbc.fetchall(): + speed = int(int(shares) * pow(2,32)) / ( int(averageOverTime) * 1000 * 1000) + total_speed += speed + self.dbc.execute("update pool_worker set speed = %s, alive = 't' where username = %s", (speed,name)) + self.dbc.execute("update pool set value = %s where parameter = 'pool_speed'",[total_speed]) + self.dbh.commit() + + def archive_check(self): + # Check for found shares to archive + self.dbc.execute("select time from shares where upstream_result = true order by time limit 1") + data = self.dbc.fetchone() + if data is None or (data[0] + settings.ARCHIVE_DELAY) > time.time() : + return False + return data[0] + + def archive_found(self,found_time): + self.dbc.execute("insert into shares_archive_found select * from shares where upstream_result = true and time <= to_timestamp(%s)", [found_time]) + self.dbh.commit() + + def archive_to_db(self,found_time): + self.dbc.execute("insert into shares_archive select * from shares where time <= to_timestamp(%s)",[found_time]) + self.dbh.commit() + + def archive_cleanup(self,found_time): + self.dbc.execute("delete from shares where time <= to_timestamp(%s)",[found_time]) + self.dbh.commit() + + def archive_get_shares(self,found_time): + self.dbc.execute("select * from shares where time <= to_timestamp(%s)",[found_time]) + return self.dbc + + def import_shares(self,data): + log.debug("Importing Shares") +# 0 1 2 3 4 5 6 7 8 9 10 +# data: [worker_name,block_header,block_hash,difficulty,timestamp,is_valid,ip,block_height,prev_hash,invalid_reason,best_diff] + checkin_times = {} + total_shares = 0 + best_diff = 0 + for k,v in enumerate(data): + if settings.DATABASE_EXTEND : + total_shares += v[3] + if v[0] in checkin_times: + if v[4] > checkin_times[v[0]] : + checkin_times[v[0]]["time"] = v[4] + else: + checkin_times[v[0]] = {"time": v[4], "shares": 0, "rejects": 0 } + + if v[5] == True : + checkin_times[v[0]]["shares"] += v[3] + else : + checkin_times[v[0]]["rejects"] += v[3] + + if v[10] > best_diff: + best_diff = v[10] + + self.dbc.execute("insert into shares " +\ + "(time,rem_host,username,our_result,upstream_result,reason,solution,block_num,prev_block_hash,useragent,difficulty) " +\ + "VALUES (to_timestamp(%s),%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + (v[4],v[6],v[0],bool(v[5]),False,v[9],'',v[7],v[8],'',v[3]) ) + else : + self.dbc.execute("insert into shares (time,rem_host,username,our_result,upstream_result,reason,solution) VALUES " +\ + "(to_timestamp(%s),%s,%s,%s,%s,%s,%s)", + (v[4],v[6],v[0],bool(v[5]),False,v[9],'') ) + + if settings.DATABASE_EXTEND : + self.dbc.execute("select value from pool where parameter = 'round_shares'") + round_shares = int(self.dbc.fetchone()[0]) + total_shares + self.dbc.execute("update pool set value = %s where parameter = 'round_shares'",[round_shares]) + + self.dbc.execute("select value from pool where parameter = 'round_best_share'") + round_best_share = int(self.dbc.fetchone()[0]) + if best_diff > round_best_share: + self.dbc.execute("update pool set value = %s where parameter = 'round_best_share'",[best_diff]) + + self.dbc.execute("select value from pool where parameter = 'bitcoin_difficulty'") + difficulty = float(self.dbc.fetchone()[0]) + + if difficulty == 0: + progress = 0 + else: + progress = (round_shares/difficulty)*100 + self.dbc.execute("update pool set value = %s where parameter = 'round_progress'",[progress]) + + for k,v in checkin_times.items(): + self.dbc.execute("update pool_worker set last_checkin = to_timestamp(%s), total_shares = total_shares + %s, total_rejects = total_rejects + %s where username = %s", + (v["time"],v["shares"],v["rejects"],k)) + + self.dbh.commit() + + + def found_block(self,data): + # Note: difficulty = -1 here + self.dbc.execute("update shares set upstream_result = %s, solution = %s where id in (select id from shares where time = to_timestamp(%s) and username = %s limit 1)", + (bool(data[5]),data[2],data[4],data[0])) + if settings.DATABASE_EXTEND and data[5] == True : + self.dbc.execute("update pool_worker set total_found = total_found + 1 where username = %s",(data[0],)) + self.dbc.execute("select value from pool where parameter = 'pool_total_found'") + total_found = int(self.dbc.fetchone()[0]) + 1 + self.dbc.executemany("update pool set value = %s where parameter = %s",[(0,'round_shares'), + (0,'round_progress'), + (0,'round_best_share'), + (time.time(),'round_start'), + (total_found,'pool_total_found') + ]) + self.dbh.commit() + + def get_user(self, id_or_username): + log.debug("Finding user with id or username of %s", id_or_username) + cursor = self.dbh.cursor(cursor_factory=extras.DictCursor) + + cursor.execute( + """ + SELECT * + FROM pool_worker + WHERE id = %(id)s + OR username = %(uname)s + """, + { + "id": id_or_username if id_or_username.isdigit() else -1, + "uname": id_or_username + } + ) + + user = cursor.fetchone() + cursor.close() + return user + + def list_users(self): + cursor = self.dbh.cursor(cursor_factory=extras.DictCursor) + cursor.execute( + """ + SELECT * + FROM pool_worker + WHERE id > 0 + """ + ) + + while True: + results = cursor.fetchmany() + if not results: + break + + for result in results: + yield result + + def delete_user(self, id_or_username): + log.debug("Deleting Username") + self.dbc.execute( + """ + delete from pool_worker where id = %(id)s or username = %(uname)s + """, + { + "id": id_or_username if id_or_username.isdigit() else -1, + "uname": id_or_username + } + ) + self.dbh.commit() + + def insert_user(self,username,password): + log.debug("Adding Username/Password") + m = hashlib.sha1() + m.update(password) + m.update(self.salt) + self.dbc.execute("insert into pool_worker (username,password) VALUES (%s,%s)", + (username, m.hexdigest() )) + self.dbh.commit() + + return str(username) + + def update_user(self, id_or_username, password): + log.debug("Updating Username/Password") + m = hashlib.sha1() + m.update(password) + m.update(self.salt) + self.dbc.execute( + """ + update pool_worker set password = %(pass)s where id = %(id)s or username = %(uname)s + """, + { + "id": id_or_username if id_or_username.isdigit() else -1, + "uname": id_or_username, + "pass": m.hexdigest() + } + ) + self.dbh.commit() + + def update_worker_diff(self,username,diff): + self.dbc.execute("update pool_worker set difficulty = %s where username = %s",(diff,username)) + self.dbh.commit() + + def clear_worker_diff(self): + if settings.DATABASE_EXTEND == True : + self.dbc.execute("update pool_worker set difficulty = 0") + self.dbh.commit() + + def check_password(self,username,password): + log.debug("Checking Username/Password") + m = hashlib.sha1() + m.update(password) + m.update(self.salt) + self.dbc.execute("select COUNT(*) from pool_worker where username = %s and password = %s", + (username, m.hexdigest() )) + data = self.dbc.fetchone() + if data[0] > 0 : + return True + return False + + def update_pool_info(self,pi): + self.dbc.executemany("update pool set value = %s where parameter = %s",[(pi['blocks'],"bitcoin_blocks"), + (pi['balance'],"bitcoin_balance"), + (pi['connections'],"bitcoin_connections"), + (pi['difficulty'],"bitcoin_difficulty"), + (time.time(),"bitcoin_infotime") + ]) + self.dbh.commit() + + def get_pool_stats(self): + self.dbc.execute("select * from pool") + ret = {} + for data in self.dbc.fetchall(): + ret[data[0]] = data[1] + return ret + + def get_workers_stats(self): + self.dbc.execute("select username,speed,last_checkin,total_shares,total_rejects,total_found,alive,difficulty from pool_worker") + ret = {} + for data in self.dbc.fetchall(): + ret[data[0]] = { "username" : data[0], + "speed" : data[1], + "last_checkin" : time.mktime(data[2].timetuple()), + "total_shares" : data[3], + "total_rejects" : data[4], + "total_found" : data[5], + "alive" : data[6], + "difficulty" : data[7] } + return ret + + def close(self): + self.dbh.close() + + def check_tables(self): + log.debug("Checking Tables") + + shares_exist = False + self.dbc.execute("select COUNT(*) from pg_catalog.pg_tables where schemaname = %(schema)s and tablename = 'shares'", + {"schema": settings.DB_PGSQL_SCHEMA }) + data = self.dbc.fetchone() + if data[0] <= 0 : + self.update_version_1() + + if settings.DATABASE_EXTEND == True : + self.update_tables() + + + def update_tables(self): + version = 0 + current_version = 7 + while version < current_version : + self.dbc.execute("select value from pool where parameter = 'DB Version'") + data = self.dbc.fetchone() + version = int(data[0]) + if version < current_version : + log.info("Updating Database from %i to %i" % (version, version +1)) + getattr(self, 'update_version_' + str(version) )() + + def update_version_1(self): + if settings.DATABASE_EXTEND == True : + self.dbc.execute("create table shares" +\ + "(id serial primary key,time timestamp,rem_host TEXT, username TEXT, our_result BOOLEAN, upstream_result BOOLEAN, reason TEXT, solution TEXT, " +\ + "block_num INTEGER, prev_block_hash TEXT, useragent TEXT, difficulty INTEGER)") + self.dbc.execute("create index shares_username ON shares(username)") + self.dbc.execute("create table pool_worker" +\ + "(id serial primary key,username TEXT, password TEXT, speed INTEGER, last_checkin timestamp)") + self.dbc.execute("create index pool_worker_username ON pool_worker(username)") + self.dbc.execute("alter table pool_worker add total_shares INTEGER default 0") + self.dbc.execute("alter table pool_worker add total_rejects INTEGER default 0") + self.dbc.execute("alter table pool_worker add total_found INTEGER default 0") + self.dbc.execute("create table pool(parameter TEXT, value TEXT)") + self.dbc.execute("insert into pool (parameter,value) VALUES ('DB Version',2)") + else : + self.dbc.execute("create table shares" + \ + "(id serial,time timestamp,rem_host TEXT, username TEXT, our_result BOOLEAN, upstream_result BOOLEAN, reason TEXT, solution TEXT)") + self.dbc.execute("create index shares_username ON shares(username)") + self.dbc.execute("create table pool_worker(id serial,username TEXT, password TEXT)") + self.dbc.execute("create index pool_worker_username ON pool_worker(username)") + self.dbh.commit() + + def update_version_2(self): + log.info("running update 2") + self.dbc.executemany("insert into pool (parameter,value) VALUES (%s,%s)",[('bitcoin_blocks',0), + ('bitcoin_balance',0), + ('bitcoin_connections',0), + ('bitcoin_difficulty',0), + ('pool_speed',0), + ('pool_total_found',0), + ('round_shares',0), + ('round_progress',0), + ('round_start',time.time()) + ]) + self.dbc.execute("update pool set value = 3 where parameter = 'DB Version'") + self.dbh.commit() + + def update_version_3(self): + log.info("running update 3") + self.dbc.executemany("insert into pool (parameter,value) VALUES (%s,%s)",[ + ('round_best_share',0), + ('bitcoin_infotime',0) + ]) + self.dbc.execute("alter table pool_worker add alive BOOLEAN") + self.dbc.execute("update pool set value = 4 where parameter = 'DB Version'") + self.dbh.commit() + + def update_version_4(self): + log.info("running update 4") + self.dbc.execute("alter table pool_worker add difficulty INTEGER default 0") + self.dbc.execute("create table shares_archive" +\ + "(id serial primary key,time timestamp,rem_host TEXT, username TEXT, our_result BOOLEAN, upstream_result BOOLEAN, reason TEXT, solution TEXT, " +\ + "block_num INTEGER, prev_block_hash TEXT, useragent TEXT, difficulty INTEGER)") + self.dbc.execute("create table shares_archive_found" +\ + "(id serial primary key,time timestamp,rem_host TEXT, username TEXT, our_result BOOLEAN, upstream_result BOOLEAN, reason TEXT, solution TEXT, " +\ + "block_num INTEGER, prev_block_hash TEXT, useragent TEXT, difficulty INTEGER)") + self.dbc.execute("update pool set value = 5 where parameter = 'DB Version'") + self.dbh.commit() + + def update_version_5(self): + log.info("running update 5") + # Adding Primary key to table: pool + self.dbc.execute("alter table pool add primary key (parameter)") + self.dbh.commit() + # Adjusting indicies on table: shares + self.dbc.execute("DROP INDEX shares_username") + self.dbc.execute("CREATE INDEX shares_time_username ON shares(time,username)") + self.dbc.execute("CREATE INDEX shares_upstreamresult ON shares(upstream_result)") + self.dbh.commit() + + self.dbc.execute("update pool set value = 6 where parameter = 'DB Version'") + self.dbh.commit() + + def update_version_6(self): + log.info("running update 6") + + try: + self.dbc.execute("CREATE EXTENSION pgcrypto") + except psycopg2.ProgrammingError: + log.info("pgcrypto already added to database") + except psycopg2.OperationalError: + raise Exception("Could not add pgcrypto extension to database. Have you got it installed? Ubuntu is postgresql-contrib") + self.dbh.commit() + + # Optimising table layout + self.dbc.execute("ALTER TABLE pool " +\ + "ALTER COLUMN parameter TYPE character varying(128), ALTER COLUMN value TYPE character varying(512);") + self.dbh.commit() + + self.dbc.execute("UPDATE pool_worker SET password = encode(digest(concat(password, %s), 'sha1'), 'hex') WHERE id > 0", [self.salt]) + self.dbh.commit() + + self.dbc.execute("ALTER TABLE pool_worker " +\ + "ALTER COLUMN username TYPE character varying(512), ALTER COLUMN password TYPE character(40), " +\ + "ADD CONSTRAINT username UNIQUE (username)") + self.dbh.commit() + + self.dbc.execute("update pool set value = 7 where parameter = 'DB Version'") + self.dbh.commit() + diff --git a/mining/DB_Sqlite.py b/mining/DB_Sqlite.py new file mode 100644 index 0000000..5218439 --- /dev/null +++ b/mining/DB_Sqlite.py @@ -0,0 +1,299 @@ +import time +from stratum import settings +import stratum.logger +log = stratum.logger.get_logger('DB_Sqlite') + +import sqlite3 + +class DB_Sqlite(): + def __init__(self): + log.debug("Connecting to DB") + self.dbh = sqlite3.connect(settings.DB_SQLITE_FILE) + self.dbc = self.dbh.cursor() + + def updateStats(self,averageOverTime): + log.debug("Updating Stats") + # Note: we are using transactions... so we can set the speed = 0 and it doesn't take affect until we are commited. + self.dbc.execute("update pool_worker set speed = 0, alive = 0"); + stime = '%.2f' % ( time.time() - averageOverTime ); + self.dbc.execute("select username,SUM(difficulty) from shares where time > :time group by username", {'time':stime}) + total_speed = 0 + sqldata = [] + for name,shares in self.dbc.fetchall(): + speed = int(int(shares) * pow(2,32)) / ( int(averageOverTime) * 1000 * 1000) + total_speed += speed + sqldata.append({'speed':speed,'user':name}) + self.dbc.executemany("update pool_worker set speed = :speed, alive = 1 where username = :user",sqldata) + self.dbc.execute("update pool set value = :val where parameter = 'pool_speed'",{'val':total_speed}) + self.dbh.commit() + + def archive_check(self): + # Check for found shares to archive + self.dbc.execute("select time from shares where upstream_result = 1 order by time limit 1") + data = self.dbc.fetchone() + if data is None or (data[0] + settings.ARCHIVE_DELAY) > time.time() : + return False + return data[0] + + def archive_found(self,found_time): + self.dbc.execute("insert into shares_archive_found select * from shares where upstream_result = 1 and time <= :time",{'time':found_time}) + self.dbh.commit() + + def archive_to_db(self,found_time): + self.dbc.execute("insert into shares_archive select * from shares where time <= :time",{'time':found_time}) + self.dbh.commit() + + def archive_cleanup(self,found_time): + self.dbc.execute("delete from shares where time <= :time",{'time':found_time}) + self.dbc.execute("vacuum") + self.dbh.commit() + + def archive_get_shares(self,found_time): + self.dbc.execute("select * from shares where time <= :time",{'time':found_time}) + return self.dbc + + def import_shares(self,data): + log.debug("Importing Shares") +# 0 1 2 3 4 5 6 7 8 9 10 +# data: [worker_name,block_header,block_hash,difficulty,timestamp,is_valid,ip,block_height,prev_hash,invalid_reason,share_diff] + checkin_times = {} + total_shares = 0 + best_diff = 0 + sqldata = [] + for k,v in enumerate(data): + if settings.DATABASE_EXTEND : + total_shares += v[3] + if v[0] in checkin_times: + if v[4] > checkin_times[v[0]] : + checkin_times[v[0]]["time"] = v[4] + else: + checkin_times[v[0]] = {"time": v[4], "shares": 0, "rejects": 0 } + + if v[5] == True : + checkin_times[v[0]]["shares"] += v[3] + else : + checkin_times[v[0]]["rejects"] += v[3] + + if v[10] > best_diff: + best_diff = v[10] + + sqldata.append({'time':v[4],'rem_host':v[6],'username':v[0],'our_result':v[5],'upstream_result':0,'reason':v[9],'solution':'', + 'block_num':v[7],'prev_block_hash':v[8],'ua':'','diff':v[3]} ) + else : + sqldata.append({'time':v[4],'rem_host':v[6],'username':v[0],'our_result':v[5],'upstream_result':0,'reason':v[9],'solution':''} ) + + if settings.DATABASE_EXTEND : + self.dbc.executemany("insert into shares " +\ + "(time,rem_host,username,our_result,upstream_result,reason,solution,block_num,prev_block_hash,useragent,difficulty) " +\ + "VALUES (:time,:rem_host,:username,:our_result,:upstream_result,:reason,:solution,:block_num,:prev_block_hash,:ua,:diff)",sqldata) + + + self.dbc.execute("select value from pool where parameter = 'round_shares'") + round_shares = int(self.dbc.fetchone()[0]) + total_shares + self.dbc.execute("update pool set value = :val where parameter = 'round_shares'",{'val':round_shares}) + + self.dbc.execute("select value from pool where parameter = 'round_best_share'") + round_best_share = int(self.dbc.fetchone()[0]) + if best_diff > round_best_share: + self.dbc.execute("update pool set value = :val where parameter = 'round_best_share'",{'val':best_diff}) + + self.dbc.execute("select value from pool where parameter = 'bitcoin_difficulty'") + difficulty = float(self.dbc.fetchone()[0]) + + if difficulty == 0: + progress = 0 + else: + progress = (round_shares/difficulty)*100 + self.dbc.execute("update pool set value = :val where parameter = 'round_progress'",{'val':progress}) + + sqldata = [] + for k,v in checkin_times.items(): + sqldata.append({'last_checkin':v["time"],'addshares':v["shares"],'addrejects':v["rejects"],'user':k}) + self.dbc.executemany("update pool_worker set last_checkin = :last_checkin, total_shares = total_shares + :addshares, " +\ + "total_rejects = total_rejects + :addrejects where username = :user",sqldata) + else: + self.dbc.executemany("insert into shares (time,rem_host,username,our_result,upstream_result,reason,solution) " +\ + "VALUES (:time,:rem_host,:username,:our_result,:upstream_result,:reason,:solution)",sqldata) + + self.dbh.commit() + + def found_block(self,data): + # Note: difficulty = -1 here + self.dbc.execute("update shares set upstream_result = :usr, solution = :sol where time = :time and username = :user", + {'usr':data[5],'sol':data[2],'time':data[4],'user':data[0]}) + if settings.DATABASE_EXTEND and data[5] == True : + self.dbc.execute("update pool_worker set total_found = total_found + 1 where username = :user",{'user':data[0]}) + self.dbc.execute("select value from pool where parameter = 'pool_total_found'") + total_found = int(self.dbc.fetchone()[0]) + 1 + self.dbc.executemany("update pool set value = :val where parameter = :parm", [{'val':0,'parm':'round_shares'}, + {'val':0,'parm':'round_progress'}, + {'val':0,'parm':'round_best_share'}, + {'val':time.time(),'parm':'round_start'}, + {'val':total_found,'parm':'pool_total_found'} + ]) + self.dbh.commit() + + def get_user(self, id_or_username): + raise NotImplementedError('Not implemented for SQLite') + + def list_users(self): + raise NotImplementedError('Not implemented for SQLite') + + def delete_user(self,id_or_username): + raise NotImplementedError('Not implemented for SQLite') + + def insert_user(self,username,password): + log.debug("Adding Username/Password") + self.dbc.execute("insert into pool_worker (username,password) VALUES (:user,:pass)", {'user':username,'pass':password}) + self.dbh.commit() + + def update_user(self,username,password): + raise NotImplementedError('Not implemented for SQLite') + + def check_password(self,username,password): + log.debug("Checking Username/Password") + self.dbc.execute("select COUNT(*) from pool_worker where username = :user and password = :pass", {'user':username,'pass':password}) + data = self.dbc.fetchone() + if data[0] > 0 : + return True + return False + + def update_worker_diff(self,username,diff): + self.dbc.execute("update pool_worker set difficulty = :diff where username = :user",{'diff':diff,'user':username}) + self.dbh.commit() + + def clear_worker_diff(self): + if settings.DATABASE_EXTEND == True : + self.dbc.execute("update pool_worker set difficulty = 0") + self.dbh.commit() + + def update_pool_info(self,pi): + self.dbc.executemany("update pool set value = :val where parameter = :parm",[{'val':pi['blocks'],'parm':"bitcoin_blocks"}, + {'val':pi['balance'],'parm':"bitcoin_balance"}, + {'val':pi['connections'],'parm':"bitcoin_connections"}, + {'val':pi['difficulty'],'parm':"bitcoin_difficulty"}, + {'val':time.time(),'parm':"bitcoin_infotime"} + ]) + self.dbh.commit() + + def get_pool_stats(self): + self.dbc.execute("select * from pool") + ret = {} + for data in self.dbc.fetchall(): + ret[data[0]] = data[1] + return ret + + def get_workers_stats(self): + self.dbc.execute("select username,speed,last_checkin,total_shares,total_rejects,total_found,alive,difficulty from pool_worker") + ret = {} + for data in self.dbc.fetchall(): + ret[data[0]] = { "username" : data[0], + "speed" : data[1], + "last_checkin" : data[2], + "total_shares" : data[3], + "total_rejects" : data[4], + "total_found" : data[5], + "alive" : data[6], + "difficulty" : data[7] } + return ret + + def close(self): + self.dbh.close() + + def check_tables(self): + log.debug("Checking Tables") + if settings.DATABASE_EXTEND == True : + self.dbc.execute("create table if not exists shares" +\ + "(time DATETIME,rem_host TEXT, username TEXT, our_result INTEGER, upstream_result INTEGER, reason TEXT, solution TEXT, " +\ + "block_num INTEGER, prev_block_hash TEXT, useragent TEXT, difficulty INTEGER)") + self.dbc.execute("create table if not exists pool_worker" +\ + "(username TEXT, password TEXT, speed INTEGER, last_checkin DATETIME)") + self.dbc.execute("create table if not exists pool(parameter TEXT, value TEXT)") + + self.dbc.execute("select COUNT(*) from pool where parameter = 'DB Version'") + data = self.dbc.fetchone() + if data[0] <= 0: + self.dbc.execute("alter table pool_worker add total_shares INTEGER default 0") + self.dbc.execute("alter table pool_worker add total_rejects INTEGER default 0") + self.dbc.execute("alter table pool_worker add total_found INTEGER default 0") + self.dbc.execute("insert into pool (parameter,value) VALUES ('DB Version',2)") + self.update_tables() + else : + self.dbc.execute("create table if not exists shares" + \ + "(time DATETIME,rem_host TEXT, username TEXT, our_result INTEGER, upstream_result INTEGER, reason TEXT, solution TEXT)") + self.dbc.execute("create table if not exists pool_worker(username TEXT, password TEXT)") + self.dbc.execute("create index if not exists pool_worker_username ON pool_worker(username)") + + def update_tables(self): + version = 0 + current_version = 6 + while version < current_version : + self.dbc.execute("select value from pool where parameter = 'DB Version'") + data = self.dbc.fetchone() + version = int(data[0]) + if version < current_version : + log.info("Updating Database from %i to %i" % (version, version +1)) + getattr(self, 'update_version_' + str(version) )() + + + def update_version_2(self): + log.info("running update 2") + self.dbc.executemany("insert into pool (parameter,value) VALUES (?,?)",[('bitcoin_blocks',0), + ('bitcoin_balance',0), + ('bitcoin_connections',0), + ('bitcoin_difficulty',0), + ('pool_speed',0), + ('pool_total_found',0), + ('round_shares',0), + ('round_progress',0), + ('round_start',time.time()) + ]) + self.dbc.execute("create index if not exists shares_username ON shares(username)") + self.dbc.execute("create index if not exists pool_worker_username ON pool_worker(username)") + self.dbc.execute("update pool set value = 3 where parameter = 'DB Version'") + self.dbh.commit() + + def update_version_3(self): + log.info("running update 3") + self.dbc.executemany("insert into pool (parameter,value) VALUES (?,?)",[ + ('round_best_share',0), + ('bitcoin_infotime',0), + ]) + self.dbc.execute("alter table pool_worker add alive INTEGER default 0") + self.dbc.execute("update pool set value = 4 where parameter = 'DB Version'") + self.dbh.commit() + + def update_version_4(self): + log.info("running update 4") + self.dbc.execute("alter table pool_worker add difficulty INTEGER default 0") + self.dbc.execute("create table if not exists shares_archive" +\ + "(time DATETIME,rem_host TEXT, username TEXT, our_result INTEGER, upstream_result INTEGER, reason TEXT, solution TEXT, " +\ + "block_num INTEGER, prev_block_hash TEXT, useragent TEXT, difficulty INTEGER)") + self.dbc.execute("create table if not exists shares_archive_found" +\ + "(time DATETIME,rem_host TEXT, username TEXT, our_result INTEGER, upstream_result INTEGER, reason TEXT, solution TEXT, " +\ + "block_num INTEGER, prev_block_hash TEXT, useragent TEXT, difficulty INTEGER)") + self.dbc.execute("update pool set value = 5 where parameter = 'DB Version'") + self.dbh.commit() + + def update_version_5(self): + log.info("running update 5") + # Adding Primary key to table: pool + self.dbc.execute("alter table pool rename to pool_old") + self.dbc.execute("create table if not exists pool(parameter TEXT, value TEXT, primary key(parameter))") + self.dbc.execute("insert into pool select * from pool_old") + self.dbc.execute("drop table pool_old") + self.dbh.commit() + # Adding Primary key to table: pool_worker + self.dbc.execute("alter table pool_worker rename to pool_worker_old") + self.dbc.execute("CREATE TABLE pool_worker(username TEXT, password TEXT, speed INTEGER, last_checkin DATETIME, total_shares INTEGER default 0, total_rejects INTEGER default 0, total_found INTEGER default 0, alive INTEGER default 0, difficulty INTEGER default 0, primary key(username))") + self.dbc.execute("insert into pool_worker select * from pool_worker_old") + self.dbc.execute("drop table pool_worker_old") + self.dbh.commit() + # Adjusting indicies on table: shares + self.dbc.execute("DROP INDEX shares_username") + self.dbc.execute("CREATE INDEX shares_time_username ON shares(time,username)") + self.dbc.execute("CREATE INDEX shares_upstreamresult ON shares(upstream_result)") + self.dbh.commit() + + self.dbc.execute("update pool set value = 6 where parameter = 'DB Version'") + self.dbh.commit() diff --git a/mining/__init__.py b/mining/__init__.py index 0bb7f7e..627e801 100644 --- a/mining/__init__.py +++ b/mining/__init__.py @@ -2,6 +2,8 @@ from subscription import MiningSubscription from twisted.internet import defer import time +import simplejson as json +from twisted.internet import reactor @defer.inlineCallbacks def setup(on_startup): @@ -12,37 +14,50 @@ def setup(on_startup): *before* you call setup() in the launcher script.''' from stratum import settings + + # Get logging online as soon as possible + import stratum.logger + log = stratum.logger.get_logger('mining') + from interfaces import Interfaces - # Let's wait until share manager and worker manager boot up - (yield Interfaces.share_manager.on_load) - (yield Interfaces.worker_manager.on_load) - from lib.block_updater import BlockUpdater from lib.template_registry import TemplateRegistry - from lib.bitcoin_rpc import BitcoinRPC + from lib.bitcoin_rpc_manager import BitcoinRPCManager from lib.block_template import BlockTemplate from lib.coinbaser import SimpleCoinbaser - bitcoin_rpc = BitcoinRPC(settings.BITCOIN_TRUSTED_HOST, - settings.BITCOIN_TRUSTED_PORT, - settings.BITCOIN_TRUSTED_USER, - settings.BITCOIN_TRUSTED_PASSWORD) - - import stratum.logger - log = stratum.logger.get_logger('mining') - - log.info('Waiting for bitcoin RPC...') - + bitcoin_rpc = BitcoinRPCManager() + + # Check bitcoind + # Check we can connect (sleep) + # Check the results: + # - getblocktemplate is avalible (Die if not) + # - we are not still downloading the blockchain (Sleep) + log.info("Connecting to bitcoind...") while True: try: result = (yield bitcoin_rpc.getblocktemplate()) if isinstance(result, dict): - log.info('Response from bitcoin RPC OK') - break - except: - time.sleep(1) - + if result['version'] == 2: + break + except Exception, e: + if isinstance(e[2], str): + if isinstance(json.loads(e[2])['error']['message'], str): + error = json.loads(e[2])['error']['message'] + if error == "Method not found": + log.error("Bitcoind does not support getblocktemplate!!! (time to upgrade.)") + reactor.stop() + elif error == "Bitcoin is downloading blocks...": + log.error("Bitcoind downloading blockchain... will check back in 30 sec") + time.sleep(29) + else: + log.error("Bitcoind Error: %s", error) + time.sleep(1) # If we didn't get a result or the connect failed + + log.info('Connected to bitcoind - Ready to GO!') + + # Start the coinbaser coinbaser = SimpleCoinbaser(bitcoin_rpc, settings.CENTRAL_WALLET) (yield coinbaser.on_load) @@ -52,7 +67,7 @@ def setup(on_startup): settings.INSTANCE_ID, MiningSubscription.on_template, Interfaces.share_manager.on_network_block) - + # Template registry is the main interface between Stratum service # and pool core logic Interfaces.set_template_registry(registry) @@ -61,6 +76,11 @@ def setup(on_startup): # This is just failsafe solution when -blocknotify # mechanism is not working properly BlockUpdater(registry, bitcoin_rpc) - + log.info("MINING SERVICE IS READY") - on_startup.callback(True) + on_startup.callback(True) + + + + + diff --git a/mining/basic_share_limiter.py b/mining/basic_share_limiter.py new file mode 100644 index 0000000..7e2de1c --- /dev/null +++ b/mining/basic_share_limiter.py @@ -0,0 +1,127 @@ +from stratum import settings + +import stratum.logger +log = stratum.logger.get_logger('BasicShareLimiter') + +import DBInterface +dbi = DBInterface.DBInterface() +dbi.clear_worker_diff() + +''' This is just a customized ring buffer ''' +class SpeedBuffer: + def __init__(self, size_max): + self.max = size_max + self.data = [] + self.cur = 0 + + def append(self, x): + self.data.append(x) + self.cur += 1 + if len(self.data) == self.max: + self.cur = 0 + self.__class__ = SpeedBufferFull + + def avg(self): + return sum(self.data) / self.cur + + def pos(self): + return self.cur + + def clear(self): + self.data = [] + self.cur = 0 + + def size(self): + return self.cur + +class SpeedBufferFull: + def __init__(self, n): + raise "you should use SpeedBuffer" + + def append(self, x): + self.data[self.cur] = x + self.cur = (self.cur + 1) % self.max + + def avg(self): + return sum(self.data) / self.max + + def pos(self): + return self.cur + + def clear(self): + self.data = [] + self.cur = 0 + self.__class__ = SpeedBuffer + + def size(self): + return self.max + +class BasicShareLimiter(object): + def __init__(self): + self.worker_stats = {} + self.target = settings.VDIFF_TARGET + self.retarget = settings.VDIFF_RETARGET + self.variance = self.target * (float(settings.VDIFF_VARIANCE_PERCENT) / float(100)) + self.tmin = self.target - self.variance + self.tmax = self.target + self.variance + self.buffersize = self.retarget / self.target * 4 + # TODO: trim the hash of inactive workers + + def submit(self, connection_ref, job_id, current_difficulty, timestamp, worker_name): + ts = int(timestamp) + + # Init the stats for this worker if it isn't set. + if worker_name not in self.worker_stats : + self.worker_stats[worker_name] = {'last_rtc': (ts - self.retarget / 2), 'last_ts': ts, 'buffer': SpeedBuffer(self.buffersize) } + dbi.update_worker_diff(worker_name, settings.POOL_TARGET) + return + + # Standard share update of data + self.worker_stats[worker_name]['buffer'].append(ts - self.worker_stats[worker_name]['last_ts']) + self.worker_stats[worker_name]['last_ts'] = ts + + # Do We retarget? If not, we're done. + if ts - self.worker_stats[worker_name]['last_rtc'] < self.retarget and self.worker_stats[worker_name]['buffer'].size() > 0: + return + + # Set up and log our check + self.worker_stats[worker_name]['last_rtc'] = ts + avg = self.worker_stats[worker_name]['buffer'].avg() + log.info("Checking Retarget for %s (%i) avg. %i target %i+-%i" % (worker_name, current_difficulty, avg, + self.target, self.variance)) + + if avg < 1: + log.info("Reseting avg = 1 since it's SOOO low") + avg = 1 + + # Figure out our Delta-Diff + ddiff = int((float(current_difficulty) * (float(self.target) / float(avg))) - current_difficulty) + if (avg > self.tmax and current_difficulty > settings.POOL_TARGET): + # For fractional -0.1 ddiff's just drop by 1 + if ddiff > -1: + ddiff = -1 + # Don't drop below POOL_TARGET + if (ddiff + current_difficulty) < settings.POOL_TARGET: + ddiff = settings.POOL_TARGET - current_difficulty + elif avg < self.tmin: + # For fractional 0.1 ddiff's just up by 1 + if ddiff < 1: + ddiff = 1 + # Don't go above BITCOIN_DIFF + # TODO + else: # If we are here, then we should not be retargeting. + return + + # At this point we are retargeting this worker + new_diff = current_difficulty + ddiff + log.info("Retarget for %s %i old: %i new: %i" % (worker_name, ddiff, current_difficulty, new_diff)) + + self.worker_stats[worker_name]['buffer'].clear() + session = connection_ref().get_session() + + session['prev_diff'] = session['difficulty'] + session['prev_jobid'] = job_id + session['difficulty'] = new_diff + connection_ref().rpc('mining.set_difficulty', [new_diff, ], is_notification=True) + dbi.update_worker_diff(worker_name, new_diff) + diff --git a/mining/interfaces.py b/mining/interfaces.py index 1fef21a..5496485 100644 --- a/mining/interfaces.py +++ b/mining/interfaces.py @@ -3,49 +3,68 @@ and customize references to interface instances in your launcher. (see launcher_demo.tac for an example). ''' - import time from twisted.internet import reactor, defer +from lib.util import b58encode import stratum.logger log = stratum.logger.get_logger('interfaces') +import lib.notify_email + +import DBInterface +dbi = DBInterface.DBInterface() +dbi.init_main() + class WorkerManagerInterface(object): def __init__(self): - # Fire deferred when manager is ready - self.on_load = defer.Deferred() - self.on_load.callback(True) + return def authorize(self, worker_name, worker_password): - return True + # Important NOTE: This is called on EVERY submitted share. So you'll need caching!!! + return dbi.check_password(worker_name, worker_password) + class ShareLimiterInterface(object): '''Implement difficulty adjustments here''' - def submit(self, connection_ref, current_difficulty, timestamp): + def submit(self, connection_ref, job_id, current_difficulty, timestamp, worker_name): '''connection - weak reference to Protocol instance current_difficulty - difficulty of the connection timestamp - submission time of current share - raise SubmitException for stop processing this request - call mining.set_difficulty on connection to adjust the difficulty''' - pass - + return dbi.update_worker_diff(worker_name, settings.POOL_TARGET) + class ShareManagerInterface(object): def __init__(self): - # Fire deferred when manager is ready - self.on_load = defer.Deferred() - self.on_load.callback(True) + self.block_height = 0 + self.prev_hash = 0 + + # Send out the e-mail saying we are starting. + notify_email = lib.notify_email.NOTIFY_EMAIL() + notify_email.notify_start() - def on_network_block(self, prevhash): + def on_network_block(self, prevhash, block_height): '''Prints when there's new block coming from the network (possibly new round)''' + self.block_height = block_height + self.prev_hash = b58encode(int(prevhash, 16)) pass - def on_submit_share(self, worker_name, block_header, block_hash, shares, timestamp, is_valid): - log.info("%s %s %s" % (block_hash, 'valid' if is_valid else 'INVALID', worker_name)) - - def on_submit_block(self, is_accepted, worker_name, block_header, block_hash, timestamp): + def on_submit_share(self, worker_name, block_header, block_hash, difficulty, timestamp, is_valid, ip, invalid_reason, share_diff): + log.info("%s (%s) %s %s" % (block_hash, share_diff, 'valid' if is_valid else 'INVALID', worker_name)) + dbi.queue_share([worker_name, block_header, block_hash, difficulty, timestamp, is_valid, ip, self.block_height, self.prev_hash, + invalid_reason, share_diff ]) + + def on_submit_block(self, is_accepted, worker_name, block_header, block_hash, timestamp, ip, share_diff): log.info("Block %s %s" % (block_hash, 'ACCEPTED' if is_accepted else 'REJECTED')) + dbi.found_block([worker_name, block_header, block_hash, -1, timestamp, is_accepted, ip, self.block_height, self.prev_hash, share_diff ]) + + # Send out the e-mail saying we found a block. + if is_accepted: + notify_email = lib.notify_email.NOTIFY_EMAIL() + notify_email.notify_found_block(worker_name) class TimestamperInterface(object): '''This is the only source for current time in the application. @@ -55,13 +74,13 @@ def time(self): class PredictableTimestamperInterface(TimestamperInterface): '''Predictable timestamper may be useful for unit testing.''' - start_time = 1345678900 # Some day in year 2012 + start_time = 1345678900 # Some day in year 2012 delta = 0 def time(self): self.delta += 1 return self.start_time + self.delta - + class Interfaces(object): worker_manager = None share_manager = None @@ -87,4 +106,5 @@ def set_timestamper(cls, manager): @classmethod def set_template_registry(cls, registry): - cls.template_registry = registry \ No newline at end of file + dbi.set_bitcoinrpc(registry.bitcoin_rpc) + cls.template_registry = registry diff --git a/mining/service.py b/mining/service.py index ff86d96..1285427 100644 --- a/mining/service.py +++ b/mining/service.py @@ -1,6 +1,7 @@ import binascii from twisted.internet import defer +from stratum import settings from stratum.services import GenericService, admin from stratum.pubsub import Pubsub from interfaces import Interfaces @@ -40,7 +41,6 @@ def authorize(self, worker_name, worker_password): if Interfaces.worker_manager.authorize(worker_name, worker_password): session['authorized'][worker_name] = worker_password return True - else: if worker_name in session['authorized']: del session['authorized'][worker_name] @@ -56,24 +56,9 @@ def subscribe(self): session = self.connection_ref().get_session() session['extranonce1'] = extranonce1 - session['difficulty'] = 1 # Following protocol specs, default diff is 1 + session['difficulty'] = settings.POOL_TARGET # Following protocol specs, default diff is 1 return Pubsub.subscribe(self.connection_ref(), MiningSubscription()) + (extranonce1_hex, extranonce2_size) - - ''' - def submit(self, worker_name, job_id, extranonce2, ntime, nonce): - import time - start = time.time() - - for x in range(100): - try: - ret = self.submit2(worker_name, job_id, extranonce2, ntime, nonce) - except: - pass - - log.info("LEN %.03f" % (time.time() - start)) - return ret - ''' def submit(self, worker_name, job_id, extranonce2, ntime, nonce): '''Try to solve block candidate using given parameters.''' @@ -82,50 +67,51 @@ def submit(self, worker_name, job_id, extranonce2, ntime, nonce): session.setdefault('authorized', {}) # Check if worker is authorized to submit shares - if not Interfaces.worker_manager.authorize(worker_name, - session['authorized'].get(worker_name)): + if not Interfaces.worker_manager.authorize(worker_name, session['authorized'].get(worker_name)): raise SubmitException("Worker is not authorized") # Check if extranonce1 is in connection session extranonce1_bin = session.get('extranonce1', None) + if not extranonce1_bin: raise SubmitException("Connection is not subscribed for mining") difficulty = session['difficulty'] submit_time = Interfaces.timestamper.time() + ip = self.connection_ref()._get_ip() - Interfaces.share_limiter.submit(self.connection_ref, difficulty, submit_time) + Interfaces.share_limiter.submit(self.connection_ref, job_id, difficulty, submit_time, worker_name) # This checks if submitted share meet all requirements # and it is valid proof of work. try: - (block_header, block_hash, on_submit) = Interfaces.template_registry.submit_share(job_id, - worker_name, extranonce1_bin, extranonce2, ntime, nonce, difficulty) - except SubmitException: + (block_header, block_hash, share_diff, on_submit) = Interfaces.template_registry.submit_share(job_id, + worker_name, session, extranonce1_bin, extranonce2, ntime, nonce, difficulty) + except SubmitException as e: # block_header and block_hash are None when submitted data are corrupted Interfaces.share_manager.on_submit_share(worker_name, None, None, difficulty, - submit_time, False) + submit_time, False, ip, e[0], 0) raise - Interfaces.share_manager.on_submit_share(worker_name, block_header, block_hash, difficulty, - submit_time, True) + Interfaces.share_manager.on_submit_share(worker_name, block_header, + block_hash, difficulty, submit_time, True, ip, '', share_diff) if on_submit != None: # Pool performs submitblock() to bitcoind. Let's hook # to result and report it to share manager on_submit.addCallback(Interfaces.share_manager.on_submit_block, - worker_name, block_header, block_hash, submit_time) + worker_name, block_header, block_hash, submit_time, ip, share_diff) return True # Service documentation for remote discovery update_block.help_text = "Notify Stratum server about new block on the network." - update_block.params = [('password', 'string', 'Administrator password'),] + update_block.params = [('password', 'string', 'Administrator password'), ] authorize.help_text = "Authorize worker for submitting shares on this connection." authorize.params = [('worker_name', 'string', 'Name of the worker, usually in the form of user_login.worker_id.'), - ('worker_password', 'string', 'Worker password'),] + ('worker_password', 'string', 'Worker password'), ] subscribe.help_text = "Subscribes current connection for receiving new mining jobs." subscribe.params = [] @@ -137,5 +123,5 @@ def submit(self, worker_name, job_id, extranonce2, ntime, nonce): ('job_id', 'string', 'ID of job (received by mining.notify) which the current solution is based on.'), ('extranonce2', 'string', 'hex-encoded big-endian extranonce2, length depends on extranonce2_size from mining.notify.'), ('ntime', 'string', 'UNIX timestamp (32bit integer, big-endian, hex-encoded), must be >= ntime provided by mining,notify and <= current time'), - ('nonce', 'string', '32bit integer, hex-encoded, big-endian'),] - \ No newline at end of file + ('nonce', 'string', '32bit integer, hex-encoded, big-endian'), ] + diff --git a/mining/subscription.py b/mining/subscription.py index d04c62e..81df21d 100644 --- a/mining/subscription.py +++ b/mining/subscription.py @@ -1,6 +1,7 @@ from stratum.pubsub import Pubsub, Subscription from mining.interfaces import Interfaces +from stratum import settings import stratum.logger log = stratum.logger.get_logger('subscription') @@ -16,10 +17,10 @@ def on_template(cls, is_new_block): new block which we have to broadcast clients.''' start = Interfaces.timestamper.time() - clean_jobs = is_new_block + (job_id, prevhash, coinb1, coinb2, merkle_branch, version, nbits, ntime, _) = \ - Interfaces.template_registry.get_last_broadcast_args() + Interfaces.template_registry.get_last_broadcast_args() # Push new job to subscribed clients cls.emit(job_id, prevhash, coinb1, coinb2, merkle_branch, version, nbits, ntime, clean_jobs) @@ -37,9 +38,8 @@ def _finish_after_subscribe(self, result): return result # Force set higher difficulty - # TODO - #self.connection_ref().rpc('mining.set_difficulty', [2,], is_notification=True) - #self.connection_ref().rpc('client.get_version', []) + self.connection_ref().rpc('mining.set_difficulty', [settings.POOL_TARGET, ], is_notification=True) + # self.connection_ref().rpc('client.get_version', []) # Force client to remove previous jobs if any (eg. from previous connection) clean_jobs = True @@ -51,4 +51,5 @@ def after_subscribe(self, *args): '''This will send new job to the client *after* he receive subscription details. on_finish callback solve the issue that job is broadcasted *during* the subscription request and client receive messages in wrong order.''' - self.connection_ref().on_finish.addCallback(self._finish_after_subscribe) \ No newline at end of file + self.connection_ref().on_finish.addCallback(self._finish_after_subscribe) + diff --git a/scripts/generateAdminHash.sh b/scripts/generateAdminHash.sh new file mode 100755 index 0000000..9fe6666 --- /dev/null +++ b/scripts/generateAdminHash.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +if [ "x$1" == "x" ]; then + echo " Usage: $0 " + exit + fi + +echo -n "$1" | sha256sum | cut -f1 -d' ' diff --git a/statics/basic_stats.css b/statics/basic_stats.css new file mode 100644 index 0000000..9ce8cd7 --- /dev/null +++ b/statics/basic_stats.css @@ -0,0 +1,4 @@ +H1 { + font-size: 18pt; + color: #999; + } diff --git a/statics/bitcoin.ico b/statics/bitcoin.ico new file mode 100644 index 0000000..8b12f82 Binary files /dev/null and b/statics/bitcoin.ico differ diff --git a/statics/favicon.ico b/statics/favicon.ico new file mode 100644 index 0000000..14f09cd Binary files /dev/null and b/statics/favicon.ico differ diff --git a/update_submodules b/update_submodules new file mode 100755 index 0000000..a57cd81 --- /dev/null +++ b/update_submodules @@ -0,0 +1,3 @@ +#!/bin/sh + +git submodule foreach git pull origin master