教程区块链区块链基础知识chunk_46_ch16_mini_pt2第16章 从零实现一个迷你区块链(中)— 交易签名、P2P网络与HTTP API

本页目录

本章范围:16.4 交易、余额模型与签名验证 → 16.5 简易 P2P 网络:节点发现与广播 → 16.6 构建 HTTP API 与区块链浏览器。

目标:在前三节(16.1–16.3 数据结构、哈希链、PoW 挖矿)的基础上,叠加交易经济层去中心化通信层用户交互层,三节合一形成一个带交易能力、可多节点组网的迷你区块链系统。

前三节我们已经有了一个能独立挖矿的单节点区块链。但真实的区块链之所以是"价值网络",是因为它允许参与者之间转移资产,并且多个节点共同维护一条账本。本节将分三步完成这个跃迁:

  1. 16.4 — 引入余额模型与椭圆曲线签名,让交易具备经济含义和身份认证;
  2. 16.5 — 用 HTTP 端点模拟 P2P 通信,让多个节点发现彼此、广播交易和区块;
  3. 16.6 — 构建 REST API 与简易前端,提供浏览器访问和交互能力。

16.4 交易、余额模型与签名验证

16.4.1 为什么需要余额模型?

第 15 章讨论的 UTXO(未花费交易输出)模型 是比特币的选择——每笔交易消耗旧的 UTXO、创建新的 UTXO,像一个"硬币拆零"的过程。UTXO 模型隐私性更好、并行度高,但状态查询比较复杂:要知道某个地址的余额,必须扫描全链上所有未花费的输出。

我们的迷你链选择 余额模型(Account Model),和以太坊一致——用一个全局字典 {address: balance} 记录每个地址的余额,查询 O(1) 完成。代价是必须保证交易按序执行,且需要 nonce 计数器防止重放攻击。

text
state = {
    "Alice": 100.0,
    "Bob":    0.0
}
nonces = {
    "Alice": 0,
    "Bob":   0
}

余额模型的状态转移约束可以写为:

state[tx.sender]tx.amount+tx.fee\texttt{state}[\texttt{tx.sender}] \geq \texttt{tx.amount} + \texttt{tx.fee}
state[tx.sender]=state[tx.sender]tx.amount\texttt{state}'[\texttt{tx.sender}] = \texttt{state}[\texttt{tx.sender}] - \texttt{tx.amount}
state[tx.recipient]=state[tx.recipient]+tx.amount\texttt{state}'[\texttt{tx.recipient}] = \texttt{state}[\texttt{tx.recipient}] + \texttt{tx.amount}

16.4.2 交易的数据结构与哈希

每一笔交易需要携带发送方、接收方、金额、nonce 和签名,其中签名由发送方的私钥生成。我们在 16.1 节 Transaction 的基础上增加 noncesignature 两个字段:

python
import hashlib, json
from dataclasses import dataclass, asdict, field
from typing import Optional

@dataclass
class Transaction:
    sender: str
    recipient: str
    amount: float
    nonce: int = 0
    signature: str = ""

    def to_dict(self) -> dict:
        # 用有序 dict 保证序列化一致性,避免哈希波动
        return {
            "sender": self.sender,
            "recipient": self.recipient,
            "amount": self.amount,
            "nonce": self.nonce,
        }

    def hash(self) -> str:
        raw = json.dumps(self.to_dict(), sort_keys=True, separators=(",", ":"))
        return hashlib.sha256(raw.encode()).hexdigest()

交易哈希的计算方式为:

Hash(tx)=SHA256(JSONcanonical(tx))\text{Hash}(\text{tx}) = \text{SHA256}\Big(\text{JSON}_{\text{canonical}}(\text{tx})\Big)

其中 JSON_canonical 指的是通过 sort_keys=True 保证字段顺序一致、separators=(",", ":") 去除多余空白,确保同一笔交易在任何环境中计算出的哈希值完全相同。

16.4.3 使用 ecdsa 库签名和验证

Python 的 ecdsa 库提供了对椭圆曲线数字签名算法(ECDSA)的开箱支持。我们使用 SECP256k1 曲线——也就是比特币和以太坊所用的标准曲线。

python
from ecdsa import SigningKey, VerifyingKey, SECP256k1
from ecdsa.util import sigencode_der, sigdecode_der

