基于Python的时间胶囊区块链:AES-GCM加密、链上存证与时间锁实践

发布时间:2026/10/30 11:11:49
基于Python的时间胶囊区块链:AES-GCM加密、链上存证与时间锁实践

简介:“区块链上的时间胶囊”项目源码与学习素材,适合具备 Python 编程基础、希望进一步接触区块链时间戳与智能合约开发的读者,可用于理解信息上链、加密保存及到期解锁等场景的基本实现思路,并体会分布式系统中透明性与隐私保护的权衡。压缩包共24个文件,约159KB,主要由9个JavaScript脚本、6个Vue组件、2个Solidity合约及JSON配置、HTML入口等组成,分别对应合约部署、前端交互、路由配置与项目初始化等环节。目前已有203人学习。借助合约、迁移脚本和Vue界面,读者可以梳理时间胶囊从创建、部署到交互的完整结构,理解链上时间戳、数据加密与智能合约解锁条件如何配合。项目把不可篡改的链上记录与前端操作分开组织,读者还能参考其目录划分和模块职责,搭建自己的区块链小项目。

1. 时间胶囊不是玄学,拆开就是加密、存证、时间锁三件事

有人问我,把一段话封进 Python 写的区块链,设个未来时间点,不到那个时间点谁都打不开——这事靠谱吗?我第一反应是:别把它想成什么神秘装置。它其实是三件普通工程事叠在一起:先用对称加密把内容封成密文,再把密文摘要和元数据写进链上交易拿到不可篡改的存证,最后用一个时间锁规则卡住解密入口。这个项目就是把这套流程做成可跑的单机节点加命令行工具,适合想入门链式数据结构、又不想啃庞大框架的 Python 开发者,也适合要做一个"给未来自己留话"小工具的人。它不追求公链级别的吞吐,追求的是链路完整、每一步都能看懂、改得动。

2. 工程骨架:一条链、一个胶囊、一套 CLI 怎么分层

拿到源码先别急着pip install,先看目录。这类小项目的坑往往不在算法,而在分层没分清楚——加密逻辑写进区块类,节点接口直接操作文件,最后想换个存储后端就得全篇改。这套代码按"数据结构 / 密码学 / 网络 / 交互"四层切开,每层只依赖下一层,改起来心里有底。

2.1 目录结构与各层职责

timecapsule-chain/ ├── capsule/ │ ├── __init__.py │ ├── block.py # 区块头、哈希计算、Merkle 根 │ ├── transaction.py # 胶囊交易结构与 txid │ ├── blockchain.py # 链管理、校验、难度调整 │ ├── crypto.py # AES-GCM 封装/解封、密钥派生、签名 │ ├── timelock.py # 时间锁策略(逻辑锁/顺序锁/阈值锁) │ ├── wallet.py # 密钥对生成与本地保存 │ ├── mempool.py # 待打包交易池 │ ├── storage.py # JSON 快照 + SQLite 索引 │ └── node.py # Flask HTTP 接口 ├── cli.py # 命令行入口 ├── config.py # 全局参数 ├── tests/ # pytest 用例 └── requirements.txt

block.py只认哈希,不碰密码学细节;crypto.py只认字节,不知道区块长什么样;node.py只做参数校验和转发,业务全在blockchain.py。这种切法看着啰嗦,但后面想加 P2P 或者换 SQLite,改动面能控制在单个文件内。

我一般把config.py放在最外层而不是包内,原因是它经常要跟着部署环境变,放包里容易被误提交。配置项也不多,出块目标时间、初始难度、难度调整窗口、链数据路径、节点端口,五个够用。

2.2 依赖选型:Flask 加 cryptography 的组合

依赖清单很短,这是好事:

# requirements.txt Flask==3.0.3 # 节点 HTTP 接口,轻量够用 requests==2.32.3 # 节点间同步 cryptography==42.0.8 # AES-GCM、椭圆曲线签名 pytest==8.2.2 # 测试

为什么不上 FastAPI?做过就知道,这类工具项目大部分时间是命令行在跑,HTTP 接口只是附属,FastAPI 带进来的 pydantic 版本约束反而会在老环境上翻车。Flask 三个装饰器就能把接口写完,调试时curl一把梭。

