FPGA应用篇【2】比特币SHA256算法实现——挖矿自动化

上一篇中,我们通过FPGA实现了一个通过串口传输数据的半自动挖矿模块,哈希碰撞的速度大约为25MH/s。不论能不能挖到矿,我们距离一个真正的矿机只剩和矿池服务器沟通获取数据头与难度的PC端程序。

我们的设计中,FPGA负责进行计算量最高的哈希碰撞部分,而PC端负责与矿池服务器沟通,计划使用Python 3.8实现。(没错我不是只会做Verilog部分内容)

FPGA应用篇【2】比特币SHA256算法实现——挖矿自动化

  • 串口通信自动化
  • 矿池服务器使用
    • Stratum协议
    • 矿池通信代码
      • 挖矿初始化
  • 最终代码
  • 实际测试
  • 写在最后

串口通信自动化

在上一篇中,我们将608位数据头输入FPGA进行哈希碰撞的方式是使用Putty手动输入,很明显这种做法是不适合自动化的。因此首要任务是找到一种自动传输串口数据的方式

这里我们引入serial库文件,写一个fpga_mine函数,输入为prefix数据头(长度为152的string)和difficulty难度系数(integer),设置波特率、端口名后写入数据,等待结果,验证结果后返回32位变量

import serial
import hashlib
import binascii

hexlify = binascii.hexlify
unhexlify = binascii.unhexlify

def sha256d(message):
    return hashlib.sha256(hashlib.sha256(message).digest()).hexdigest()

def fpga_mine(prefix, difficulty):
    ser = serial.Serial()
    ser.baudrate = 115200
    ser.port = 'COM4'
    ser.open()
    if ser.is_open:
        ser.write((prefix + '%02x'%difficulty + '\r').encode('utf-8'))
        print('Calculating hash with prefix ' + prefix)
        print('and difficulty ' + str(difficulty))
        time.sleep(0.5)
        t0 = time.time()
        while ser.in_waiting < 156:
            time.sleep(0.25)
        ser.read(155)
        result = ser.read(8)
        ser.reset_input_buffer()
        hash_check = sha256d(unhexlify((prefix + result.decode('utf-8')).encode('utf-8')))
        print('checked hash is ' + hash_check)
        dt = time.time() - t0;
		print('used time ' + str(dt) + ' s')
        difficulty_tmp = math.floor(difficulty / 4)
        if ('0' * difficulty_tmp == hash_check[:difficulty_tmp]):
            return result
    ser.close()

if __name__ == '__main__':
    prefix = '00000020e30267c75d2d24b7dfd8b6459fd8374479d3eb278c6a0b000000000000000000f65c34242c47500767b534c949c0a38af49907b94a6e136321407cbc19d27e6287072e60b9210d17'
    difficulty = 32
    print(fpga_mine(prefix, difficulty))

定义的sha256d函数通过digest,将第一重sha256哈希的结果传给第二重sha256,得到双重哈希结果

fpga_mine函数中,将开始和终结的时间戳记录,可以看出进行哈希碰撞的总时长,方便观察,以及后期计算哈希速率。当难度系数提高到遍历所有32位变量都找不到正确的结果时,我们就可以通过用(2^32/dt)实时追踪开发板进行哈希碰撞的速率了

同样,这里的哈希结果验证除了用来方便观察,还在后面难度系数提高以后,可以用来识别验证FPGA是否因为找不到结果而返回了变量0

将bitstream烧写入开发板,并进行复位后,执行该程序,可以得到如下结果

Calculating hash with prefix 00000020e30267c75d2d24b7dfd8b6459fd8374479d3eb278c6a0b000000000000000000f65c34242c47500767b534c949c0a38af49907b94a6e136321407cbc19d27e6287072e60b9210d17
and difficulty 32
checked hash is 000000003017bb11ebf34abc063a9f3daafec52e26840af3f1d3c9a29b54c580
used time 20.9918794631958 s
b'C3CF0B91'

