diff --git a/.gitignore b/.gitignore index 948f564..67bc2fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ *.[oa] *.db_info +*.pyc +__pycache__ src/verilog/odo_gen src/pool/fakepool src/projects/*/build_files/* diff --git a/README.md b/README.md index e4bead4..f2daa0d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,14 @@ your installation. For example ``export QUARTUSPATH="/home/miner/altera/18.1/qu This line should be added to your ``~/.profile`` file or another file that is sourced whenever a shell is launched. Don't forget to source the file after editing it. +Solo Mining +----------- + +Install and start a full node via . + +* A python interpreter is required and pip is recommended - ``apt install python python-pip`` (Python 3 should also work, but most testing has been done in Python 2). +* Python modules base58 and requests - ``pip install base58 requests`` + Additional Files ---------------- @@ -44,7 +52,8 @@ Starting to Mine This will require multiple terminal windows. A screen multiplexer such as [tmux](https://github.com/tmux/tmux/wiki) or [screen](https://www.gnu.org/software/screen/) may make things easier for you. +* Ensure your DigiByte node is running. It is recommended that you do not specify an rpcpassword in digibyte.conf. The rpcuser and rpcpassword options will soon be deprecated. * In one terminal, go to the ``src`` directory and run ``./autocompile.sh --testnet cyclone_v_gx_starter_kit de10_nano`` -* In another terminal, go to the ``src/pool`` directory and run ``make fakepool`` followed by ``./fakepool --testnet`` -* Finally, for each mining fpga open a terminal in the ``src/miner`` directory and run ``$QUARTUSPATH/quartus_stp -t mine.tcl`` +* In another terminal, go to the ``src/pool/solo`` directory and run ``python pool.py --testnet `` +* Finally, for each mining fpga open a terminal in the ``src/miner`` directory and run ``$QUARTUSPATH/quartus_stp -t mine.tcl [hardware_name]``. The ``hardware_name`` argument is optional, and if not specified the script will prompt you to select one of the detected mining devices. diff --git a/src/pool/solo/config.py b/src/pool/solo/config.py new file mode 100644 index 0000000..1658ad2 --- /dev/null +++ b/src/pool/solo/config.py @@ -0,0 +1,118 @@ +# Copyright (C) 2019 MentalCollatz +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import argparse +from base64 import b64encode +import os +import platform +import sys + +from template import Script + +DEFAULT_LISTEN_PORT = 17064 + +MAINNET_RPC_PORT = 14022 +TESTNET_RPC_PORT = 18332 + +MAINNET_ADDR_FORMAT = {"bech32_hrp": "dgb", "prefix_pubkey": 30, "prefix_script": 63 } +TESTNET_ADDR_FORMAT = {"bech32_hrp": "dgbt", "prefix_pubkey": 126, "prefix_script": 140 } + +def data_dir(): + if platform.system() == "Windows": + return os.path.join(os.environ["APPDATA"], "DigiByte") + elif platform.system() == "Darwin": + return os.path.expanduser("~/Library/Application Support/DigiByte/") + else: + return os.path.expanduser("~/.digibyte/") + +# base64 encode that works the same in both Python 2 and 3 +def b64encode_helper(s): + res = b64encode(s.encode()) + if type(res) == bytes: + res = res.decode() + return res + +params = {} + +def init(argv): + parser = argparse.ArgumentParser(description="Solo-mining pool.") + parser.add_argument("-t", "--testnet", help="use testnet params", action="store_true") + parser.add_argument("-H", "--host", help="rpc host", dest="rpc_host", default="localhost") + parser.add_argument("-p", "--port", help="rpc port", dest="rpc_port", type=int) + parser.add_argument("--user", help="rpc user (discouraged, --auth is preferred)") + parser.add_argument("--password", help="rpc password (discouraged, --auth is preferred)") + parser.add_argument("-a", "--auth", help="rpc authorization file", type=argparse.FileType("r")) + parser.add_argument("-l", "--listen", help="port to listen for miners on", dest="listen_port", default=DEFAULT_LISTEN_PORT, type=int) + parser.add_argument("-r", "--remote", help="allow remote miners to connect", action="store_true") + parser.add_argument("--coinbase", help="coinbase string", type=str, default="/odo-miner-solo/") + parser.add_argument("address", help="address to mine to", type=str) + args = parser.parse_args(argv[1:]) + + global params + params = {key: getattr(args, key) for key in ["rpc_host", "listen_port", "testnet"]} + + addr_format = TESTNET_ADDR_FORMAT if args.testnet else MAINNET_ADDR_FORMAT + cbscript = Script.from_address(args.address, **addr_format) + if cbscript is None: + other_addr_format = MAINNET_ADDR_FORMAT if args.testnet else TESTNET_ADDR_FORMAT + cbscript = Script.from_address(args.address, **other_addr_format) + if cbscript is not None: + if args.testnet: + parser.error("mainnet address specified with --testnet") + else: + parser.error("testnet address specified without --testnet") + else: + parser.error("invalid address") + params["cbscript"] = cbscript.data + params["cbstring"] = args.coinbase + + if args.user and args.password: + if args.auth: + parser.error("argument --auth is not allowed with arguments --user and --password") + if ':' in args.user: + parser.error("user may not contain `:`") + rpc_auth = args.user + ':' + args.password + elif args.user or args.password: + parser.error("--user and --password must both be present or neither present") + elif args.auth: + rpc_auth = args.auth.read() + else: + cookie = data_dir() + if args.testnet: + cookie = os.path.join(cookie, "testnet3") + cookie = os.path.join(cookie, ".cookie") + try: + with open(cookie, "r") as f: + # Note: if the user restarts the server, they will need to + # restart the pool also + rpc_auth = f.read() + except IOError as e: + parser.error("Unable to read default auth file `%s`, please specify auth file or user and password.") + params["rpc_auth"] = "Basic " + b64encode_helper(rpc_auth) + + if args.rpc_port: + rpc_port = args.rpc_port + else: + rpc_port = TESTNET_RPC_PORT if args.testnet else MAINNET_RPC_PORT + params["rpc_port"] = rpc_port + params["rpc_url"] = "http://%s:%d" % (params["rpc_host"], params["rpc_port"]) + + params["bind_addr"] = "" if args.remote else "localhost" + +def get(key): + global params + if not params: + init(sys.argv) + return params[key] diff --git a/src/pool/solo/pool.py b/src/pool/solo/pool.py new file mode 100644 index 0000000..a7590b6 --- /dev/null +++ b/src/pool/solo/pool.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python + +# Copyright (C) 2019 MentalCollatz +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import socket +import threading +import time + +import config +import rpc +from template import BlockTemplate + +def get_templates(callback): + longpollid = None + last_errno = None + while True: + try: + template = rpc.get_block_template(longpollid) + if "coinbaseaux" not in template: + template["coinbaseaux"] = {} + template["coinbaseaux"]["cbstring"] = config.get("cbstring") + callback(template) + longpollid = template["longpollid"] + if last_errno != 0: + print("%s: successfully acquired template" % time.asctime()) + last_errno = 0 + except (rpc.RpcError, socket.error) as e: + if last_errno == 0: + callback(None) + if e.errno != last_errno: + last_errno = e.errno + print("%s: %s (errno %d)" % (time.asctime(), e.strerror, e.errno)) + time.sleep(1) + +class Manager(threading.Thread): + def __init__(self, cbscript): + threading.Thread.__init__(self) + self.cbscript = cbscript + self.template = None + self.extra_nonce = 0 + self.miners = [] + self.cond = threading.Condition() + + def add_miner(self, miner): + with self.cond: + self.miners.append(miner) + self.cond.notify() + + def remove_miner(self, miner): + with self.cond: + self.miners.remove(miner) + + def push_template(self, template): + with self.cond: + if template is None: + self.template = None + else: + self.template = BlockTemplate(template, self.cbscript) + self.extra_nonce = 0 + for miner in self.miners: + miner.next_refresh = 0 + self.cond.notify() + + def run(self): + while True: + with self.cond: + now = time.time() + next_refresh = now + 1000 + for miner in self.miners: + if miner.next_refresh < now: + miner.push_work(self.template, self.extra_nonce) + self.extra_nonce += 1 + next_refresh = min(next_refresh, miner.next_refresh) + wait_time = max(0, next_refresh - time.time()) + self.cond.wait(wait_time) + +class Miner(threading.Thread): + def __init__(self, conn, manager): + threading.Thread.__init__(self) + self.conn = conn + self.manager = manager + self.lock = threading.Lock() + self.conn_lock = threading.Lock() + self.work_items = [] + self.next_refresh = 0 + self.refresh_interval = 10 + manager.add_miner(self) + self.start() + + def push_work(self, template, extra_nonce): + if template is None: + workstr = "work %s %s %d" % ("0"*64, "0"*64, 0) + else: + work = template.get_work(extra_nonce) + workstr = "work %s %s %d" % (work, template.target, template.odo_key) + with self.lock: + if template is None: + self.work_items = [] + else: + self.work_items.insert(0, (work, template, extra_nonce)) + if len(self.work_items) > 2: + self.work_items.pop() + self.next_refresh = time.time() + self.refresh_interval + try: + self.send(workstr) + except socket.error as e: + # let the other thread clean it up + pass + + def send(self, s): + with self.conn_lock: + self.conn.sendall((s + "\n").encode()) + + def submit(self, work): + with self.lock: + for work_item in self.work_items: + if work_item[0][0:152] == work[0:152]: + template = work_item[1] + extra_nonce = work_item[2] + submit_data = work + template.get_data(extra_nonce) + break + else: + return "stale" + try: + return rpc.submit_work(submit_data) + except (rpc.RpcError, socket.error) as e: + print("failed to submit: %s (errno %d)" % (e.strerror, e.errno)); + return "error" + + def run(self): + while True: + try: + data = self.conn.makefile().readline().rstrip() + if not data: + break + parts = data.split() + command, args = parts[0], parts[1:] + if command == "submit" and len(args) == 1: + result = self.submit(*args) + self.send("result %s" % result) + else: + print("unknown command: %s" % data) + except socket.error as e: + break + self.manager.remove_miner(self) + self.conn.close() + +if __name__ == "__main__": + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind((config.get("bind_addr"), config.get("listen_port"))) + listener.listen(10) + + manager = Manager(config.get("cbscript")) + manager.start() + + callback = lambda t: manager.push_template(t) + threading.Thread(target=get_templates, args=(callback,)).start() + + while True: + conn, addr = listener.accept() + Miner(conn, manager) + diff --git a/src/pool/solo/rpc.py b/src/pool/solo/rpc.py new file mode 100644 index 0000000..f9192db --- /dev/null +++ b/src/pool/solo/rpc.py @@ -0,0 +1,51 @@ +# Copyright (C) 2019 MentalCollatz +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import requests +import json + +import config + +class RpcError(Exception): + def __init__(self, **kwargs): + self.strerror = kwargs["message"] + self.errno = kwargs["code"] + +def json_request(method, *params): + jdata = {"method": method, "params": params} + headers = {"Content-Type": "application/json", "Authorization": config.get("rpc_auth")} + response = requests.post(config.get("rpc_url"), headers=headers, json=jdata) + + try: + data = response.json() + except ValueError as e: + if response.status_code != requests.codes.ok: + raise RpcError(code=response.status_code, message="HTTP status code") + raise RpcError(code=500, message=str(e)) + + if data["error"] is None: + return data["result"] + raise RpcError(**data["error"]) + +def get_block_template(longpollid): + params = {"rules":["segwit"]} + algo = "odo" + if longpollid is not None: + params["longpollid"] = longpollid + return json_request("getblocktemplate", params, algo) + +def submit_work(submit_data): + return json_request("submitblock", submit_data) or "accepted" + diff --git a/src/pool/solo/segwit_addr.py b/src/pool/solo/segwit_addr.py new file mode 100644 index 0000000..d450080 --- /dev/null +++ b/src/pool/solo/segwit_addr.py @@ -0,0 +1,123 @@ +# Copyright (c) 2017 Pieter Wuille +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +"""Reference implementation for Bech32 and segwit addresses.""" + + +CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def bech32_polymod(values): + """Internal function that computes the Bech32 checksum.""" + generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] + chk = 1 + for value in values: + top = chk >> 25 + chk = (chk & 0x1ffffff) << 5 ^ value + for i in range(5): + chk ^= generator[i] if ((top >> i) & 1) else 0 + return chk + + +def bech32_hrp_expand(hrp): + """Expand the HRP into values for checksum computation.""" + return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] + + +def bech32_verify_checksum(hrp, data): + """Verify a checksum given HRP and converted data characters.""" + return bech32_polymod(bech32_hrp_expand(hrp) + data) == 1 + + +def bech32_create_checksum(hrp, data): + """Compute the checksum values given HRP and data.""" + values = bech32_hrp_expand(hrp) + data + polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1 + return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] + + +def bech32_encode(hrp, data): + """Compute a Bech32 string given HRP and data values.""" + combined = data + bech32_create_checksum(hrp, data) + return hrp + '1' + ''.join([CHARSET[d] for d in combined]) + + +def bech32_decode(bech): + """Validate a Bech32 string, and determine HRP and data.""" + if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or + (bech.lower() != bech and bech.upper() != bech)): + return (None, None) + bech = bech.lower() + pos = bech.rfind('1') + if pos < 1 or pos + 7 > len(bech) or len(bech) > 90: + return (None, None) + if not all(x in CHARSET for x in bech[pos+1:]): + return (None, None) + hrp = bech[:pos] + data = [CHARSET.find(x) for x in bech[pos+1:]] + if not bech32_verify_checksum(hrp, data): + return (None, None) + return (hrp, data[:-6]) + + +def convertbits(data, frombits, tobits, pad=True): + """General power-of-2 base conversion.""" + acc = 0 + bits = 0 + ret = [] + maxv = (1 << tobits) - 1 + max_acc = (1 << (frombits + tobits - 1)) - 1 + for value in data: + if value < 0 or (value >> frombits): + return None + acc = ((acc << frombits) | value) & max_acc + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if pad: + if bits: + ret.append((acc << (tobits - bits)) & maxv) + elif bits >= frombits or ((acc << (tobits - bits)) & maxv): + return None + return ret + + +def decode(hrp, addr): + """Decode a segwit address.""" + hrpgot, data = bech32_decode(addr) + if hrpgot != hrp: + return (None, None) + decoded = convertbits(data[1:], 5, 8, False) + if decoded is None or len(decoded) < 2 or len(decoded) > 40: + return (None, None) + if data[0] > 16: + return (None, None) + if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: + return (None, None) + return (data[0], decoded) + + +def encode(hrp, witver, witprog): + """Encode a segwit address.""" + ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5)) + if decode(hrp, ret) == (None, None): + return None + return ret diff --git a/src/pool/solo/template.py b/src/pool/solo/template.py new file mode 100644 index 0000000..6514331 --- /dev/null +++ b/src/pool/solo/template.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python + +# Copyright (C) 2019 MentalCollatz +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from base58 import b58decode_check +from binascii import hexlify, unhexlify +from hashlib import sha256 +from segwit_addr import decode as segwit_decode +from struct import pack + +def sha256d(data): + return sha256(sha256(data).digest()).digest() + +def byte_ord(b): + if type(b) == int: + return b + return ord(b) + +def as_str(b): + if type(b) == bytes: + return b.decode() + return b + +# Serialize an integer using VarInt encoding +def serialize_int(n): + result = b'' + if n == 0: + return result + + while n != 0: + result += pack('>= 8 + + if byte_ord(result[-1]) & 0x80: + result += b'\0' + return result + +# Serialize the length of an object +def compact_size(n): + if hasattr(n, '__len__'): + n = len(n) + if n < 253: + return pack('= 0, "Negative integers not supported" + if use_opcodes: + if n == 0: + return self.push_byte(self.OP_0) + elif n <= 16: + return self.push_byte(n + self.OP_1 - 1) + return self.push_str(serialize_int(n)) + + def push_bytes(self, s): + assert len(s) < self.OP_PUSHDATA1, "Long strings not supported" + self.push_byte(len(s)) + for b in s: + self.push_byte(b) + return self + + def push_str(self, s): + assert len(s) < self.OP_PUSHDATA1, "Long strings not supported" + self.push_byte(len(s)) + self.data += s + return self + + @classmethod + def from_address(self, addr, bech32_hrp, prefix_pubkey, prefix_script): + # bech32 address + witver, witprog = segwit_decode(bech32_hrp, addr) + if witver is not None: + return Script().push_int(witver).push_bytes(witprog) + + # legacy address + try: + addrbin = b58decode_check(addr) + except ValueError as e: + return None + addr_prefix = byte_ord(addrbin[0]) + addrbin = addrbin[1:] + if len(addrbin) != 20: + return None + + # pubkey hash + if addr_prefix == prefix_pubkey: + return Script()\ + .push_byte(self.OP_DUP)\ + .push_byte(self.OP_HASH160)\ + .push_str(addrbin)\ + .push_byte(self.OP_EQUALVERIFY)\ + .push_byte(self.OP_CHECKSIG) + + # script hash + if addr_prefix == prefix_script: + return Script()\ + .push_byte(self.OP_HASH160)\ + .push_str(addrbin)\ + .push_byte(self.OP_EQUAL) + + return None + +class Coinbase: + def __init__(self, cbscript, template): + self.height = template["height"] + self.txout = [(template["coinbasevalue"], cbscript)] + self.needs_witness = any(tx["txid"] != tx["hash"] for tx in template["transactions"]) + if self.needs_witness: + self.txout.append((0, unhexlify(template["default_witness_commitment"]))) + self.coinbaseaux = template.get("coinbaseaux", {}) + + def _data(self, extra_nonce, extended): + if not self.needs_witness: + extended = False + + script_sig = Script()\ + .push_int(self.height)\ + .push_int(extra_nonce, False) + for aux in self.coinbaseaux.values(): + if aux: + script_sig.push_str(aux.encode()) + assert len(script_sig.data) <= 100, "script-sig too long" + + data = b'\x01\0\0\0' # transaction version + if extended: + data += b'\0' # extended + data += b'\x01' # flags + data += b'\x01' # txin count + data += b'\0' * 32 # prevout hash + data += b'\xff' * 4 # prevout n + data += compact_size(script_sig.data) + script_sig.data + data += b'\xff' * 4 # sequence + data += compact_size(self.txout) + for value, script in self.txout: + data += pack('