密码学库选cryptography而不是自己拼pycryptodome,关键在 AES-GCM 的 AAD(附加认证数据)支持更顺手。时间胶囊这个场景,我强烈建议把"解锁时间"塞进 AAD——密文和元数据绑死,谁改了交易里的unlock_at字段,解密直接报认证失败。这一点后面第 4 章会展开。

签名部分用cryptography的 Ed25519,密钥短、签名快、没有曲线参数选择困难症。钱包私钥落盘前用scrypt派生一层口令密钥再加密,虽然本地工具谈不上多高安全等级,但至少别让私钥以明文躺在磁盘上。

2.3 配置项与启动参数

# config.py import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) CHAIN_FILE = os.path.join(BASE_DIR, "data", "chain.json") INDEX_DB = os.path.join(BASE_DIR, "data", "index.db") TARGET_BLOCK_TIME = 30 # 目标出块间隔,单位秒 INITIAL_DIFFICULTY = 4 # 前导零个数 ADJUST_INTERVAL = 10 # 每 10 个块调整一次难度 MAX_TX_PER_BLOCK = 50 # 单块交易上限 NODE_PORT = 5000

INITIAL_DIFFICULTY = 4意味着哈希前面要有 4 个十六进制 0,笔记本上大概几百毫秒到两秒能出一个块,适合演示。调到 6 就会明显变慢,调到 7 基本可以泡杯茶了——这不是夸张,是血泪经验,第一次跑千万别直接上 6。

TARGET_BLOCK_TIME和ADJUST_INTERVAL是配套的,难度调整公式在blockchain.py里:实际耗时比目标快就加难度,慢就减,单次调整幅度限制在一倍以内,避免震荡。这个设计抄的是主流链的常见做法,小项目照抄不吃亏。

2.4 从零把节点拉起来

环境准备和第一条命令:

python -m venv .venv && source .venv/bin/activate pip install -r requirements.txt # 初始化链数据目录和创世块 python cli.py init --difficulty 4 # 另开终端启动 HTTP 节点 python cli.py serve --port 5000

init做的事很简单:建data/目录、生成创世块、写一份空的chain.json。创世块的previous_hash固定为 64 个 0,timestamp用当前时间取整到秒,这样重跑初始化得到的创世块哈希可复现,方便测试断言。

serve起来后,curl http://127.0.0.1:5000/chain应该能拿到一个只含创世块的 JSON。拿不到先别怀疑代码,八成是端口被占或者虚拟环境没激活——这两种情况我各踩过一次。

3. 核心模块拆解:一个区块从交易打包到落盘的完整链路

这一章是整套代码的主干。链路是:胶囊交易进内存池 → 打包成区块 → 算 Merkle 根 → 挖矿找 nonce → 落盘 → 广播。每一环都有个容易被忽略的细节,写错一处,后面校验就过不去。

3.1 交易结构与 Merkle 根

胶囊交易不是普通转账,字段要多一些:

# transaction.py import hashlib import json import time import uuid def build_capsule_tx(ciphertext: bytes, unlock_at: int, recipient_pub: str, meta: dict) -> dict: """构造一条胶囊交易,链上只存密文与元数据""" tx = { "txid": "", # 稍后由内容哈希填充 "type": "capsule", "created_at": int(time.time()), "unlock_at": int(unlock_at), # Unix 秒,解锁时间点 "recipient": recipient_pub, # 收件人公钥(hex) "cipher": ciphertext.hex(), # AES-GCM 密文(含 tag) "meta": meta, # 算法、nonce、salt、文件名等 "nonce": uuid.uuid4().hex, # 防同内容重复导致 txid 冲突 } tx["txid"] = tx_hash(tx) return tx def tx_hash(tx: dict) -> str: """规范化序列化后取 SHA-256,保证跨机器一致""" payload = {k: v for k, v in tx.items() if k != "txid"} raw = json.dumps(payload, sort_keys=True, separators=(",", ":")) return hashlib.sha256(raw.encode()).hexdigest()

sort_keys=True加separators去空格这两步不能省。JSON 默认序列化会带空格,键顺序也依赖插入顺序,两个节点对同一条交易算出的 txid 就可能不一样,同步时直接对不上。这个坑不显眼,但排查起来很费时间,因为单机跑永远正常。

Merkle 根的决定性作用是让区块头能代表整块交易:

# block.py def merkle_root(tx_hashes: list[str]) -> str: if not tx_hashes: return hashlib.sha256(b"").hexdigest() layer = list(tx_hashes) while len(layer) > 1: if len(layer) % 2 == 1: # 奇数个就复制最后一个 layer.append(layer[-1]) layer = [ hashlib.sha256((layer[i] + layer[i + 1]).encode()).hexdigest() for i in range(0, len(layer), 2) ] return layer[0]

参数是交易哈希列表,返回单个 64 位十六进制串。奇数层复制末尾这个处理是标准做法,不复制的话两两配对会漏一个,不同实现对同一批交易算出不同根,跨节点校验必然失败。空块返回空串的哈希而不是空字符串,是为了让compute_hash的拼接逻辑统一。

3.2 区块哈希与 PoW 挖矿

区块头字段的顺序一旦定下就不能改,改了历史块的哈希全部作废:

# block.py import hashlib import json class Block: def __init__(self, index, timestamp, transactions, previous_hash, difficulty, nonce=0): self.index = index self.timestamp = int(timestamp) # 一律用整型秒 self.transactions = transactions self.previous_hash = previous_hash self.difficulty = difficulty self.nonce = nonce self.merkle_root = merkle_root([t["txid"] for t in transactions]) def header(self) -> dict: return { "index": self.index, "timestamp": self.timestamp, "merkle_root": self.merkle_root, "previous_hash": self.previous_hash, "difficulty": self.difficulty, "nonce": self.nonce, } def compute_hash(self) -> str: raw = json.dumps(self.header(), sort_keys=True, separators=(",", ":")) return hashlib.sha256(raw.encode()).hexdigest() def mine(self, difficulty: int) -> str: target = "0" * difficulty while not self.compute_hash().startswith(target): self.nonce += 1 return self.compute_hash()

timestamp强制int()类型转换是刻意的。浮点秒在不同平台序列化出来可能是1717000000.0也可能是1717000000,JSON 一序列化,哈希就变了,重启后整条链校验不过——这就是所谓的"玄学问题",其实是类型不统一。

挖矿循环里每次compute_hash都重新做一次json.dumps,难度 4 的时候感觉不出来,难度上去了就是性能瓶颈。真要提速,把 header 前缀序列化一次缓存起来,nonce 单独拼。不过演示项目保持可读性优先,我倾向不优化,让读代码的人一眼看懂在干嘛。

3.3 链接管:verify_chain 的五道校验

Blockchain类管着链的增删查,核心是校验函数:

# blockchain.py class Blockchain: def __init__(self, difficulty=4, adjust_interval=10, target_time=30): self.chain: list[Block] = [] self.difficulty = difficulty self.adjust_interval = adjust_interval self.target_time = target_time self.mempool = [] def verify_chain(self) -> tuple[bool, str]: for i in range(1, len(self.chain)): cur, prev = self.chain[i], self.chain[i - 1] if cur.previous_hash != prev.compute_hash(): return False, f"块 {i} 前哈希不匹配" if not cur.compute_hash().startswith("0" * cur.difficulty): return False, f"块 {i} 工作量不足" if cur.merkle_root != merkle_root( [t["txid"] for t in cur.transactions]): return False, f"块 {i} Merkle 根被篡改" if cur.timestamp <= prev.timestamp: return False, f"块 {i} 时间戳未单调递增" for tx in cur.transactions: if tx["txid"] != tx_hash(tx): return False, f"块 {i} 交易 {tx['txid']} 被改动" return True, "ok"

五道校验对应五个攻击面:改前块、改难度、改交易集合、倒填时间戳、改单笔交易内容。任何一项不过,链就整条拒绝,不给"部分信任"的余地。

注意cur.timestamp <= prev.timestamp用的是非严格单调,同一个秒内出两个块在生产链上会碰壁,本地测试又经常一秒出一个。我的处理是出块时强制max(now, prev.timestamp + 1),让时间戳自动往后推一秒,校验逻辑保持严格。这属于工程折中,不是理论最优,但能让测试跑顺。

3.4 持久化:JSON 快照加 SQLite 索引

链数据用 JSON 整存整取,简单直接:

# storage.py import json import os def save_chain(path: str, blocks: list[dict]) -> None: os.makedirs(os.path.dirname(path), exist_ok=True) tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump({"blocks": blocks}, f, ensure_ascii=False, sort_keys=True, indent=2) os.replace(tmp, path) # 原子替换,防写一半断电 def load_chain(path: str) -> list[dict]: if not os.path.exists(path): return [] with open(path, encoding="utf-8") as f: return json.load(f).get("blocks", [])