可以看出32位难度可以在20秒左右算出,结果经过验证,确实开头的32比特都是0

矿池服务器使用

在探究如何从矿池服务器获取数据头之前,我们要了解矿池是什么,使用的是什么通信协议。我们可以参考这篇文章:Stratum协议原理

文中介绍部分写道:比特币是一个去中心化的网络架构,通过安装比特币守护程序的节点来转发新交易和新区块。而矿机、矿池也同时形成了另一个网络,我们称之为矿工网络。矿工网络分成矿机、矿池、钱包等几个主要部分,有时矿池软件与钱包安装在一起,可合称为矿池。矿池被用来提升比特币开采稳定性,使矿工收益薪酬趋于稳定,类似于从一个矿工单独挖矿谁挖到算谁的,变成一个挖矿公司,挖到矿的收益均分。

矿机与矿池软件之间的通讯协议是stratum,而矿池软件与钱包之间的通讯是bitcoinrpc接口。stratum是JSON数据格式

Stratum协议

和矿池通信的Stratum协议大致流程如下:
FPGA应用篇【2】比特币SHA256算法实现——挖矿自动化_第1张图片
每一步的具体内容如下

  1. 矿机发送订阅到矿池,参数可以为空,这里我们放入矿机名字和版本:
{"id": 1, "method": "mining.subscribe", "params": [TestMiner/0.1]}
  1. 矿池收到订阅后回复订阅认证信息给矿机,这里参数中的数字可以忽略,个人理解是初始值:
{"id":1,"result":[[["mining.set_difficulty","00"],["mining.notify","00"]],"00",8],"error":null}
  1. 矿机发送矿工认证信息,包括用户名和密码到矿池:
{"params": ["矿工用户名", "矿工密码"], "id": 2, "method": "mining.authorize"}
  1. 矿池收到矿工认证后返回该用户名和密码是否符合条件,如果结果不是true,可能是矿池服务器有问题等问题:
{"id": 2, "result": true, "error": null}
  1. 验证完矿工信息后,矿池发送难度配置信息给矿机,这里难度系数为 2 n 2^n 2n,那目标哈希值的头32+n位必须为0,例如我们收到难度系数为65536,则要求头48位为0:
{"id": null, "method": "mining.set_difficulty", "params": [65536]}
  1. 矿池发送任务信息给矿机,用来计算数据头,收到的参数分别为job_id, prevhash, coinb1, coinb2, merkle_branches, version, nbits, ntime, clean_jobs,后面会慢慢介绍:
{"params": ["0", "4d16b6f85af6e2198f44ae2a6de67f78487ae5611b77c6c0440b921e00000000", "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff20020862062f503253482f04b8864e5008", "072f736c7573682f000000000100f2052a010000001976a914d23fcdf86f7e756a64a7a9688ef9903327048ed988ac00000000", [], "00000002", "1c2ac4af", "504e86b9", false], "id": null, "method": "mining.notify"}
  1. 矿机用数据头和难度配置进行挖矿,并提交满足难度配置的结果给矿机,其中五个参数分别为用户名, job_id, extranounce2, ntime, nounce,后面会具体介绍:
{"params": ["矿工用户名", "0", "00000001", "504e86ed", "b2957c02"], "id": 4, "method": "mining.submit"}
  1. 矿池服务器验证矿机提交的结果,并将验证结果返回给矿机,如果被拒绝,矿池会返回拒绝的理由:
{"error": null, "id": 4, "result": true}

上面的内容中可以看出,要实现矿机自动化,需要一个矿池账号,这里不做广告,大家可以去找一个矿池服务器,并申请一个账号。一般矿池主页会提供一个地址,例如poolin的

stratum+tcp://btc.ss.poolin.com:443

矿池通信代码

首先我们要引入需要的库文件,以及一些基础函数的定义:

import serial
import time
import hashlib
import binascii
import math
import socket
import json
import struct