def generate_keypair():
    sk = SigningKey.generate(curve=SECP256k1)
    vk = sk.verifying_key
    return sk, vk

def sign_tx(sk: SigningKey, tx_hash: str) -> str:
    return sk.sign(tx_hash.encode(), sigencode=sigencode_der).hex()

def verify_tx(vk: VerifyingKey, tx_hash: str, signature_hex: str) -> bool:
    try:
        return vk.verify(
            bytes.fromhex(signature_hex),
            tx_hash.encode(),
            sigdecode=sigdecode_der,
        )
    except Exception:
        return False

签名验证的数学本质(仅作展示,不要求读者实现底层运算):

Verify(PK,H(tx),σ){true,false}\text{Verify}(\text{PK}, \text{H}(\text{tx}), \sigma) \in \{\text{true}, \text{false}\}

验证者使用发送方的公钥 PK,对交易哈希 H(tx) 和签名 σ 执行 ECDSA 验签算法,输出布尔值。若验证通过,说明签名者确实持有与 PK 对应的私钥,且交易内容未被篡改。

16.4.4 交易的验证规则

一个交易在进入内存池(mempool)之前必须通过全部验证规则。我们定义统一的验证函数:

python
def validate_transaction(tx: Transaction, state: dict, nonces: dict) -> bool:
    # 1. 余额足够
    if state.get(tx.sender, 0) < tx.amount:
        return False

    # 2. nonce 严格递增(不允许跳号)
    if nonces.get(tx.sender, 0) != tx.nonce:
        return False

    # 3. 签名验证(要求 sender 是十六进制公钥地址)
    try:
        vk = VerifyingKey.from_string(bytes.fromhex(tx.sender), curve=SECP256k1)
        tx_hash = tx.hash()
        if not verify_tx(vk, tx_hash, tx.signature):
            return False
    except Exception:
        return False

    return True
flowchart LR
    A["用户创建交易<br/>sender, recipient, amount, nonce"] --> B["发送方用私钥<br/>对交易哈希签名"]
    B --> C["交易广播到节点"]
    C --> D{"验证:<br/>余额 ≥ 金额?<br/>nonce 正确?<br/>签名有效?"}
    D -- 全部通过 --> E["加入 mempool<br/>等待打包"]
    D -- 任意失败 --> F["交易被丢弃"]
    E --> G["矿工从 mempool<br/>取出交易打包进区块"]
    G --> H["更新全局状态<br/>sender -= amount<br/>recipient += amount<br/>nonce += 1"]
    H --> I["新区块广播到<br/>其他节点"]

16.4.5 状态更新与内存池

验证通过后,执行原子状态更新——要么全部生效,要么全部回滚:

python
def apply_transaction(tx: Transaction, state: dict, nonces: dict):
    if not validate_transaction(tx, state, nonces):
        return False
    state[tx.sender] = state.get(tx.sender, 0) - tx.amount
    state[tx.recipient] = state.get(tx.recipient, 0) + tx.amount
    nonces[tx.sender] = tx.nonce + 1
    return True

内存池(mempool)就是一个暂存未打包交易的列表。挖矿时一次性取出所有有效交易打包进区块。注意创世块挖矿奖励交易(coinbase) 不需要签名验证——coinbase 交易没有发送方,纯由协议产出:

python
def create_coinbase_tx(miner_address: str, reward: float = 50.0) -> Transaction:
    return Transaction(
        sender="COINBASE",
        recipient=miner_address,
        amount=reward,
        nonce=0,
        signature="",
    )

16.4 要点总结

  • 余额模型用 {address: balance} 字典维护状态,查询 O(1),但交易必须严格串行并按 nonce 递增。
  • 交易哈希对规范化的 JSON 字符串做 SHA-256,确保跨节点哈希一致。
  • ECDSA(SECP256k1)签名保证交易身份认证与完整性;验证通过 VerifyingKey.verify() 完成。
  • 交易进入 mempool 前需通过余额、nonce、签名三项检查;coinbase 交易免验证。

16.5 简易 P2P 网络:节点发现与广播

16.5.1 网络架构思路

真正的 P2P 网络(如比特币的 addr 消息传播、Kademlia DHT)需要实现底层 TCP 长连接和复杂的节点路由表。我们的迷你链选择用 HTTP 请求模拟 P2P 通信——每个节点运行一个 Flask(或 FastAPI)HTTP 服务器,同时通过 requests 库主动调用其他节点的接口。这种设计在教学上非常直观:读者只需要理解"节点 A 向节点 B 发了一个 POST 请求"即可。