os.replace这步是后悔药。直接open(path, "w")写到一半进程被杀,chain.json就成了半截文件,下次启动解析失败,整条链看起来"凭空消失"。先写临时文件再原子替换,至少保证要么是旧版本,要么是新版本。

SQLite 只做索引,不存全量区块:txid、block_index、unlock_at、recipient四个字段建表,按解锁时间倒排查询时能秒回。全量数据还是 JSON,理由是调试时用编辑器直接打开看最省事,索引库删了也能从 JSON 重建。

4. 时间锁与加密封装:胶囊怎么封、怎么到期才开

封胶囊和解胶囊是两套不对称的逻辑。封装时只需要收件人公钥和解锁时间,解密时除了私钥还得确认链上时间已经过了点。这一章把交易字段、AES-GCM 封装、三种时间锁策略和解锁流程讲透。

4.1 链上只放密文:胶囊交易字段设计

字段设计有一条铁律:明文永远不上链。链上存的东西任何人可见,一旦写入就撤不回来。

字段类型是否上链说明
txidstring是内容哈希,交易唯一标识
unlock_atint是Unix 秒,参与 AAD 绑定
recipientstring是收件人 Ed25519 公钥 hex
cipherstring是AES-GCM 密文加认证标签 hex
meta.algstring是固定AESGCM-256
meta.noncestring是12 字节 GCM nonce
meta.saltstring是scrypt 盐,16 字节
meta.filenamestring是原文文件名,可选
plaintextbytes否只在本地内存出现

meta.filename这种字段泄露的信息很少,但如果你要封的是敏感内容,连文件名都该塞进密文里,meta只留算法参数。这个取舍看你自己的场景,代码里留了开关。

4.2 AES-GCM 封装与 scrypt 密钥派生

# crypto.py import os from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.kdf.scrypt import Scrypt def derive_key(password: bytes, salt: bytes) -> bytes: kdf = Scrypt(salt=salt, length=32, n=2 ** 14, r=8, p=1) return kdf.derive(password) def seal(plaintext: bytes, password: bytes, unlock_at: int) -> dict: salt = os.urandom(16) nonce = os.urandom(12) # GCM nonce,绝不复用 key = derive_key(password, salt) aes = AESGCM(key) aad = f"unlock_at={unlock_at}".encode() # 绑死解锁时间 ct = aes.encrypt(nonce, plaintext, aad) return { "cipher": ct.hex(), "meta": { "alg": "AESGCM-256", "nonce": nonce.hex(), "salt": salt.hex(), }, }

n=2**14的 scrypt 参数在笔记本上大概 50 到 100 毫秒,安全性和体验的平衡点。想更硬可以上2**17,代价是每次解封多等半秒。

aad传进去的那行是整个时间锁防篡改的关键。交易里的unlock_at如果被人从 2030 年改成 2025 年,解密时 AAD 对不上,AESGCM.decrypt直接抛InvalidTag。这就是把元数据变成密码学承诺,比在业务层写if now < unlock_at靠谱得多。

nonce 复用是 GCM 模式的致命伤,同一个 key 加同一个 nonce 加密两段不同明文,密钥流可被还原。代码里每次os.urandom(12)现生成,不做任何缓存。

4.3 时间锁的三种实现层次

时间锁在密码学上有不同段位,项目里三种都留了接口:

第一种,逻辑锁。最常用,也最容易被误解。节点和解封客户端都检查now >= unlock_at,不到点直接拒。它的约束力来自社会共识——节点软件守规矩,你就打不开。缺点也很明显:改本地系统时间,或者自己拿私钥私下去解,没人拦得住。

# timelock.py import hashlib def vdf_key(seed: bytes, rounds: int) -> bytes: """顺序锁:连续哈希 rounds 次,只能串行算""" h = seed for _ in range(rounds): h = hashlib.sha256(h).digest() return h

第二种,顺序锁。上面这段是极简的时间锁谜题:密钥要连续迭代哈希 N 次才能得到,N 越大耗时越长。想提前拿到密钥,唯一办法就是老老实实算完 N 次,算力换时间。它不是严格意义上的 VDF(真正的 VDF 还需要证明可快速验证),但在这个项目里用来说明"解密成本随时间增长"够了。要调难度就改rounds,十万次大概几百毫秒,百万次就上秒级了。

