上一篇中,我们通过FPGA实现了一个通过串口传输数据的半自动挖矿模块,哈希碰撞的速度大约为25MH/s。不论能不能挖到矿,我们距离一个真正的矿机只剩和矿池服务器沟通获取数据头与难度的PC端程序。
我们的设计中,FPGA负责进行计算量最高的哈希碰撞部分,而PC端负责与矿池服务器沟通,计划使用Python 3.8实现。(没错我不是只会做Verilog部分内容)
在上一篇中,我们将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协议大致流程如下:
每一步的具体内容如下
{"id": 1, "method": "mining.subscribe", "params": [TestMiner/0.1]}
{"id":1,"result":[[["mining.set_difficulty","00"],["mining.notify","00"]],"00",8],"error":null}
{"params": ["矿工用户名", "矿工密码"], "id": 2, "method": "mining.authorize"}
{"id": 2, "result": true, "error": null}
{"id": null, "method": "mining.set_difficulty", "params": [65536]}
{"params": ["0", "4d16b6f85af6e2198f44ae2a6de67f78487ae5611b77c6c0440b921e00000000", "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff20020862062f503253482f04b8864e5008", "072f736c7573682f000000000100f2052a010000001976a914d23fcdf86f7e756a64a7a9688ef9903327048ed988ac00000000", [], "00000002", "1c2ac4af", "504e86b9", false], "id": null, "method": "mining.notify"}
{"params": ["矿工用户名", "0", "00000001", "504e86ed", "b2957c02"], "id": 4, "method": "mining.submit"}
{"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矿机了
然而,贫穷的博主只能在这里想象了,回归到打工人的生活,有什么新的电子下次再肝吧