每个节点维护:

python
peers = set()          # 已知邻居的 URL,如 {"http://localhost:5001", ...}
blockchain = []        # 本地区块链副本
state = {}             # 全局余额状态
nonces = {}            # 地址 nonce 计数器
mempool = []           # 待打包交易列表
node_id = str(uuid.uuid4())[:8]

16.5.2 Bootstrap 节点与邻居发现

新节点启动时至少需要知道一个种子(bootstrap)节点的地址。注册过程非常简单:

python
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/nodes/register", methods=["POST"])
def register_node():
    data = request.get_json()
    node_url = data.get("url")
    if node_url and node_url not in peers:
        peers.add(node_url)
    return jsonify({"message": "registered", "peers": list(peers)})

@app.route("/nodes", methods=["GET"])
def get_peers():
    return jsonify({"peers": list(peers)})

新节点首次启动时,向 bootstrap 发送 POST /nodes/register,bootstrap 返回当前已知的邻居列表。之后新节点主动向每个邻居再发一次注册,整个网络就建立了连接。

16.5.3 消息类型与协议格式

我们定义四种核心消息类型,统一封装为 JSON:

消息类型方向说明
NEW_BLOCK广播挖到新区块后向所有 peer 推送
NEW_TRANSACTION广播收到新交易后向所有 peer 转发
REQUEST_CHAIN点对点请求对方全链
RESPONSE_CHAIN点对点返回完整的区块链

广播函数遍历 peers 集合,用 requests.post 并发推送:

python
import requests
from concurrent.futures import ThreadPoolExecutor

def broadcast(msg_type: str, data: dict):
    payload = {"type": msg_type, "data": data, "timestamp": time.time()}
    with ThreadPoolExecutor(max_workers=10) as pool:
        for peer in peers:
            pool.submit(requests.post, f"{peer}/p2p", json=payload, timeout=2)

@app.route("/p2p", methods=["POST"])
def handle_p2p_message():
    msg = request.get_json()
    msg_type = msg["type"]
    data = msg["data"]

    if msg_type == "NEW_TRANSACTION":
        tx = Transaction(**data)
        if validate_transaction(tx, state, nonces):
            mempool.append(tx)
            broadcast("NEW_TRANSACTION", data)   # 继续转发

    elif msg_type == "NEW_BLOCK":
        handle_incoming_block(data)

    elif msg_type == "REQUEST_CHAIN":
        return jsonify({"chain": [b.__dict__ for b in blockchain], "length": len(blockchain)})

    elif msg_type == "RESPONSE_CHAIN":
        remote_chain = data["chain"]
        resolve_conflicts(remote_chain)

    return jsonify({"status": "ok"})
sequenceDiagram
    participant A as 节点 A (矿工)
    participant B as 节点 B
    participant C as 节点 C
    A->>A: 挖矿成功,得到新区块
    A->>B: POST /p2p (NEW_BLOCK)
    A->>C: POST /p2p (NEW_BLOCK)
    B->>B: 验证区块 (PoW + 前序哈希 + 交易)
    C->>C: 验证区块
    B-->>B: 验证通过,追加到本地链
    C-->>C: 验证通过,追加到本地链
    Note over B,C: 双方链长一致,网络达到共识

16.5.4 最长链规则与冲突解决

区块链最根本的共识规则是最长链规则(Longest Chain Rule):当节点收到一个与本地链冲突的区块时,如果传来的链更长且有效,就用它替换本地链。

其数学形式为:

C>CValid(C)=true    CC|\mathcal{C}'| > |\mathcal{C}| \land \text{Valid}(\mathcal{C}') = \text{true} \implies \mathcal{C} \leftarrow \mathcal{C}'

收到新区块时有三种分叉情况:

flowchart TD
    RX["收到 NEW_BLOCK"] --> CHECK{"block.index<br/>与本地链关系?"}
    CHECK -->|"index == len(local_chain)"| APPEND["验证通过则直接追加"]
    CHECK -->|"index > len(local_chain)"| PULL["发送 REQUEST_CHAIN<br/>拉取完整远程链"]
    CHECK -->|"index < len(local_chain)"| IGNORE["丢弃:已更新"]
    APPEND --> DONE["更新状态"]
    PULL --> COMPARE{"远程链更长<br/>且有效?"}
    COMPARE -->|是| REPLACE["替换本地链<br/>回滚并重放交易"]
    COMPARE -->|否| STAY["保留本地链"]
    REPLACE --> DONE