第三种,阈值锁。把密钥拆成 m 份,交给 n 个持有者,凑够 t 份才能还原。项目里用一个简单的 Shamir 分片演示,适合做"多个见证人共同见证"的场景。实现比前两种复杂,代码放在timelock.py后半段,默认不启用。

提示:逻辑锁、顺序锁、阈值锁可以叠加。先用顺序锁抬高提前解密的成本,再用阈值锁分散信任,最后用逻辑锁卡住客户端入口,三层叠起来才算有点样子。

4.4 解锁流程:拿链上中位时间说话

解胶囊的入口函数:

# crypto.py from cryptography.hazmat.primitives.ciphers.aead import AESGCM def open_capsule(tx: dict, password: bytes, chain_median_time: int) -> bytes: unlock_at = tx["unlock_at"] if chain_median_time < unlock_at: raise PermissionError( f"未到解锁时间,还差 {unlock_at - chain_median_time} 秒") meta = tx["meta"] salt = bytes.fromhex(meta["salt"]) nonce = bytes.fromhex(meta["nonce"]) key = derive_key(password, salt) aes = AESGCM(key) aad = f"unlock_at={unlock_at}".encode() return aes.decrypt(nonce, bytes.fromhex(tx["cipher"]), aad)

第二个参数chain_median_time是重点,它不是本地time.time()。取值逻辑是取最近 11 个区块时间戳的中位数:

def median_time_past(blocks: list[Block], window: int = 11) -> int: recent = [b.timestamp for b in blocks[-window:]] recent.sort() return recent[len(recent) // 2]

用中位数而不是最新块时间戳,是为了防止有人用刚出的一个时间戳异常的块当借口提前开锁。11 个块的中位数至少要影响 6 个块才能推动,成本比改本地时钟高得多。这个思路在主流的链上都有影子,属于成熟做法。

抛PermissionError而不是返回None,是为了让 CLI 层能区分"还没到点"和"密码错了"——前者提示剩余时间,后者直接报认证失败,用户体验差别很大。

5. 常见问题排查:跑不通、解不开、链分叉的现场记录

这一章都是我自己和身边人真实踩过的坑,按现象、原因、解决三段写。遇到问题先对号入座,比盲改代码快得多。

5.1 挖矿卡死不动,CPU 跑满却不出块

现象:执行python cli.py mine,终端没输出,等了五分钟还在转,CPU 单核 100%。

原因多半是difficulty设太高。前导零每多一个,期望尝试次数翻 16 倍,难度 4 大概几千次哈希,难度 6 就到百万级,纯 Python 每轮还要做一次 JSON 序列化,速度上不去。另一种可能是compute_hash每次重新序列化区块头,nonce 递增却没复用前缀,白白多算。

解决办法:先把config.py里的INITIAL_DIFFICULTY改回 4 验证流程,跑通了再往上试。想真提速,把 header 里不变的部分预先序列化成字符串,循环内只拼 nonce:

def mine_fast(block: Block, difficulty: int) -> str: prefix = json.dumps( {k: v for k, v in block.header().items() if k != "nonce"}, sort_keys=True, separators=(",", ":"))[:-1] # 去掉右花括号 target = "0" * difficulty while True: raw = f'{prefix},"nonce":{block.nonce}}}' h = hashlib.sha256(raw.encode()).hexdigest() if h.startswith(target): return h block.nonce += 1

这个改法把耗时降一个量级,但代码可读性会下降,建议只在演示前临时用。

5.2 重启后链校验失败,提示前哈希不匹配

现象:昨天能跑的链,今天python cli.py verify报"块 3 前哈希不匹配",但代码一个字没改。

原因几乎都是序列化不一致。检查两处:timestamp是不是被某处写成了浮点,json.dump时有没有加sort_keys=True。Python 的json.dumps在字典键顺序不同的时候输出不同字符串,创世块如果是先用默认顺序写入,后来又用sort_keys读取重算,哈希必然对不上。

解决办法:所有涉及哈希计算的序列化统一走一个函数,参数固定sort_keys=True, separators=(",", ":"),不要各处手写。已经写坏的链数据,用python cli.py repair --rebuild-index从 JSON 重建索引并重算哈希,前提是区块内容本身没被改,只修序列化差异。

5.3 解密报 InvalidTag,密码明明是对的

现象:open_capsule抛cryptography.exceptions.InvalidTag,输了三遍密码都一样。

原因有三类。第一,AAD 不一致。封装时unlock_at是整型,解密时如果从 JSON 读回来变成字符串,f"unlock_at={unlock_at}"拼出来的字节数组不同,认证直接失败。第二,nonce 或 salt 的十六进制串大小写、空格不对导致bytes.fromhex解出不同字节。第三,封装和解封用了不同的 scrypt 参数,派生出的密钥自然不同。

解决办法:封和解两端共用同一个build_aad()函数,参数先int()强制类型;meta字段 hex 统一小写、去空格;scrypt 的n/r/p写进meta里存着,而不是从配置读。

def build_aad(unlock_at) -> bytes: return f"unlock_at={int(unlock_at)}".encode()

5.4 多节点同步后分叉,两边都认为自己是对的

现象:两个节点跑了一段时间,各自/chain返回的最新区块不同,重启任何一个都自愈不了。

原因是选链规则用了长度比较而不是累计工作量比较。难度调低之后,短链可能比长链难挖得多,单看长度会选错。另一个隐性原因是同步时没校验对端区块的工作量,直接接收了低难度块。

解决办法:把resolve逻辑改成比累计难度:

def chain_work(blocks: list[Block]) -> int: return sum(16 ** b.difficulty for b in blocks)

接收对端链之前先跑一遍verify_chain,并要求chain_work(remote) > chain_work(local),两个条件都满足才替换。16 ** difficulty是期望尝试次数的近似,够用,不必引入精确的难度目标值计算。

5.5 时间胶囊被提前打开的三个漏洞

现象:设了三年后解锁,结果第二天就解开了。

第一个漏洞,明文密码或密钥被写进交易meta备注里。虽然看着像"调试用",一旦上链永久可见。第二个漏洞,客户端只比对本地time.time(),系统时间一改立刻放行。第三个漏洞,节点没校验unlock_at的合理性,允许写入一个过去的时间戳。

对应三条修法:meta白名单校验,只允许alg/nonce/salt/filename四个键,其他一律拒收;解锁时间统一取median_time_past,不信任本地时钟;提交交易时空节点检查unlock_at > median_time_past() + 60,防止写入刚过期的时间。

注意:这三条只堵住软层面的漏洞。真要防有算力的人,得靠顺序锁抬成本,或者干脆把密钥交给第三方托管,链上只放解锁凭证。安全边界想清楚再动手,别指望一个开关解决所有问题。

6. 二次开发:把单机玩具改成能用的三处切入点

代码跑通之后想往下走,我建议按改造收益从高到低排。第一处,存储换成 IPFS 或者对象存储,链上只放 CID,cipher字段改成cid,meta里加size和sha256做完整性校验。这样区块体积能压到几百字节,同步速度差别肉眼可见。

第二处,共识从 PoW 换成轻量的 PoA 或轮值出块。演示项目里挖矿纯粹为了秀链路,实际部署时本地节点挖矿只会白白烧电。做法是保留Block.mine接口,把difficulty固定为 0,加一个validator_set白名单,出块权按轮次分配,verify_chain里多一道签名校验。

第三处,把顺序锁换成真正的 VDF 或者接入可信时间源做双重校验。这一步投入大,但能把"可信时间"从社交共识升级成密码学保证。我一般先在timelock.py里加一个可插拔策略接口:

class TimeLockStrategy: def compute_key(self, seed: bytes) -> bytes: ... def verify(self, seed: bytes, key: bytes) -> bool: ...

然后写IterHashLock、ShamirLock、RemoteTSALock三个实现,配置里切换。接口一立,后面不管加什么策略都不用动业务代码。

验证方法也简单:每次改完跑一遍pytest tests/ -k "seal or verify",再手动python cli.py verify一次,最后起两个节点同步一轮,看/chain是否一致。三关都过,才算改对。

最后说个教训。早些年我做一个类似的存证小工具,图省事把序列化逻辑散在四个文件里,各写各的json.dumps,上线三个月后加个字段,整条链的哈希全变了,历史数据一条都验不过,只能写脚本逐块重算。从那以后我每次动靠近哈希计算的代码,都强制先跑一遍全链校验再提交。这类项目看着小,但哈希和序列化这种地基一旦松动,修起来比从头写还费劲。希望帮到你。

本文还有配套的精品资源,点击获取