hexlify = binascii.hexlify
unhexlify = binascii.unhexlify

这里我们建立一个名为server_handler的类来处理所有和矿池相关的内容,其中参数包括地址、端口、用户名、密码等

class server_handler:
    def __init__(self, url, port, username, password):
        self._url = url
        self._port = port
        self._username = username
        self._password = password
        self._requests = dict()
        self._socket = None
        self._message_id = 1
        self._difficulty = 0
        self._target = 0
        self._worker_name = None
        self._accepted_shares = 0
        self._extranounce1 = 0
        self._extranounce2_size = 0

首先我们需要定义一个建立socket连接的函数:

    def connect(self):
        self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self._socket.connect((self._url, self._port))

在类内定义一个发送stratum数据包的函数,变量包括方式method和参数列表param,将其连接成一个dict,再转化成json格式,通过socket发出,并将发出的数据包ID记录

    def send(self, method, params):
        request = dict(id=self._message_id, method=method, params=params)
        message = json.dumps(request)
        self._socket.send((message + '\n').encode('utf-8'))
        self._requests[self._message_id] = request
        self._message_id += 1

和矿池服务器通信的第一步是订阅subscribe,这里我们把USER_AGENT和VERSION定义在开头,但其实params也可以为空:

    def subscribe(self):
        method = 'mining.subscribe'
        params = ["%s/%s" % (USER_AGENT, '.'.join(str(p) for p in VERSION))]
        self.send(method, params)

有了第一个订阅数据包,服务器就会进行回复,我们定义最主要的函数来处理回复以及进行下一步:

首先将收到的数据包用回车分段,并打印出来进行观察;

根据返回数据包的ID,找到缓存中请求数据包的内容,以便后续使用;

如果收到的数据包是notify或者set_difficulty,则调用相应函数进行挖矿或者修改难度系数,这些函数在之后会定义;

在其他情况下,则这个数据包是针对我们发出其他数据包的回复内容,这时观察此回复数据包是否正常,并记录内部有用信息或作出下一步操作,例如subscribe的回复收到后进行authorize;

如果收到的数据包没有对应之前的request,则出错返回。

    def handle_reply(self):
        data = ""
        while True:
            # Get the next line if we have one, otherwise, read and handle the response
            if '\n' in data:
                (line, data) = data.split('\n', 1)
            else:
                chunk = self._socket.recv(2048)
                print('receive from server: ' + chunk.decode('utf-8'))
                data += chunk.decode('utf-8')
                continue

            reply = json.loads(line)
            if 'id' in reply and reply['id'] in self._requests:
                request = self._requests[reply['id']]

            if reply.get('method') == 'mining.notify':
                if 'params' not in reply or len(reply['params']) != 9:
                    print('Malformed mining.notify message')
                    continue

                (job_id, prevhash, coinb1, coinb2, merkle_branches, version, nbits, ntime, clean_jobs) = reply['params']
                print('New job %s', job_id)
                self.mine(job_id, prevhash, coinb1, coinb2, merkle_branches, version, nbits, ntime)
                time.sleep(0.25)


            elif reply.get('method') == 'mining.set_difficulty':
                if 'params' not in reply or len(reply['params']) != 1:
                    print('Malformed ming.set_difficulty message')
                    continue

                (difficulty,) = reply['params']
                self.set_difficulty(difficulty)
                print('Change difficulty to %s' % difficulty)

            elif request:

                if request.get('method') == 'mining.subscribe':
                    if 'result' not in reply or len(reply['result']) != 3 or len(reply['result'][0]) != 2:
                        print('Reply to mining.subscribe is malformed')
                        continue
                    ((mining_notify, subscription_id), extranounce1, extranounce2_size) = reply['result']
                    print('Authorize with username %s' % self._username)
                    print('Password %s' % self._password)
                    self.send(method='mining.authorize', params=[self._username, self._password])
                    self._extranounce1 = extranounce1
                    self._extranounce2_size = extranounce2_size

                elif request.get('method') == 'mining.authorize':
                    if 'result' not in reply or not reply['result']:
                        print('Failed to authenticate worker')

                    self._worker_name = request['params'][0]
                    print('Authorized worker name = %s' % self._worker_name)

                elif request.get('method') == 'mining.submit':
                    if 'result' not in reply or not reply['result']:
                        print('Failed to accept submit')

                    self._accepted_shares += 1
                    print('Accepted shares: %d' % self._accepted_shares)

                else:
                    print('Unhandled message')

            else:
                print('Bad message state')