python
def resolve_conflicts(remote_chain: list) -> bool:
    # 只接受严格更长的有效链
    if len(remote_chain) <= len(blockchain):
        return False

    # 验证远程链的 PoW 与前序哈希
    for i in range(1, len(remote_chain)):
        prev = remote_chain[i - 1]
        cur = remote_chain[i]
        cur_hash = calculate_block_hash(cur)
        if cur["prev_hash"] != calculate_block_hash(prev):
            return False
        if not cur_hash.startswith("0" * difficulty):
            return False

    # 替换本地链,重新计算状态
    blockchain.clear()
    blockchain.extend(remote_chain)
    rebuild_state()
    return True

def rebuild_state():
    """从创世块开始逐块重放交易,重建 state 和 nonces"""
    state.clear()
    nonces.clear()
    for block in blockchain:
        for tx_data in block["transactions"]:
            tx = Transaction(**tx_data)
            apply_transaction(tx, state, nonces)

16.5.5 并发与一致性问题

HTTP 模拟 P2P 天然存在竞态:两个节点可能同时出块,产生同高度分叉;网络延迟可能导致 NEW_BLOCKREQUEST_CHAIN 交叉到达。我们的应对策略:

  • blockchainstatemempool 等全局可变数据使用 threading.Lock() 保护;
  • 同高度分叉采用"先到先用"原则——先收到的区块保留,后收到的丢弃,等待下一次出块后通过最长链规则自然收敛;
  • 暂不考虑女巫攻击(Sybil Attack)和 51% 攻击,这些内容将在下一章深入讨论。

16.5 要点总结

  • 迷你链用 HTTP 端点(Flask)模拟 P2P 通信,每个节点既是服务器也是客户端。
  • Bootstrap 节点提供初始邻居发现;peers 集合维护已知节点列表。
  • 四种核心消息(NEW_BLOCK / NEW_TRANSACTION / REQUEST_CHAIN / RESPONSE_CHAIN)构成完整的点对点协议。
  • 最长链规则 + 全链验证实现冲突解决;同高度分叉"先到先用",等待下一轮出块自然收敛。
  • 全局数据需加锁,防止 HTTP 并发请求导致状态不一致。

16.6 构建 HTTP API 与区块链浏览器

16.6.1 RESTful API 端点设计

在 16.5 的 Flask 节点之上,我们暴露一组 REST 端点,供钱包客户端、命令行工具和前端浏览器调用:

flowchart TB
    CLIENT["浏览器 / curl"] --> API["Flask REST API<br/>localhost:5000"]

    API -->|GET /blocks| BC["返回整条区块链"]
    API -->|GET /blocks/&lt;index&gt;| BLOCK["返回单个区块详情"]
    API -->|POST /transactions/new| TX["验证后加入 mempool<br/>并广播"]
    API -->|GET /mine| MINE["执行 PoW 挖矿<br/>打包交易 + 广播区块"]
    API -->|GET /balance/&lt;addr&gt;| BAL["从 state 查询余额"]
    API -->|GET /chain/validate| VAL["遍历验证全链"]
    API -->|POST /nodes/register| PEERS["注册邻居节点"]
    API -->|GET /peers| LIST["列出所有邻居"]

    TX --> BROADCAST["广播 NEW_TRANSACTION"]
    MINE --> BROADCAST2["广播 NEW_BLOCK"]

16.6.2 核心接口实现

以下是与区块和交易相关的三个核心路由:

python
@app.route("/blocks", methods=["GET"])
def get_blocks():
    return jsonify([b.__dict__ for b in blockchain])

@app.route("/blocks/<int:index>", methods=["GET"])
def get_block(index):
    if 0 <= index < len(blockchain):
        return jsonify(blockchain[index].__dict__)
    return jsonify({"error": "block not found"}), 404

