首页 / 资讯中心 / 文章详情

用Python手写极简区块链:从零实现哈希链与工作量证明

用Python手写极简区块链:从零实现哈希链与工作量证明 ★ FEATURED ARTICLE
想理解区块链最快的方式不是读白皮书而是亲手写一条。这个项目是我花了一个周末做的极简区块链——没有P2P 网络、没有智能合约只保留了区块、哈希链、工作量证明、签名交易和 Merkle 树这几个最核心的骨架。整套代码用 Python 实现跑通之后再回头看那些概念文档之前总觉得虚的部分一下子踏实了。如果你已经有 Python 基础语法的手感想真正弄懂“区块到底怎么串起来”“转账是怎么验证的”“最长链规则凭什么让大家服气”这篇东西可以给你一份能直接跑通的参考实现。1. 动手之前先把“最小区块链”的边界画清楚很多人一听到“用 Python 写区块链”第一反应是去抄一个现成库或者直接拉一条以太坊代码下来研究。我的建议是别。你需要的不是一个功能完备的生产链而是一条能暴露所有核心机制的教学链。先想清楚什么该做、什么不该做这比写代码更重要。1.1 哪些组件必须保留哪些可以砍掉区块链这个概念拆开看其实就四层数据层、网络层、共识层、应用层。数据层是区块和交易怎么组织网络层是节点之间怎么同步共识层是大家怎么对同一本账达成一致应用层是智能合约这类业务逻辑。对于“简单区块链”我的取舍如下组件是否实现理由区块结构与哈希链实现这是区块链的根基不写等于没写工作量证明PoW实现共识机制必须亲自动手才算理解数字签名交易实现不解决“谁有权花这笔钱”就是玩具Merkle 树实现轻量级验证的经典方案值得完整跑一遍P2P 网络砍掉多节点通信是工程问题不是机制问题单机可以先复现核心逻辑智能合约砍掉属于应用层跟链本身无关UTXO 模型简化为余额模型UTXO 理解成本高先用余额模型讲清签名和记账再扩展不迟这个取舍表帮我省掉了很多纠结。单机版最大的价值在于你可以用肉眼看到每一个区块的数据结构、每一条交易的验签过程、每一次 PoW 的哈希变化。如果一开始就堆网络模块你会被 socket、握手、广播协议淹没反而看不清链本身。1.2 技术选型用标准库 一个密码学库我用的环境是 Python 3.10依赖只有pycryptodome一个用于生成 RSA 密钥和签名验签。哈希直接走标准库的hashlib持久化用内置sqlite3命令行入口用argparse。为什么不用Crypto之外的重型库因为区块链的密码学部分在实际生产中大量使用椭圆曲线签名但 RSA 的签名验签代码更容易读懂密钥生成和 JSON 序列化也更直观。教学项目里机制比算法选型重要先跑通 RSA以后再换 ECDSA 也不是难事。文件结构也非常简单mini_chain/ ├── blockchain.py # 区块、PoW、链验证、Merkle树 ├── wallet.py # 密钥生成、地址派生、交易签名 ├── cli.py # 命令行入口 └── mini_chain.db # SQLite 生成文件运行时产生我强烈建议你把这个项目当成“能跑的最小系统”来写而不是一个多目录的框架。框架感太重代码量翻倍学习收益却不会翻倍。2. 区块结构和哈希链先让数据变得不可篡改任何区块链项目第一步都是定义区块。区块是账本的“页”页页相扣形成链条。我这里实现的区块字段如下class Block: def __init__(self, index, timestamp, transactions, prev_hash, nonce0): self.index index self.timestamp timestamp self.transactions transactions # 交易列表每个交易是 dict self.prev_hash prev_hash # 上一个区块的哈希 self.nonce nonce # 工作量证明的随机数 self.merkle_root self._compute_merkle_root(transactions) self.hash self.compute_hash() def compute_hash(self): header { index: self.index, timestamp: self.timestamp, transactions: self.transactions, prev_hash: self.prev_hash, nonce: self.nonce, merkle_root: self.merkle_root, } encoded json.dumps(header, sort_keysTrue, ensure_asciiFalse).encode(utf-8) return hashlib.sha256(encoded).hexdigest()2.1 为什么每个字段都参与哈希计算区块的哈希不是只算一个“区块头摘要”而是把交易、索引、时间戳、前块哈希、随机数全部序列化之后再做 SHA256。这里最容易被新手忽略的坑是序列化必须稳定。compute_hash()里我特意加了sort_keysTrue。原因很简单——Python 字典在 3.7 之后保留插入顺序但如果你在另一台机器、另一种语言里重建这个区块字段顺序可能不同而json.dumps对字段顺序敏感同样的内容会得到完全不同的哈希。sort_keysTrue保证“内容一样哈希必然一样”。prev_hash字段是整个哈希链的关键。它把每个区块“绑”在前一个区块上。我习惯用一个类比向同事解释这件事每个新同学不仅要写自己的作业还要把上一个同学的作业指纹抄在开头。你如果想改前面某一页的内容从那一页开始后面每一页的指纹都会对不上任何人一校验就能发现篡改。这里顺便说一句创世区块的prev_hash没有前一页可抄所以我约定它固定为 64 个0。这算一个行业通用惯例很多链的创世区块都是这么处理的。2.2 区块校验不是计算出来就完事光会算哈希还不够链要保证每个区块都合法。我实现了is_valid_blockdef is_valid_block(new_block, previous_block, difficulty): if new_block.index ! previous_block.index 1: return False if new_block.prev_hash ! previous_block.hash: return False if new_block.compute_hash() ! new_block.hash: return False if new_block.timestamp previous_block.timestamp: return False if not new_block.hash.startswith(0 * difficulty): return False return True第一个条件防止区块跳号第二个条件强制链条连续第三个条件防止有人声称一个区块哈希是什么实际内容却对不上第四个条件防止时间戳倒挂最后一个条件确保工作量证明被真实完成。我后来在调试中发现第四和第五个条件之间有个微妙的配合。PoW 要求哈希前导零但理论上如果 someone 反复试 nonce有可能试出一个时间戳更早但仍然满足难度的哈希。所以在挖矿代码里我会把时间戳固定在“当前时间”在校验时再检查时间戳递增双保险。3. 工作量证明从一个可调节的运气门槛说起在去中心化系统里没有任何中央机构来决定“下一个区块由谁来写”。那怎么保证大家不乱写比特币给出的答案是一个很朴素的思路你证明你费了劲你才有资格记账。这个“费劲”不是拼算力谁更强而是拼谁能先撞上一个指定难度的哈希。3.1 hashcash 思想在 Python 里的落地PoW 的实现核心是让区块哈希满足一定条件——最常见的是哈希结果以若干个0开头。出块难度越高需要的0越多尝试次数呈指数级上升。class Blockchain: def __init__(self, difficulty5): self.chain [] self.pending_transactions [] self.difficulty difficulty self._create_genesis_block() def _create_genesis_block(self): genesis Block(0, time.time(), [], 0 * 64) genesis.hash genesis.compute_hash() self.chain.append(genesis) def proof_of_work(self, block): block.nonce 0 calculated_hash block.compute_hash() while not calculated_hash.startswith(0 * self.difficulty): block.nonce 1 calculated_hash block.compute_hash() return calculated_hash这里difficulty5的意思是哈希必须以前 5 个字符00000开头。字符串哈希是十六进制每个字符有 16 种取值所以每个字符为 0 的概率是 1/165 个 0 的平均尝试次数大约是 16^5 1048576 次。这个量级在单机上跑大约几秒到十几秒非常适合教学演示——既有等待感又不会等到失去耐心。如果你想调快一点difficulty4平均只要 65536 次尝试基本眨眼就出块想看更“真实”的挖矿感可以调到 6大约一两分钟一个块。我建议初学者先用 4 或 5 把流程跑通再慢慢加难度。3.2 为什么这个方法能形成共识PoW 的美妙之处在于它把“记账权”变成了一个可调节的运气门槛。系统不关心你看了多少资料、有多高的社会地位只关心你有没有先找到满足条件的nonce。只要大多数人遵守“哈希有效且链条最长”的规则整体账本就会沿着最长的合法链条稳定生长。在实际比特币实现里难度是动态调整的全网算力变强出块变快就调高难度算力下降、出块过慢就调低难度。我这里没有纠结复杂的调参公式而是用一个非常简化的版本说明原理def adjust_difficulty(self, target_block_time30): if len(self.chain) % 10 0: actual self.chain[-1].timestamp - self.chain[-10].timestamp expected target_block_time * 10 if actual expected / 2: self.difficulty 1 elif actual expected * 2: self.difficulty - 1这个代码属于教学补充真实 BTC 是每 2016 个块调整一次目标出块时间 10 分钟。原理完全一样实际时间远快于目标就加大难度远慢于目标就减小难度。难度调整是“让全网络出块速度保持稳定”的关键机制没有它算力波动会直接摧毁整个共识节奏。4. 交易与 Merkle 树从字符串记账到可验证的分布式账本很多入门教程到这里就停了区块里的data字段就是一个字符串比如“转账 100 元”。但真实区块链绝不会这么做因为字符串无法验证“到底是谁发起这笔转账”也无法高效地证明“某个交易确实存在”。所以我把这一节作为整条链的进阶核心。4.1 钱包与地址谁拥有这笔钱我实现了一个简单的钱包模块负责生成 RSA 密钥对并把公钥派生为地址。地址不是公钥本身而是公钥的哈希这样做一方面缩短了长度另一方面避免直接暴露公钥公钥只在签名验证时才需要公开。from Crypto.PublicKey import RSA from Crypto.Hash import SHA256, RIPEMD160 def create_wallet(): key RSA.generate(2048) return key def address_from_public_key(pub_key_hex): pub_bytes bytes.fromhex(pub_key_hex) sha SHA256.new(pub_bytes).digest() ripe RIPEMD160.new(sha).hexdigest() return ripe地址生成后面向用户的部分我用 Base58 再编码一下会更像真实钱包但这里为了调试方便直接输出十六进制字符串。地址的本质就是把公钥的指纹编码成人类可读的形式内部逻辑完全一样。4.2 交易签名与验签交易最核心的问题是我怎么知道一笔转账确实是地址 A 发起而不是有人冒充答案就是数字签名。签名时用私钥签验签时用公钥验。私钥只有你自己有别人拿不到所以签名无法伪造。def sign_transaction(private_key, tx_data): serialized json.dumps(tx_data, sort_keysTrue).encode(utf-8) digest SHA256.new(serialized) signature pkcs1_15.new(private_key).sign(digest) return signature.hex()注意这里我故意对tx_data不含签名的交易主体做序列化后签名而不是对整个交易字典签名。因为签名一旦生成如果再被塞进交易字典参与哈希就产生了循环依赖——签名依赖内容内容又包含签名。正确的做法是签名的范围严格限定在“除签名以外的所有字段”验证时也按同样范围重算。验签完整流程是先检查交易中的公钥能否派生出 sender 地址再对交易体做 SHA256 后用公钥验签。两步都通过这笔交易才被认可。在余额模型下我还给每笔交易加了一个timestamp防重放并约定同一笔交易不能重复被打包。这还远远不够生产级但能帮你理解基本思路。4.3 Merkle 树我如何快速证明一笔交易存在区块里有好几笔交易我不可能每次都对整个区块做哈希来核对。Merkle 树的作用是把所有交易的哈希两两配对逐层向上合并直到生成一个根哈希。你只需要知道根哈希和一条“验证路径”就能证明某笔交易在某个区块里而不需要下载全部交易。def _compute_merkle_root(self, transactions): if not transactions: return hashlib.sha256(b).hexdigest() hashes [] for tx in transactions: data json.dumps(tx, sort_keysTrue).encode(utf-8) hashes.append(hashlib.sha256(data).hexdigest()) while len(hashes) 1: if len(hashes) % 2 1: hashes.append(hashes[-1]) merged [] for i in range(0, len(hashes), 2): merged.append(hashlib.sha256((hashes[i] hashes[i 1]).encode()).hexdigest()) hashes merged return hashes[0]两个细节值得注意。第一空交易的区块也必须有一个 Merkle 根我约定为对空字符串做 SHA256返回的是一个固定常量这样区块结构始终完整。第二当交易数为奇数时我会复制最后一个节点的哈希再配对这叫“奇数节点自我复制”是 Merkle 树的通用处理方式。Merkle 根被放进区块哈希参与计算这意味着哪怕改动一笔交易的一丁点内容根哈希会变区块哈希会变整条链从那个位置开始全部被破坏。所以 Merkle 树不是锦上添花而是让区块链能在“保留大量交易的同时还能轻量校验”的关键支撑。5. 链验证与分叉处理最长链规则为什么能平息分歧单机上跑几条手工链很容易产生一个错觉区块链就是一条直线往前走。但在真实网络中两个矿工几乎同时挖出区块是常态这时会出现分叉——两条链在同一个高度都合法到底听谁的5.1 验证整条链预防篡改的最后一道闸门我先实现了一个简单的整链验证方法它会把从创世区块开始的每一个区块都重新校验一遍def verify_chain(self): for i in range(1, len(self.chain)): if not is_valid_block(self.chain[i], self.chain[i - 1], self.difficulty): return False return True别小看这个函数。调试区块链时90% 的 bug 都是靠它发现的。比如我之前有一位同事写区块哈希时漏掉了nonce导致同一个区块算出不同哈希最后整链验证直接失败。你以后在真实项目里维护一个“随时能重新算出全链并检验”的接口对排查问题极其重要。5.2 最长链规则用累计工作量投票当链出现分叉时节点选择的规则是接收累计工作量更大的那条链。在等难度出块的简化模型里累计工作量约等于区块高度所以最简单的方式是比“谁更长”。我在Blockchain类里加了替代链替换逻辑def replace_chain(self, new_chain, new_pending): if len(new_chain) len(self.chain) and self.verify_chain(new_chain): self.chain new_chain self.pending_transactions new_pending这个思路讲出来很朴素每个节点都把自己看到的最长合法链作为权威短链被丢弃。为什么大家会信这条规则因为对诚实矿工来说算力不会说谎。制造一条比主链更长的链必须付出至少相当于全网多数算力的努力这在经济上不可行。规则听上去简单背后其实是“用物理成本换信任”的经济学设计。5.3 模拟一次分叉合并为了亲眼看到最长链规则生效我写了一个小场景节点 A 和节点 B 各自持有同一份链的副本A 先挖出第 1 个块B 也同时挖出一个不同的第 1 个块因为 pending 交易不同这时两边各执一词。我给 A 自己继续往后挖了两个块A 的链变长B 节点同步时发现 A 的链更完整于是丢弃自己的短链切换过去。这个演示让我真正体会到了“分叉不是异常而是日常”。区块链系统的魅力不在于永远不分叉而在于分叉之后能有机制自动收敛。只要不是全网长期分裂最长链规则会把所有节点拉回同一条主线。5.4 为什么比“最长”而不是“包含最多交易”有些运行者以为“最长链”就是交易最多的链其实不准确。如果某条链虽然短但每个块都塞满了交易是不是应该选它真实比特币不会这么做因为判断“交易最多”需要遍历区块内容成本高且容易被伪造判断“最长”只需要对比高度与累计难度成本低且作弊成本高。所以规则的准确说法是“累计工作量最大的链”高度只是它的简化表现。理解了这一层你再去读白皮书里的“longest chain”就不会望文生义了。6. 持久化与 CLI把五分钟的 Demo 变成能一直跑的小系统代码写到这里区块链已经能挖矿、能转账、能验签但它还活在内存里进程一关就全没了。真正的链需要持久化需要一个命令行入口让整个系统“活”起来。这一节我把它接到 SQLite 上并配了完整的 CLI 命令。6.1 SQLite 表结构区块即文档区块链的持久化有个很自然的思路区块本身就是一个完整对象我把它整体序列化成 JSON 存一行。SQLite 的TEXT字段足够容纳。我采用的表结构非常简单CREATE TABLE IF NOT EXISTS blocks ( height INTEGER PRIMARY KEY, block_json TEXT NOT NULL, created_at REAL NOT NULL );读写时block_json用json.loads还原为字典再重建Block对象。这里一个关键点是从数据库恢复区块时不能直接调用Block.__init__重新计算哈希因为hash字段是当时挖矿算出的结果重新计算会得到同样的值但如果数据库内容被篡改重启时你会立刻发现验证失败。我专门写了一个from_dict类方法只恢复字段不重新挖矿然后把整链交给verify_chain做检查。6.2 CLI 命令一览我用argparse定义了 6 个命令构成了一个可演示的最小闭环命令功能关键输出init初始化链创建创世区块创世块哈希wallet生成/加载钱包打印地址地址与公钥balance查询某地址余额数字余额transfer发起一笔签名交易交易 IDmine打包 pending 交易并挖一个新块新区块哈希chain打印整条链每个区块概览verify全链验证验证结果transfer命令里包含了完整的签名流程它读钱包、构造交易字典、调用sign_transaction最后把交易放进 pending 队列。只有执行minepending 里的交易才会被真正塞进区块。这个设计模拟了真实世界的节奏转账请求先广播到交易池矿工再把交易池中的交易打包出块。实际跑一个完整流程的输出大致长这样$ python cli.py init 创世块生成: 00000e3ea7f0c1ecb0f0dd1d8ce6c9d6f9e83bc64bf4a2c5adfe37fba73c5e41 $ python cli.py wallet 地址: 1b2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b 公钥: 30820122300d06092a864886f70d01010105000382010f0030... $ python cli.py transfer --recipient 2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e --amount 25 交易已签名: a1b2c3d4... $ python cli.py mine 新区块 #1 生成: 000001a9ebf1d0f5d9c0...6.3 自己动手时会踩的几个坑这几条是我跑通之后复盘记下来的每条都曾让我多花了一到两小时。第一时间戳单位必须统一。我在签名交易里用了秒但在区块里一开始用毫秒导致同一个交易在签名时和打包后时间不同验签死活不过。后来统一到秒问题立刻消失。真实系统里单位不一致是跨语言协作最常见的事故源。第二私钥文件不要带多余权限。钱包保存时我明确只写当前用户可读写的权限否则同机其他用户可能读到私钥。以下代码在wallet.py里帮忙挡住了这个问题with open(wallet_path, w) as f: f.write(private_key_export) os.chmod(wallet_path, 0o600)第三哈希计算中的浮点陷阱。time.time()返回浮点数JSON 序列化浮点在跨语言环境中可能有精度差异。解决方式是转成整数int(time.time())这样序列化结果在任何语言下都一致。第四Merkle 树对空交易的根哈希要固定。如果空交易返回None后续所有依赖根哈希的校验逻辑都要判空容易埋雷。我给空交易一个固定根哈希整个区块结构永远完整。第五重放攻击是余额模型的天然弱点。同一笔已签名的交易如果被反复广播接收方可能重复入账。真实系统用 UTXO 模型和交易序号来解决。我在教学实现里加了交易去重检查——每个地址的交易序号必须单调递增打包时发现旧序号直接拒绝。虽然简化为单节点版本但思路是一致的。写在最后的体会把这条链完整跑起来之后我对之前背过的那些概念有了完全不一样的感觉。哈希链不是抽象图是我能在调试器里看到变量一步步串起来的东西PoW 不是一个神秘的数学问题无非是找一个 nonce 让哈希前导零Merkle 树不是高端协议只是把节点两两合并哈希的一棵树。如果你也想自己写一条链我建议从本文这份最小骨架开始把difficulty从 5 降到 4 跑通流程再慢慢加难度、加交易、加持久化一步步把它扩展成你自己真正理解和掌控的小账本。
阅读完成 · 觉得有帮助?
咨询建站