在handle_reply函数中,收到set_difficulty后要设置类中的难度系数,设置函数如下

    def set_difficulty(self, difficulty):
        if difficulty < 0:
            print('Difficulty must be non-negative')
            return

        if difficulty == 0:
            target = 2 ** 256 - 1
        else:
            target = min(int((0xffff0000 * 2 ** (256 - 64) + 1) / difficulty - 1 + 0.5), 2 ** 256 - 1)

        self._difficulty = math.floor(math.log2(difficulty))+32
        self._target = '%064x' % target

挖矿初始化

在notify数据包收到时,所有挖矿需要的参数就都获得了,具体说就是这些参数:

prevhash, coinb1, coinb2, merkle_branches, version, nbits, ntime

下面我们通过实际代码详细介绍通过这些参数计算出上一篇博客中需要的608位数据头的方法:

    def mine(self, job_id, prevhash, coinb1, coinb2, merkle_branches, version, nbits, ntime):
        for extranounce2 in range(0, 0x7fffffff):
            extranounce2_bin = struct.pack('

将coinb1, coinb2, extranounce1, extranounce2的格式转换为二进制后相加为coinbase_bin,并进行双重SHA256哈希得到coinbase_hash_bin,作为merkle树的root

收到的参数中merkle_branches是一个可能为空的list,将其内部每一个元素和merkle_root相加后进行双重哈希,最终与version, prevhash, ntime和nbits相加得到608位的目标header_prefix_bin

有了这些准备之后,就可以调用前面fpga_mine函数,把header_prefix和难度系数通过串口输入FPGA中进行挖矿操作,等到有效结果了

这里我们把前面的fpga_mine函数进行了修改,使其返回值中增加一个强行submit的信号,具体代码可以在最后的完整代码中给出。按照我们的系统的算力,基本不可能挖到矿,所以传输几个错误信号给矿池服务器,尝试着降低一点难度系数,不过根据实际尝试,服务器似乎并不会为我降低太多难度,就这么被无视了。。。不过当做submit的测试也是可以的

最终代码

在结束了前面的介绍后,这里给出最终的成品代码(私人信息已抹去):

import serial
import time
import hashlib
import binascii
import math
import socket
import json
import struct

USER_AGENT = "TestMiner"
VERSION = [0, 1]

hexlify = binascii.hexlify
unhexlify = binascii.unhexlify


def sha256d(message):
    return hashlib.sha256(hashlib.sha256(message).digest()).hexdigest()


def swap_endian_word(hex_word):
    message = unhexlify(hex_word)
    if len(message) != 4:
        print('Must be 4-byte word')
    return message[::-1]


def swap_endian_words(hex_words):
    message = unhexlify(hex_words)
    if len(message) % 4 != 0: print('Must be 4-byte word aligned')
    return b''.join([message[4 * i: 4 * i + 4][::-1] for i in range(0, len(message) // 4)])


def fpga_mine(prefix, difficulty):
    ser = serial.Serial()
    ser.baudrate = 115200
    ser.port = 'COM4'
    ser.open()
    if ser.is_open:
        ser.write((prefix + '%02x'%difficulty + '\r').encode('utf-8'))
        print('Calculating hash with prefix ' + prefix)
        print('and difficulty ' + str(difficulty))
        time.sleep(0.5)
        t0 = time.time()
        while ser.in_waiting < 156:
            time.sleep(0.25)
        ser.read(155)
        result = ser.read(8)
        ser.reset_input_buffer()
        hash_check = sha256d(unhexlify((prefix + result.decode('utf-8')).encode('utf-8')))
        print('checked hash is ' + hash_check)
        dt = time.time() - t0;
        # print('used time ' + str(dt) + ' s')
        print('hash rate is %f' % (2**32/dt))
        difficulty_tmp = math.floor(difficulty / 4)
        if ('0' * difficulty_tmp == hash_check[:difficulty_tmp]):
            return [result, True]

    ser.close()
    '''Try to decrease difficulty by sending false result'''
    if difficulty >45:
        return ['0'.encode('utf-8'), True]
    return ['0'.encode('utf-8'), False]


class server_handler:
    def __init__(self, url, port, username, password):
        self._url = url
        self._port = port
        self._username = username
        self._password = password
        self._requests = dict()
        self._socket = None
        self._message_id = 1
        self._difficulty = 0
        self._target = 0
        self._worker_name = None
        self._accepted_shares = 0
        self._extranounce1 = 0
        self._extranounce2_size = 0

    def set_difficulty(self, difficulty):
        if difficulty < 0:
            print('Difficulty must be non-negative')
            return

        if difficulty == 0:
            target = 2 ** 256 - 1
        else:
            target = min(int((0xffff0000 * 2 ** (256 - 64) + 1) / difficulty - 1 + 0.5), 2 ** 256 - 1)

        self._difficulty = math.floor(math.log2(difficulty))+32
        self._target = '%064x' % target

    def send(self, method, params):
        request = dict(id=self._message_id, method=method, params=params)
        message = json.dumps(request)
        self._socket.send((message + '\n').encode('utf-8'))
        self._requests[self._message_id] = request
        self._message_id += 1

    def mine(self, job_id, prevhash, coinb1, coinb2, merkle_branches, version, nbits, ntime):
        for extranounce2 in range(0, 0x7fffffff):
            extranounce2_bin = struct.pack('

实际测试

将我们的NEXYS4 DDR开发板与PC相连,打开Vivado,烧写进之前的bitstream,看到指示灯亮起后手动进行一次复位,将Python代码中的用户名和密码改成申请好的账号密码后执行,我们可以看到实际运行效果如下:

receive from server: {"id":1,"result":[[["mining.set_difficulty","00"],["mining.notify","00"]],"00",8],"error":null}

Authorize with username 用户名
Password 密码
receive from server: {"id": 2, "result": true, "error": null}
{"id":null,"method":"mining.set_difficulty","params":[65536]}
{"id":null,"method":"mining.notify","params":["0%8/1=Y=K]4}s$P0<:$pw{T]*FUERzlr+1C

这时你可以看到板子上指示挖矿进度的LED灯正在缓慢前进,努力而徒劳的为你挖矿。虽然它没有什么卵用,但看着它还在努力前进的样子,你是不是更加有动力继续打工搬砖了呢?来吧毕竟板子也在跟你一起打工:)

写在最后

虽然本文做出的矿机很弱,之后不到1GH/s的速率,但它还是有优化空间的。如果有更好的FPGA芯片,可以尝试着将其时钟提高到400MHz甚至更高,并且并行配置更多个挖矿子系统。目前我们使用200MHz 8核,如果能提高到400MHz 16核就可以有100MH/s。更进一步想,如果这个逻辑能做成ASIC芯片,时钟可以进一步提高,逻辑密度也会更高,说不定就可以达到GH/s量级,然后再像现在的矿机一样堆砌上百片一样的芯片,加上合适的电源管理和专门的矿池通信器,似乎就可以做出一个真正的ASIC矿机了

然而,贫穷的博主只能在这里想象了,回归到打工人的生活,有什么新的电子下次再肝吧

你可能感兴趣的:(FPGA,FPGA,Python,区块链,比特币,挖矿)