@app.route("/transactions/new", methods=["POST"])
def new_transaction():
    data = request.get_json()
    tx = Transaction(
        sender=data["sender"],
        recipient=data["recipient"],
        amount=data["amount"],
        nonce=data.get("nonce", 0),
        signature=data.get("signature", ""),
    )
    if validate_transaction(tx, state, nonces):
        mempool.append(tx)
        broadcast("NEW_TRANSACTION", tx.to_dict())
        return jsonify({"message": "transaction accepted", "index": len(blockchain)})
    return jsonify({"error": "invalid transaction"}), 400

@app.route("/mine", methods=["GET", "POST"])
def mine():
    if not mempool:
        # 没有用户交易时,至少包含 coinbase 奖励
        reward_tx = create_coinbase_tx(node_id)
        mempool.append(reward_tx)

    new_block = mine_block(list(mempool), blockchain, difficulty)
    if new_block:
        mempool.clear()
        broadcast("NEW_BLOCK", new_block.__dict__)
        return jsonify(new_block.__dict__)
    return jsonify({"error": "mining failed"}), 500

@app.route("/balance/<address>", methods=["GET"])
def get_balance(address):
    return jsonify({"address": address, "balance": state.get(address, 0)})

挖矿难度与目标值的数学关系延续 16.3 的定义:

Target=22568×D\text{Target} = 2^{256 - 8 \times D}

其中 D 是难度(前导零个数)。每增加 1 个前导零,目标值缩小为原来的 1/161/16

16.6.3 最小前端区块链浏览器

一个纯 HTML + JavaScript 的单页应用即可提供完整的浏览体验。我们将其放在 static/index.html,Flask 自动提供静态文件服务。

html
<!DOCTYPE html>
<html>
<head><title>Mini Blockchain Explorer</title></head>
<body>
  <h1>🔗 Mini Blockchain Explorer</h1>
  <div id="stats">
    <span id="height">Height: --</span>
    <span id="txcount">Txs: --</span>
    <span id="peers">Peers: --</span>
  </div>

  <h2>发起交易</h2>
  <form id="txForm">
    <input name="sender" placeholder="发送方公钥(hex)" size="50" />
    <input name="recipient" placeholder="接收方公钥(hex)" size="50" />
    <input name="amount" type="number" step="0.01" placeholder="金额" />
    <button type="submit">发送交易</button>
  </form>

  <h2>区块链</h2>
  <table border="1" id="chainTable">
    <thead>
      <tr>
        <th>#</th><th>Time</th><th>Miner</th><th>Txs</th><th>Hash (前 16 位)</th>
      </tr>
    </thead>
    <tbody id="chainBody"></tbody>
  </table>

  <script>
    const API = "/blocks";

    function renderBlocks(blocks) {
      const body = document.getElementById("chainBody");
      body.innerHTML = blocks.map(b => `
        <tr>
          <td>${b.index}</td>
          <td>${new Date(b.timestamp * 1000).toLocaleTimeString()}</td>
          <td>${(b.miner || "?").slice(0, 10)}...</td>
          <td>${b.transactions?.length || 0}</td>
          <td>${b.hash.slice(0, 16)}</td>
        </tr>
      `).join("");
      document.getElementById("height").textContent = `Height: ${blocks.length - 1}`;
    }

    function fetchChain() {
      fetch(API).then(r => r.json()).then(renderBlocks).catch(console.error);
    }

    document.getElementById("txForm").addEventListener("submit", e => {
      e.preventDefault();
      const fd = new FormData(e.target);
      fetch("/transactions/new", {
        method: "POST",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify(Object.fromEntries(fd)),
      }).then(r => r.json()).then(console.log);
    });

    setInterval(fetchChain, 3000);
    fetchChain();
  </script>
</body>
</html>

16.6.4 多节点本地测试网演示

在一个机器上启动 3 个不同端口的节点,使用同一个 bootstrap 地址注册:

bash
# 终端 1:Bootstrap 节点
python node.py --port 5000 --bootstrap http://localhost:5000

# 终端 2:节点 B
python node.py --port 5001 --bootstrap http://localhost:5000

# 终端 3:节点 C
python node.py --port 5002 --bootstrap http://localhost:5000

演示流程

  1. 节点 A(5000)调用 /transactions/new 从 Alice 向 Bob 转账 10 个币;
  2. 节点 A 广播 NEW_TRANSACTION 到节点 B、C,三方的 mempool 同步更新;
  3. 节点 C 调用 /mine 打包区块,成功后广播 NEW_BLOCK
  4. 节点 A、B 验证并追加,三条链保持一致。
