ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Substrate区块链开发框架:用Rust构建自定义链的核心架构与实操

Substrate区块链开发框架:用Rust构建自定义链的核心架构与实操 Substrate 这个词最近在开发者圈子里热度一直没下去。不少朋友跑来问我它到底是个什么东西是一条链吗还是一个框架我直接用一句话回答Substrate 是一个用 Rust 写的、用来构建自定义区块链的模块化框架。你可以用它从零搭一条完全属于自己的链而不需要去 fork 比特币或者以太坊的代码再改得面目全非。这篇文章我会从它到底是什么讲起把核心架构、关键概念、实操路线和我实际踩过的坑一起捋清楚希望能帮准备入坑或者已经在坑边的朋友少走弯路。这篇文章适合三类人一是想做一条独立链但不知道怎么下手的开发者二是对区块链底层机制好奇、想搞懂 Runtime 和智能合约有什么区别的技术爱好者三是已经在看 Substrate 文档但被 FRAME、Pallet、Wasm 这些名词搞得头晕的初学者。我会尽量把概念讲得通俗同时保证足够的技术深度毕竟这东西本身就不算一个轻量级玩具。1. Substrate 解决的根子问题为什么造链这么难1.1 从复制代码改参数到搭积木在 Substrate 出现之前想要一条自己的链主流路径只有两条要么从比特币或以太坊代码库 fork 出来改共识参数、改代币总量、改交易格式然后祈祷自己改对了一切要么干脆用以太坊智能合约发一个 ERC-20 代币但这根本不叫一条链只是一个跑在别人链上的合约。fork 这条路的问题在于比特币和以太坊的代码库是为某一条链设计的不是为任意一条链设计的。你想改一个存储结构、换一种共识算法、加一个自定义操作往往要动到核心代码而核心代码的耦合度极高改一处崩三处。更麻烦的是这些老代码库已经积累了十几年的历史包袱安全补丁和功能升级经常互相打架。我自己就见过一个 fork 以太坊的项目组改了几行 gas 逻辑导致整个区块验证器出错排查了整整两周。Substrate 的思路完全不同。它先把一条区块链拆成一堆标准零件——存储、共识、交易池、网络同步、Runtime 执行环境——然后把这些零件做成模块化的框架。开发者不需要从零实现区块链的底层协议只需要关注两个问题我的链要有什么业务逻辑我的链要选什么底层配置前者通过写 Pallet模块来解决后者通过配置项来解决。这就是搭积木和从零烧砖的区别。1.2 核心架构拆解Client 和 Runtime 的双人舞Substrate 的架构分两层理解这两层后面所有概念都好懂了。Client客户端层负责区块链的物理部分包括网络同步、交易广播、区块打包、数据库存储、共识引擎如 Aura、Grandpa这些跟业务无关的底层功能。这一层可以理解为电脑的操作系统——你不需要关心它怎么调度进程只需要知道它能跑程序。Runtime运行时层负责链的业务部分也就是状态转换函数。链上每个区块执行后状态从 A 变成 B这个怎么变的逻辑全部写在 Runtime 里。包括余额怎么转移、投票怎么计数、某个模块的参数怎么更新都在这一层。这两层的关系很微妙。Client 负责把交易收进来、验证、打包但它并不知道这些交易在业务上意味着什么Runtime 知道自己所有业务规则但它不关心交易是以什么网络方式传进来的。两者通过一组固定的接口通信这就是 Substrate 的 Runtime API。一个关键点是Runtime 会被编译成两种形式一种是本地原生的二进制native另一种是 WebAssemblyWasm。为什么搞成两份因为 Wasm 版本可以被存储在链上节点可以通过链上 Wasm 来升级 Runtime 逻辑而不需要停链、分叉、让所有节点手动更新客户端。这就是 Substrate 引以为傲的无分叉升级能力。后面我会专门讲这个机制的细节和坑。1.3 为什么大家都说它是Polkadot 的爸爸这里有个经常混淆的点。很多人以为 Substrate 是 Polkadot 的一部分或者 Polkadot 是 Substrate 的工具。实际上关系是反过来的Polkadot 本身是用 Substrate 构建的一条链。Polkadot 需要解决的是异构多链互联的问题它自己作为中继链连接一堆平行链。而构建这么多条链每条都从头写显然不现实所以 Parity 团队把构建链的公共部分提取出来做成了 Substrate 框架。Polkadot 相当于 Substrate 的第一个旗舰产品用框架搭出来的最复杂的链。这带来的好处非常实际Substrate 的代码升级、Bug 修复会先在 Polkadot 这条最艰难的链上经受考验然后沉淀回框架本身。所以你用 Substrate 搭自己的链等于站在了 Polkadot 团队踩过的坑的肩膀上。很多安全意识强的项目方选择 Substrate 而不是 fork 以太坊很大程度上就是看中了这一点。2. FRAME 和 Pallet你的链的业务零件库2.1 用生活化类比理解 FRAME 是什么如果 Substrate 是一台手机那 FRAMEFramework for Runtime Aggregation of Modular Entities就是这台手机的应用商店系统。手机本身的硬件和操作系统Client已经就位但每个用户需要不同的应用组合——有人要相机有人要支付工具有人要社交功能。FRAME 就是让你能把这些应用装进手机里、并管理它们之间关系的那个系统。在技术上FRAME 是 Substrate 提供的一套用于构建 Runtime 的工具集和规范它定义了一组宏macros和 trait让开发者可以用声明式的方式描述我的链有哪些模块、每个模块有哪些存储项、哪些函数可以被外部调用、哪些事件会触发通知。最常用的三个宏就是construct_runtime!把各个模块组装成整体、decl_storage!声明链上存储、以及decl_event!声明事件。新手刚接触会觉得很别扭因为 Rust 的宏语法看起来不像普通函数调用。但习惯之后你会发现这套声明式写法极大地减少了样板代码。2.2 Pallet 怎么理解把业务逻辑打包成积木Pallet 是 FRAME 体系下的一个模块单元。每个 Pallet 封装一组相关业务逻辑。比如你要做一条有投票功能的链就可以写一个votesPallet里面包含投票的存储结构、投票的操作函数、投票结果的查询接口。Substrate 官方仓库里已经有一个庞大的 Pallet 库包括pallet_balances账户余额管理是最基础的模块几乎每条链都要用pallet_governance链上治理包括公投、议会、技术委员会等pallet_stakingPoS 质押和验证人机制pallet_assets资产发行与管理pallet_contracts智能合约执行环境类似以太坊虚拟机我刚开始学的时候有个误区以为写一条链必须把这些 Pallet 全用上。其实完全不是。Pallet 是可选的、独立的。你可以只选balances加一个自定义的业务 Pallet就能跑通一条最简单的链。这也让 Substrate 的学习曲线比想象中友好不需要先搞懂全部模块只需要装配你需要的几个就行。2.3 写 Pallet 的本质状态转换 权限校验 事件通知其实写一个 Pallet 的思维模式非常固定本质就是三步定义存储结构、定义可调用函数、定义事件。存储结构决定链上持久化什么。在 Substrate 里存储项是全局的不是合约里那种mapping的概念而是直接挂在链状态上。比如一个简单的计数器 Pallet#[pallet::config] pub trait Config: frame_system::Config { type RuntimeEvent: FromEventSelf IsTypeSelf as frame_system::Config::RuntimeEvent; } #[pallet::pallet] pub struct PalletT(_); #[pallet::storage] #[pallet::getter(fn counter_value)] pub type CounterValueT StorageValue_, u32, ValueQuery;这里声明了一个存储项CounterValue类型是StorageValue存的是一整个u32。对应地如果要存某个账户对应的值就用StorageMap类似哈希表。然后定义可调用函数#[pallet::call] implT: Config PalletT { #[pallet::weight(10_000)] pub fn increment(origin: OriginForT) - DispatchResult { let _who ensure_signed(origin)?; let value Self::counter_value(); CounterValueT::put(value 1); Ok(()) } }这个函数做了三件事校验调用者是已签名的账户、读取当前计数、加一后写回存储。就这么简单。origin是 Substrate 特有的参数代表调用来源——是用户链上签名账户、还是根权限治理决策、还是国库账户。每种来源能调用的函数不同这就是权限控制的基础。最后定义事件#[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum EventT: Config { CounterIncremented(u32), }事件是用来通知外部世界链上发生了什么事。它会被记录在区块的日志里前端可以用订阅接口监听。我的经验是所有重要的状态变更都最好发一个事件。虽然技术上不发事件也能跑但事后链上数据分析和前端交互都会变得很痛苦。2.4 为什么 Substrate 的存储比 Solidity 的 storage 更好理解如果你学过快链开发这里有个对比会很有意思。Solidity 合约里每个合约有自己的 storage 空间数据的作用域被限定在合约内而 Substrate 里所有 Pallet 的存储项都是链级的共享同一个世界状态。这意味着不同 Pallet 之间可以直接通过 getter 互相读取数据前提是知道存储键的生成规则不需要经过跨合约调用。这种设计更贴近区块链的世界计算机比喻——整个链就是一个巨大的状态机。当然代价是命名空间管理必须小心。Substrate 的存储键是Twox128(模块名) Twox64(存储项名)拼接出来的如果你给两个 Pallet 起了相同的名字就会产生键冲突。这个坑我踩过一次后面会在实战部分详细说。3. Runtime 升级为什么能无分叉Wasm 机制深度拆解3.1 传统链升级规则和 Substrate 的本质差异以太坊如果要改共识规则或者核心业务逻辑通常的做法是硬分叉所有节点运营者必须升级客户端升级不一致的节点就留在旧链上链因此分裂。这个过程协调成本极高社区吵上几个月都非常正常。Substrate 的升级逻辑是Runtime 逻辑本身作为链上状态被存储。节点在验证区块时优先读取存储在链上的 Wasm 版 Runtime 来执行状态转换。升级 Runtime 不再需要换客户端只需要提交一个特殊的set_code交易把新的 Wasm 代码发布到链上。下一个区块开始所有节点都会加载这份新 Wasm 执行。这个过程有点像你正在用的软件内置了一个自我更新机制——不是下载新安装包覆盖而是程序内部就把自己的执行逻辑替换掉了。听起来很黑科技但它能实现的前提是Wasm 作为执行格式有明确的确定性任何节点执行同一段 Wasm 代码得到完全一致的结果。3.2 原生执行和 Wasm 执行性能的取舍我刚才说 Runtime 会被编译成两份原生二进制和 Wasm。这里解释一下为什么要两份以及它们如何配合。执行状态转换时如果节点本地能跑原生代码它就会优先用原生代码去执行因为速度更快——Rust 编译的原生二进制直接走 CPU 指令没有中间层开销。但问题是不能只依赖原生代码因为如果两个节点用了不同版本的客户端代码比如一个升级了 Rust 依赖另一个没有同一交易很可能算出不同结果链就会分叉。Wasm 版本的存在就是为了兜底它完整地、确定性地记录在链上是真相。如果客户端之间不信任彼此的原生版本只需把 Wasm 执行一遍即可达成一致。实际运行中绝大多数区块靠原生执行来提速而验证人节点会定期做 Wasm 执行来保证正确性。这就是 Substrate 平衡性能和确定性的核心设计理解了这条你就明白了为什么 Runtime 升级不需要全网统一换软件——因为链上那份 Wasm 就是唯一事实源。3.3 升级时的状态兼容性问题惯性思维最容易翻车的地方无分叉升级听起来完美但实际上有个很要命的约束升级只能改未来不能改过去。也就是说新的 Runtime 代码可以改变下一个区块开始的状态转换逻辑但它不能自动迁移已经存在的存储数据。举个真实例子。假设你的链上存了用户的积分余额存储结构是BalanceOfT StorageMapAccountId, u64 余额后来业务扩展你想加一个字段记录积分等级。直接加一个存储项是没问题的新存储是空的需要另写迁移逻辑来初始化。但如果你想改已有存储项的类型比如把u64改成u128那就要小心了——存储键的编码规则变了旧数据可能读不出来。Substrate 没有自动的数据迁移工具虽然有try-runtime这样的迁移辅助框架每个项目必须在升级前自己写迁移代码在on_runtime_upgrade钩子里执行。这个钩子是 Runtime 升级后第一个区块前执行的特殊函数专门用来做存储迁移。我自己当时做积分系统升级时就是忽略了迁移步骤升级后用户的积分全部显示为 0吓得后背发凉。还好测试网先踩了坑主网没有受到影响。这里送大家一句话Runtime 升级的代码审查重点永远不是新逻辑对不对而是旧数据怎么迁。3.4 治理流程也是升级的一部分不只是技术问题Substrate 的无分叉升级能落地还有一个重要前提谁有权利提交set_code如果任何人都能升级 Runtime链的安全就完了。所以 Substrate 默认把升级权限控制交给治理机制——通常是一个DemocracyPallet通过公投来决定是否接受升级提案。一个典型的升级流程是开发者发布升级代码 → 技术委员会审查 → 提交公投 → 代币持有者投票 → 投票通过 → 执行升级。这套流程看似繁琐但它保证了链的去中心化和合法性。我见过有些项目图省事把升级权限直接留给一个根账户这在初期探索阶段可以理解但长期运营绝对不建议——一旦私钥泄露整个链的代码都会被接管后果不堪设想。4. 搭建第一条 Substrate 链的实操路线4.1 环境准备版本选型是第一道坎首先明确一个原则用稳定版别追最新。Substrate 的发布节奏是跟着 Polkadot 版本号走的例如polkadot-v0.9.42、polkadot-v1.0.0这样的标签。你会看到很多文档和教程用master分支我建议不要用。原因很简单master分支每天都在变今天能编译的代码下周可能因为依赖更新就编不过了。我见过太多新手卡在编译阶段就是因为用了开发分支。安装 Rust 环境curl --proto https --tlsv1.2 -sSf https://sh.rustup.rs | sh rustup default stable rustup update nightly rustup target add wasm32-unknown-unknown --toolchain nightly这三步很关键。wasm32-unknown-unknown缺了的话编译 Wasm 版 Runtime 会直接报错。然后安装substrate相关的命令行工具cargo install --git https://github.com/paritytech/substrate --tag polkadot-v0.9.42 substrate-cli不过说实话官方现在更推荐直接用substrate-node-template来起步而不是去编译完整的 Substrate 仓库。前者是精简模板只包含最基础的链配置后者是全功能源码编译要半小时起步新手容易在第一步就被劝退。4.2 用模板初始化项目substrate-node-template是一个标准的链模板包含balances、system、template示例 Pallet等基础模块。克隆后改个名字就是你的第一套链代码。git clone -b polkadot-v0.9.42 https://github.com/substrate-developer-hub/substrate-node-template cd substrate-node-template cargo build --release第一次编译会很慢快则十几分钟慢则半小时以上。你可以打开日志观察它主要是在编译几百个 crate 依赖。这里有个经验编译时开--release是必须的。开发模式编译出来的是 debug 版本性能极差而且和正式环境的 behavior 可能有差异。还有个小技巧可以设置CARGO_BUILD_JOBS来控制并行编译线程数避免内存不足。编译完成后运行./target/release/node-template --dev --tmp--dev表示开发模式自动给你预置一些初始账户--tmp表示数据不落盘每次启动都是全新链。这个模式下网络是单节点的不需要额外配置验证人。启动日志里会出现Local node identity is: ...同时在127.0.0.1:9944上开了 WebSocket 接口前端就能连了。4.3 把模板的示例 Pallet 改造成一个计数器链模板自带的templatePallet 已经实现了一个简单的提交数据功能我建议别急着删先在上面动手改。比如改成刚才提到过的计数器打开pallets/template/src/lib.rs把decl_storage!里的存储项改掉把decl_module!里的do_something改成increment。你会发现改动量非常小——这就是 Substrate 的开发体验框架替你处理了 90% 的底层逻辑你只需要集中修改业务层。改完重新编译、运行然后在 Polkadot JS Apps 界面连接到127.0.0.1:9944找到这个模板链就能看到你的自定义模块了。这里有一个常用的调试技巧Polkadot JS Apps 左侧导航的 Developer - Extrinsics 页面可以手动提交交易选择你的模块名加函数名点提交后去 Chain State 页面读取存储项观察状态是否变化。如果变化了你的第一条链就真正有业务了。4.4 前端交互用 Polkadot JS API 订阅链上状态链跑起来了怎么和它交互最常用的是polkadot/api这个 JavaScript 库。它通过 WebSocket 连接节点可以查询链上状态、提交交易、订阅事件。import { ApiPromise, WsProvider } from polkadot/api; const provider new WsProvider(ws://127.0.0.1:9944); const api await ApiPromise.create({ provider }); // 查询链上存储 const counter await api.query.template.counterValue(); console.log(current counter:, counter.toHuman()); // 订阅存储变化 await api.query.template.counterValue((value) { console.log(counter updated:, value.toHuman()); });注意api.query.template.counterValue里的template对应的是 Pallet 的实例名你在construct_runtime!里起的名字counterValue对应存储项的名字。这两者定义在哪边、调用时怎么拼是新手最容易搞混的。建议建索引贴一张速查表模块名 - Pallet 名在construct_runtime!中注册存储项名 -decl_storage!中定义的名字查询路径 模块名.存储项名。5. 踩坑实录我在 Substrate 开发中遇到的真实问题5.1 编译时间太长的解法增量构建与 sccacheSubstrate 项目的依赖极其庞大cargo build --release动辄 20 到 40 分钟非常消磨耐心。第一次全量编译没办法省但从第二次开始增量编译应该很快。如果你发现每次编译都还是很慢十有八九是环境变量或者代码结构导致缓存失效。我的经验是搭配sccache使用。它是一个编译缓存工具可以把共享依赖的编译结果缓存起来多个项目之间复用。cargo install sccache export RUSTC_WRAPPERsccache export SCCACHE_CACHE_SIZE10G设置之后第一次编译依然要全量但以后改动只影响你改动的 crate其他依赖直接命中缓存。实测可以把增量编译从 10 分钟压到 2 分钟以内。另外如果你开了多个终端窗口同时跑cargo build会引发文件锁竞争反而更慢——这点也是我试过才发现的。5.2 存储键冲突一个低级的严重坑前面提到过 Substrate 的存储键由模块名和存储项名的哈希拼接而成。问题出现在如果你把两个不同的 Pallet 在construct_runtime!里注册成了同一个名字或者同一个 Pallet 被注册了两次存储键就会冲突。冲突的后果相当隐蔽编译不会报错运行时数据互相覆盖但你很难排查到原因。我当时的场景是为了测试一个功能把同一个 Pallet 注册了两份实例想跑对比实验。结果 A 实例写入的数据B 实例能读出来完全串台了。排查了很久才发现是存储键冲突。这里给大家一个排查建议用链的存储查询工具比如 Substrate 的state_getStorageRPC 接口直接查询原始存储键把键值打出来对比。如果两个模块的前缀哈希一样就说明注册名冲突了。规范的做法是在construct_runtime!里给每个模块起不同且有意义的名字不要偷懒用Something、Module1这种。5.3 Runtime 升级时存储迁移的顺序问题前面讲了存储迁移要用on_runtime_upgrade钩子这里再展开说一个很容易忽略的细节迁移代码必须在新的 Runtime 里写但运行时机是在 Runtime 切换的那个瞬间。也就是说迁移代码执行时链上状态还是旧版本的数据格式但代码已经是新版本的了。如果你的迁移逻辑依赖某个新存储项而新存储项又要读取旧存储项的值那必须注意读取的顺序——先读完旧值再初始化新值最后删除旧存储如果需要。不要在新旧格式之间做假设。比如fn on_runtime_upgrade() - Weight { // 1. 读取旧存储此时链上还是旧格式 let old_value OldStorage::get(); // 2. 写入新存储 NewStorage::put(old_value); // 3. 删除旧存储 OldStorage::kill(); // ... }顺序反了轻则数据丢失重则 Runtime 直接 panic 导致区块卡住。而且这个 bug 在测试网往往测不出来因为你测试网的旧数据可能和主网格式不一致。最好把主网的一个区块状态快照拿到本地用try-runtime跑一遍升级流程再上主网。5.4 无分叉升级测试永远先跑 fork-off-substrateSubstrate 生态里有个工具叫fork-off-substrate它可以抓取主网最新区块状态生成一个本地继承该状态的测试链。我强烈建议任何 Runtime 升级都要先用这个工具在本地跑一遍。它会模拟主网在这个区块升级到新代码会发生什么如果迁移失败或者状态转换 panic你在本地就能发现。用法不算复杂先跑一个主网节点然后fork-off-substrate --source ws地址 --ws-port 9945在新端口的链上用刚才讲过的set_code提交升级。升级后跑几个交易检查状态是否符合预期。确认没问题再到真实的环境走公投流程。这套流程虽然多花半小时但对比主网出事故的代价这半小时简直是性价比最高的投资。6. 学习路径和资源取舍怎么从入门到能干活6.1 我的推荐路线先跑通再补原理最后读源码根据我带过几个新人的经验最有效率的路线是这样的第一步先把substrate-node-template跑通。不要看太多文档先编译运行在 Polkadot JS 上多看几眼存储查询和交易提交建立链是怎么跑起来的直观感受。这个阶段的目标是消除恐惧别让编译和环境问题把心态搞崩。第二步动手改模板里的 Pallet。按照官方教程Substrate in Action或者substrate-node-template项目里的注释把示例模块改造成自己设计的业务逻辑。这一步的重点不是写得多复杂而是理解存储、调用、事件、权限这四个要素如何组装成业务。第三步系统读文档。此时再回头读 Substrate 官方文档的Runtime章节、FRAME章节你会发现之前看不懂的部分现在都通了。再配合polkadot-js/apps里观察真实链比如 Kusama 或一个知名测试网的模块配置和 Runtime 升级历史加深对无分叉升级的理解。第四步读源码。这个阶段不是通读全部源码而是带着问题去读。比如你想搞清楚交易费是怎么算的就去读pallet_transaction_payment的源码想搞明白验证人奖励怎么分配就去读pallet_staking的源码。Substrate 的代码质量整体很高注释规范读起来比大多数开源项目要舒服。6.2 资料选型别在过时教程上浪费时间Substrate 的文档更新速度很快很多早期博客文章和视频已经过时了。我踩过的坑是看了一篇两年前的老教程照着配置 API结果一大堆编译错误最后发现是因为依赖版本和 API 签名都变了。比较稳妥的信息源按优先级排官方文档docs.substrate.io目前最权威更新也最及时Parity 官方的 YouTube 视频讲解概念很清晰适合通勤路上听Substrate 的 GitHub 仓库这个真的很有用特别是examples目录下的小示例比看长篇大论高效生态项目的源码比如 Statemint、Westend 这些官方平行链的代码是大型真实项目的范本社区博客和深度文章作为补充但要注意发布时间和配套版本不建议一上来就追最新的 master 分支文档因为和稳定版本不匹配。选一个 LTS 样式的版本号比如 polkadot-v0.9.42一路学到底减少变量。6.3 一个容易被忽略的点搞懂 Weight 和交易费新手在写 Pallet 时很容易忽略#[pallet::weight]这个属性的含义。很多人以为它只是随便填一个数字其实它决定了两件至关重要的事交易费或者说交易成本和对区块容量的占用。在 Substrate 里每笔交易都要声明它消耗多少计算资源这个资源用 Weight 来量化。如果某个函数设置的 Weight 过小实际执行时超了区块验证就会失败因为这是对节点资源的保护如果设置得过大交易费就虚高用户体验差。计算 Weight 的简单方式是用#[pallet::weight(10_000 1_000 * (len作为参数))]这种带参数的计算式精确的估算则需要用benchmarking模块跑基准测试。测试网阶段可以先用粗略估算但上线前一定要跑 benchmark否则交易费不是高了把用户吓跑就是低了可能被恶意交易刷爆区块。6.4 我个人经历里的几条后知后觉最后分享几点我实际开发中的个人体会。第一别一上来就设计完美架构。Substrate 的模块化设计让你可以非常低成本地重构先绕着业务核心写一个能跑的最小闭环再逐步加入治理、激励、跨链这些外围能力远比一开始就设计十个 Pallet 然后统统返工要快。第二测试网是你的氧气瓶。Substrate 的测试工具链非常完善pallet单元测试、runtime集成测试、try-runtime升级测试、fork-off-substrate状态模拟每一层都在保护你不至于在主网上出事故。我认识的一些项目方就是懒得写测试结果每次改 Runtime 都提心吊胆。其实 Substrate 的 mock 框架写起来并没有多麻烦花一两天把基础设施搭好之后每个业务逻辑的改动都能立刻验证安心太多。第三社区的力量比你想的大。Substrate 的生态项目很多像 Subscan、Subquery 这些工具链已经相当完善。你的链如果在 Substrate 上构建相当于直接继承了一个庞大的开源生态而不需要从零解决区块浏览器、索引服务、钱包兼容这些问题。单凭这一点我就觉得当初选择 Substrate 是非常正确的决定。
返回列表