python
# network_sim.py —— 多进程一键启动示例
import subprocess, time

nodes = [
    ("Alice", 5000),
    ("Bob",   5001),
    ("Carol", 5002),
]
procs = []
for name, port in nodes:
    p = subprocess.Popen(["python", "node.py",
        "--port", str(port),
        "--bootstrap", "http://localhost:5000",
        "--name", name,
    ])
    procs.append(p)
    time.sleep(0.5)

print("网络启动完毕。按 Enter 关闭所有节点。")
input()
for p in procs:
    p.kill()

16.6.5 终态验证:整条链路跑通

curl 模拟一次完整交易流程:

bash
# 1. 查询余额
curl http://localhost:5000/balance/Alice

# 2. 生成密钥对并签名(Python 伪代码,实际用编程方式调用)
# sk, vk = generate_keypair()
# tx = Transaction(sender=vk.hex(), recipient="Bob", amount=5, nonce=0)
# sig = sign_tx(sk, tx.hash())
# tx.signature = sig

# 3. 发送签名交易
curl -X POST http://localhost:5000/transactions/new \
  -H "Content-Type: application/json" \
  -d '{"sender":"<公钥hex>","recipient":"Bob","amount":5,"nonce":0,"signature":"<签名hex>"}'

# 4. 触发挖矿
curl http://localhost:5000/mine

# 5. 检查区块和余额
curl http://localhost:5000/blocks/1
curl http://localhost:5000/balance/Bob

16.6 要点总结

  • REST API 提供区块链、交易、挖矿、余额查询等标准端点,7 个路由覆盖全部核心功能。
  • 前端区块链浏览器用纯 HTML/JS 实现,fetch() + setInterval 每 3 秒自动刷新。
  • 多节点测试网通过 --port--bootstrap 参数一键启动,支持本地三节点联调演示。
  • 终态验证范式:签名交易 → 广播 → 打包 → 同步,完整模拟了真实区块链的交易生命周期。

本节核心认知

  1. 余额模型 + ECDSA 签名 = 数字资产所有权证明。余额模型让状态查询变得简单,而椭圆曲线签名确保只有私钥持有者才能转移其资产。这两者共同构成了区块链经济层的基础——没有签名的交易只是数据,加上签名才有了"资产转移"的法律效力。
  1. HTTP 模拟 P2P 是教学正确但生产不可用的折中方案。迷你链用 Flask 端点模拟广播和节点发现,让读者在单机上就能感受到去中心化网络的分叉和收敛过程。但真实的 P2P 需要处理 NAT 穿透、节点表路由、gossip 协议、加密传输等复杂问题——这是从"演示原型"到"生产系统"的必经鸿沟。
  1. REST API + 前端浏览器让看不见的区块链变得可见。命令行和日志只能展示区块链的数据结构,而浏览器 UI 能将哈希、难度、交易流程以图形化方式呈现,极大地降低理解门槛。同时,API 层也为后续章节实现"钱包 SDK"和"简单智能合约"提供了通用的交互接口。

参考与附录

  1. Python ecdsa 库文档:https://github.com/tlsfuzn/python-ecdsa
  2. Flask 官方文档:https://flask.palletsprojects.com/
  3. Nakamoto, S. (2008). Bitcoin: A Peer-to-Peer Electronic Cash System. Sections 4–5.
  4. Wood, G. (2014). Ethereum: A Secure Decentralised Generalised Transaction Ledger. Yellow Paper.
  5. Antonopoulos, A. M. (2017). Mastering Bitcoin (2nd ed.), Chapter 6: The Transaction Lifecycle.

代码文件组织(与大纲对应)

| 文件 | 说明 |

|------|------|

| transaction.py | Transaction 类 + ecdsa 签名/验签 + 生成密钥对 |

| state.py | 全局状态字典 + validate_transaction() / apply_transaction() |

| p2p.py | Flask 节点骨架 + 广播函数 + 邻居管理 + 链冲突解决 |

| api.py | 全部 REST 端点路由 + 前端静态文件服务 |

| frontend/index.html | 最小 HTML/JS 区块链浏览器 |

| network_sim.py | 多节点本地测试网一键启动脚本 |

评论

0

评论加载中…

发表评论

0/2000