为什么去中心化的跨链桥不可能实现

准确来说,是 Trustless 的跨链桥不可能实现。因为一般认为去中心化就意味着 Trustless,虽然实际上去中心化是在信任一个 Permissonless 的群体。 不纠结这些具体的名词。现在的各种跨链桥方案,以及未来的跨链桥方案,一定都做不到纯粹的、工具性质的、数学证明的、轻量级运营的、去中心化的形式,能够满足物理隔离的、异步的、异构链之间的跨链。 先列举一下现在的跨链桥方案,以 BTC 到 ETH 的跨链为例: 公证人模式,也就是中心化的模式,把 BTC 打给一个中心化机构,机构再把 ETH 打给你指定的地址。安全性依赖中心化机构,没什么可说的。现在大多数跨链桥都是这种传统的模式,最多就是机构内部多签。 TSS 密钥分片、多签,相对去中心化。把 BTC 转给一个第三方的链比如 Thorchain,在 Thorchain 中,有多个节点分别持有 ETH 链上资产的密钥分片,每个节点分别扫描和验证 BTC 的交易信息,达到一定阈值则多签的签名生效、在 ETH 链上放行资产。 这种模式把公证人给去中心化了,有多个节点在共同验证来源交易是否正确。这是目前相对来说比较靠谱的、依赖社区博弈的方案。因为 Thorchain 本身就是一条链,想成为持有密钥分片的节点就需要质押 Token 等。TSS 的签名算法比较消耗计算资源,做不到同时分发太多的密钥分片,所以持有分片的节点数量在一定范围内。像所有的 PoS 链一样,表面上是去中心化的,实际上是 Permissioned 的,只不过是权限的门槛高低不同。 TSS 确实把跨链桥去中心化了,但是引入了 BTC 和 ETH 之外的第三条链,而第三条链的安全性又依赖于它自己的经济学博弈,本身就是风险。所以这种模式可靠但不够纯粹。 合并挖矿,特指资产的来源方是 PoW 链,让 BTC 的矿工在挖矿的时候,把跨链桥的某种证明信息给带上,一起挖到 BTC 的区块信息里。那么 BTC 的区块信息本身就包含了跨链信息。这种方式非常可靠但是不可行,因为矿工不可能配合跨链桥干这种事情。 HTLC,本质上是资金的对敲,对实时性要求比较高,必须双方同时在 BTC 和 ETH 在线交换密钥,而且 HTLC 只能做到资金的交换而不是跨链,涉及到汇率的问题。 全验证轻客户端,也就是在 ETH 上运行 BTC 的轻客户端,比如让每个 Geth 节点都可以同步和验证 BTC 的全部区块信息,那么 Geth 本身就是一个轻量级的 BTC 客户端,自然也能够实时地、无需第三方信任地把 BTC 交易信息同步到 ETH 的智能合约上。但是这样显然成本太高了,相当于一个客户端同时兼容了两条链。 ZK 跨链桥,运行一个 BTC 的轻客户端,把所有区块信息生成电路证明,然后在 ETH 上部署一个 Verifier,只需要验证很小的证明就可以相信来源信息是正确的。那么这种 ZK 模式的问题在哪里?ZK 并没有黑魔法,它的问题在于,需要在 ETH 上部署一个 Verifier。也就是说,你不需要关心是谁、是怎么生成证明的,但是你必须相信在 ETH 上的 Verifier。那么 Verifier 的代码本身、背后的开发者、来源是否可信任又是一个问题。 TEE 硬件设备签名。比如在一个 Intel 的服务器上,同步 BTC 的区块信息,然后用 TEE 内置的私钥签名,证明这个信息一定来源于这台设备。然后在 ETH 上,只需要判断来源签名是否来自于那台硬件设备,就可以证明信息是否安全。TEE 方案最大的问题自然是单点风险。 各种方案对比下来,似乎根本找不到一种让人满意的方式。为什么会是这样? 分布式系统中的两军问题(Two Generals’ Problem),这是计算机科学史上第一个被证明无解的问题。这个问题具体的定义不重要,重要的是它早已被科学界证实无解。 计算理论中的预言机问题(The Oracle Problem),每一条区块链本身都是一个确定性的状态机,它是封闭的、可被验证的、可被重复计算的。如果要和外部通信,就必然需要引入信任模型、经济学博弈、密码学证明等,这些内容会让跨链方案不再纯粹。 所以去中心化的跨链桥这个问题,已经触及到了计算机科学的理论边界,不是工业界无能,而是这个问题本身无解。 有趣的是,Vitalik 早在 2022 年就发表过一个 观点,未来的区块链是多链的(multi-chain),而不是跨链的(corss-chain)。因为即使存在可靠的跨链工具解决了通信问题,ETH 仍然需要信任 BTC 的共识机制。假如 BTC 网络分叉,ETH 不可能跟着 BTC 的分叉去重新分配资金。这同样是一个无解的问题。 这种跨链桥方面的技术边界可以带给我们启发: 不存在无需信任的技术方案,只有信任转移、最小化信任 工程架构不可能完美,大多数时候需要权衡

2026/1/13
articleCard.readMore

我对于 AI 时代的答案

当我的工资从每天 ___人民币上升到每天 ___美元的时候,我开始思考是什么让工资增长。 得到的结论是: 技术能力的提升 然后我继续思考这几个问题: 什么样的技术能力算是好 什么样的技术能力是稀缺、长久的 如何提高技术能力 所以多年以来,贯彻我人生的思路就是:如何提高技术能力。最明显的体现是,如果工作不能让我成长,就换工作。 那么对于 “如何提高技术能力” 这个问题,我的答案是: 关注底层原理和实现,而不是应用层的框架和接口 需要具备在复杂代码中找到并调试关键流程的能力 实际参与一些比较前沿的、像样子的项目 自己模仿实现一些比较底层和看起来硬核的项目 关注技术理念而不是具体实现 知道王垠课程所代表的 “计算的本质” 事实一 但是紧接着,我发现了一个令人不安的事实: 工资的高低、工作是否稳定,和技术能力的好坏没有必然的联系 得到这个结论有这样几个原因: 我不认为自己技术能力不合格,但是我遭遇的面试结果为不合适的情况,非常非常多 我不认为自己技术能力不合格,但是我亲眼看见水平很一般的人在做领导、面试官 我不认为自己技术能力不合格,但是我的工作很不稳定 有的人出生就在罗马 事实二 与此同时,我发现另一个令人不安的事实是,AI 在改变游戏玩法: 普通人和知识渊博的专家之间,差距只是一个 Gemini 程序员不再需要手写代码 AI 引起的变化非常大,直接改变 “如何提高技术能力” 这个问题的答案: 写代码的能力完全不重要,掌握多编程语言的能力完全不重要 在领域内的经验不再需要积累,一问 AI 全是标准答案 复盘一下我之前犯的错误: EchoEVM,在半年前,开发这样的东西似乎是有意思的。半年后的今天,AI 可以轻易开发出完整的项目。所以 EchoEVM 不再有意义。 EthBFT,在 2 个月前,开发这样的东西也许可以看到 AI 能力的局限性。但是现在,AI 在逐渐突破以前的局限。所以 EthBFT 也不再有意义。 EchoEVM 和 EthBFT 的共同特点,是偏低层、侧重技术的实现,试图用硬核项目来证明自己的技术能力。然而在拥有 AI 的今天,这种硬核的代码能力恰恰是 AI 最擅长、最先取代的。 新的问题 结合这两个令人不安的事实,需要回答的新问题是: 如何提高自己的竞争力、稀缺性、硬实力、挣更多钱、不被时代淘汰 新的答案 那么对于新的问题,我的答案是: 提高发现问题的能力 对答案的解释 你也许会说,这不废话吗,自古以来,发现问题的能力都是重要的。 不,这不一样,在没有 AI 的时候,你可以不需要有判断力,不需要能够发现问题,哪怕只是听产品经理的话来实现功能,也就是干好程序员的活,就可以活下去。 但是 AI 取代了这种只会听话干活的人。 “发现问题” 同时涵盖技术领域和非技术领域,在技术领域,发现代码有没有问题、功能设计是否存在漏洞、业务的边界条件是否缺少约束;在非技术领域,发现用户有哪些实际的需求,发现市场有哪些比较大的空缺。 那么为什么没有把 “提高判断力” 放到答案中?因为判断明天的股市涨跌也算判断,这种能力是无法验证以及无法通过努力提高的。 进一步问题 还没完,对于新的答案,有两个问题: 如何提高自己发现问题的能力 与代码能力不同,发现问题的能力该如何量化、与他人比较 对于 “如何提高自己发现问题的能力” 的问题: 只有见过更好的,才能知道现在看到的有什么不足。所以要事事都向上看齐 对于 “发现问题的能力该如何量化” 的问题: 技术方面,把发现的问题落实到技术文档、设计文档、架构文档上 非技术方面,把发现的问题记录下来,比如博客、日记,文字可以记录思考的过程

2025/12/2
articleCard.readMore

不要投资任何隐私币

除了试图犯罪以外,真正需要协议级隐私的场景很少 协议级隐私带来的是技术上的极度复杂、使用体验差 假如 ZEC 使用 100% 的 z 地址,CEX 首先会下架 ZEC 任何代币只要全过程匿名,将要面对的不只是CEX下架,还有各国政府的封禁 大型机构绝不会也不可以投资隐私币 隐私币绝不会成为国家储备 任何代币,只要警察对你进行物理监禁,技术型隐私就毫无意义 你需要生活在真实的世界中,而不是网络中

2025/11/17
articleCard.readMore

区块链技术世界的三大真理

有这样两个事实: 王垠是编程语言理论、计算机科学理论方面的专家 在区块链技术理论方面,目前看不到这种级别的专家,尤其是愿意公开发声、开课、讲授知识的 这样的事实背后是有原因的: 计算机科学经过了几十年发展,区块链大概十几年 区块链本身、从诞生之初就是工程化集成的产物,而不是理论创新 这会带来不同的现象: 掌握计算机科学的基础理论,lambda 演算、图灵机、计算模型,理解计算的本质后,在编程语言方面可以长久复用、不会过时,无论上层语言、框架如何变化,计算的核心不会变化 区块链工程似乎没有基础理论,没有什么技术是不会变的,也没有什么技术是需要长期积累的。从业者年龄小、新人多、工作内容以系统集成、调 SDK 为主 所以区块链的技术世界中,有没有什么理论性质的 “真理”,是长久不变、可以复用、无论上层框架如何变化都不需要担心的? 区块链技术世界的三大真理: 共识。如何解决拜占庭将军问题。 加密。以数学为根基的不可篡改、验证。 激励。社会学博弈引擎,让共识长久运转。 掌握了这三个部分的技术,无论区块链形式上怎么推陈出新,无论行业热点如何变化,都不用担心,因为区块链本质上就是在解决这些问题。 怎么样才算是掌握了 “真理”?我看懂了、我理解了,算是我会了吗?算是我掌握了吗? 掌握真理的标准是,可以根据真理,从头构建出知识。 在计算机科学的世界里,假如世界毁灭了,给你一张纸和笔,你可以从头实现 lambda 演算、实现数据结构、实现一个解释器、实现一种编程语言,甚至构造出更多东西,不依赖于教材、框架、API,这叫掌握真理。 真理的意义在于,让你明白知识为何必须如此存在。——这也是王垠的课程在试图教会你的东西,王垠不教知识,只教 “王垠式真理”。所以我一直认为王垠的课程好、价值高。 类似的,在区块链的世界里,如果你可以从脚本写起,实现共识、加密、激励,不一定重建全部细节,但一定要理解现有系统为何那样设计,就差不多了。 要注意,智能合约的编程语言不在真理的范畴之内,无论是比特币脚本、Solidity、Move、Cairo,都只是表达交易逻辑的 DSL,都是在用不同形式,定义区块链执行交易的规则,很重要但是还没到 “真理层”。 非要说智能合约的真理层,可能可以表达为一个确定性的状态转移函数,无论语言如何变化,这个 “真理” 都始终存在: State_t+1 = f(State_t, Transaction) 下一个状态来自于上一个状态加上一些交易引起的状态变化,简单吧。但我们这篇文章重点关注区块链世界中的 “王垠式真理”,所以依然是三大真理:共识、加密、激励。

2025/11/3
articleCard.readMore

以太坊 AA 钱包的致命问题是什么

到目前为止,最新的 AA(Account Abstraction)钱包规范仍然是基于 EIP-4337 实现的。 对于 AA 钱包存在的问题,像操作繁琐、Bundler 中心化、可用性低、生态支持不完善、合约安全风险高等表面上的问题就不多说了。 一句话描述 AA 钱包在干什么事情:AA 钱包能实现 “对账户资金的授权” 与 “把交易广播到链上” 这两个行为的分离。 AA 钱包的功能 AA 钱包的功能,体现到具体的交易行为上,就是如果没有 AA 钱包,你得自己发交易。有了 AA 钱包,你可以只签名,不发交易,让其他人代替你把交易发到链上就行。 为什么 AA 钱包能做到这一点?因为 AA 钱包本质上就是一个合约,所以你会发现,AA 钱包的大多数 “优点”,其实是智能合约本身就具备的功能,像什么社交恢复、批量操作等。唯一能带来特殊体验的,只有 “代付手续费” 这个特性。 那为什么 AA 钱包能实现代付手续费这个功能?因为 AA 钱包的所有操作实际上不是交易,而是 UserOperation,有一个链下的 Bunlder 程序会把这些用户操作,通过发交易批量提交到链上。 为什么以太坊需要 AA 钱包 为什么以太坊需要 AA 钱包?因为以太坊的共识层协议要求,交易必须由一个 EOA 地址来发起。这个 EOA 地址,就是交易结构中的 from 字段,以太坊节点会从这个 from 地址计算手续费、扣手续费、验证交易有效性等。 合约没有私钥,交易不可能由合约发起。在这样的规则约束下,就导致以太坊所有的链上行为,都必须由某一个 EOA 地址来发起交易。 你可能觉得不对,部署一个合约,然后让合约来验证 data 里得签名数据就好了。data 里的签名数据,未必需要和发起交易的地址一致。 没错,事实上,AA 钱包的发展链路是:meta-transcations -> EIP-2771 -> EIP-4337。 这些方案在解决的问题本质上都是:如何让使用资金的权限,与发起链上交易的行为分离。 而引起这一系列复杂协议的根源,来自于以太坊 “交易必须由一个 EOA 地址来发起” 的规则。 为什么以太坊有这个规则 为什么以太坊要有 “交易必须由一个 EOA 地址来发起” 这个规则? 因为以太坊的账户模型,是账户-余额模型。协议必须要知道,一笔交易的手续费从哪里扣。 比特币不存在这个问题 比特币的 PSBT 交易格式,可以实现原生的多签。功能是多个钱包只负责签名,最终由另外一个钱包把交易广播出去就可以。 多签交易,就是典型的把对资金的授权,与广播交易行为分离开的场景。 为什么比特币不存在以太坊的这个问题?因为比特币使用的是 UTXO 模型,交易根本没有 from 地址,有的是多个输入脚本,节点只需要校验交易是否符合脚本的解锁规则,而不需要考虑手续费从哪里扣的问题。 AA 钱包的定位 我们梳理一下这个链条:以太坊使用账户-余额模型 -> 交易必须由 EOA 地址发起 -> 需要 AA 钱包。 AA 钱包在干的事情,实际上是在给以太坊的账户模型打补丁,为了修补账户-余额模型相比 UTXO 模型的不足,才有了 AA 钱包这个东西。AA 钱包是在不涉及以太坊协议变更的前提下,诞生出的一种 workaround 方案。 从地位上来说,AA 钱包对于以太坊的地位,类似于铭文/符文/RGB 对于比特币的地位。在比特币生态里,因为没有图灵完备的脚本,所以在不触及比特币协议变更的前提下,搞出了铭文/符文/RGB 这些 workaround方案。 AA 钱包需要链下的 bundler 来提交交易,与符文需要链下的索引器来维护符文的数据状态,是不是一个意思,都严重依赖于链下的程序? 而事实上我们都知道,比特币生态的玩法,至今都还没有被主流社会认可。 假如 AA 钱包未来有一天能被社会大众认可,那也就意味着 workaround 方案在区块链世界中是可行的。对于整个生态的叙事都将引起巨大的改变。 AA 钱包的未来 综上所述,我们能得出的结论是,以太坊永远不可能支持 “原生” 的 AA 的钱包(在协议层面支持)。 总结 这些结论,对于技术人员的指导意义在于: 对 AA 钱包祛魅,不要以为 AA 钱包是高级、先进的技术。 可以学习、使用、研究 AA 钱包,但是千万不要真的 “相信” AA 钱包的技术理念。

2025/10/24
articleCard.readMore

基于 AI 语义执行的 MCP 区块链的设计

MCP 区块链 MCP(Modal Content Protocol)是 AI Agent 在使用的一种协议规范,用于 AI 和外部的工具进行交互,MCP Server 则是具体执行外部交互的组件。 MCP 区块链的含义是,首先它是一条区块链,然后每个节点都原生支持 MCP 协议的 RPC 接口,可以直接接受来自 AI Agent 的请求。每一个 MCP 请求,都是一笔交易(智能合约调用),这也就意味着,区块链会记录下所有的 MCP 交互历史。 节点内部的 MCP 执行引擎,功能分两部分,对于内部交互(EVM 在干的事情),只需要维护好内部的状态转移,把结果写入本地的 KV 数据库就可以了。对于外部交互,则只记录下要执行的请求本身,先不做外部调用。那么实际上对于每一个请求,都会改变本地的状态,所以这些交易是可以重放的,也就可以根据哈希值来确认节点数据的完整性。 至于对外部的执行请求,可以由外部的执行节点(一种角色)来完成。每一个外部请求都包含一些详细的参数,比如需要几个执行者、结果如何验证。交易会把一部分手续费作为执行费用,奖励给执行节点。如果执行节点作恶,自然也会有相应的惩罚机制。 对于外部的调用,关键在于如何验证外部执行的结果,这个问题可以交给调用者来定义,比如要创建一个 GitHub 仓库,验证方式就是,可以通过 API 查询到这个仓库的信息。 这就是 MCP 区块链的大体思路。 设计来源 解释一下这个想法的来源。MCP 区块链并不是想要 “把 MCP Server 去中心化”,而是想要 “给区块链带来与 AI 交互的能力”。这两种动机是截然不同的。 这个想法背后的逻辑很简单,比特币其实有脚本,只不过是生硬的操作码形式。以太坊干了一件很厉害的事情,给操作码加上了编译器,让开发者可以用编程语言来表达操作码。那么如今的 AI,很厉害的一点在于,打通了从自然语言到编程语言的路径。也就是说,未来的区块链,也许可以做到自然语言直接与状态机交互,而不需要经过 自然语言 -> 编程语言 -> 状态机 这样的路径。明显编程语言是一个中间层,MCP 区块链的设想在于消除这个中间层。 另一个边界在于,让区块链完全按照自然语言的意图执行是不切实际的,因为哪怕是人类,也需要书面形式的合同这种东西,所以代码本身不会消失,状态转移也不会消失。目前能够实现自然语言和状态机直接交互的技术方式,就是 MCP。 区块链的技术趋势 最近在思考一个问题,区块链的下一个技术趋势是什么?能明确的几个事实是: 大家已经不再怀疑加密货币是一种支付手段这件事情 依靠开发区块链发币的路线,已经完全没有叙事空间 下一个技术趋势,绝不会是以太坊路线图的扩展 行业内已经提过的方向也都不大有机会,DeAI、ZK、Layer2、DeFi、跨链、RWA、GameFi、BaaS、NFT、元宇宙、DAO、DID 等等,都是陈词滥调了。 所以区块链在技术趋势上,需要的一个原语级别的新叙事。

2025/10/13
articleCard.readMore

一个集成 Geth 和 CometBFT 的兼容层

项目动机 在对 Arc 项目 进行分析的过程中,发现 Arc 干了一件很有意思的事情,先是自己开发了 Rust 版本的 Tendermint 共识 malachite,接着开发了一个对接 Reth 和 malachite 的兼容层 malaketh-layered,也就是说,Arc 这条链的架构是这样: Reth -> malaketh-layered -> malachite 最终形成了一条完全以太坊等价的 PBFT 链。 那么有没有类似架构的链,直接把 Geth 和 CometBFT 给结合起来呢。是有的,Berachain 开发了一个beacon-kit,干的就是这样的事情,Berachain 主网本身就是这种架构启动的。 但是 beacon-kit 有一个问题,就是代码过度 “复杂”,不但自己设计了 slot 的概念,还把 Berachain 的一些经济模型的设计、LST 质押之类的东西都放到了 beacon-kit 中。所以虽然 beacon-kit 在工程上是一个 Geth+CometBFT 可行的实践,但是它本身并不是工具性质的立场在做,夹带了不少私货。 因此我觉得需要一个通用的、工具性质的兼容层项目,目前命名为 EthBFT。这个项目的愿景是,提供简洁、开放、最小实现、工具性质的架构,达到集成 Geth 和 CometBFT 的目的。整个区块链网络的架构会是这样: Geth -> EthBFT -> CometBFT EthBFT 主要干两件事情: 通过以太坊执行层的 Engine API 拿区块数据 把区块数据通过 ABCI 接口提交到 CometBFT 这里虽然用 Geth 举例,但对于其他以太坊的执行层客户端,应该也是通用的,因为以太坊的执行层和共识层客户端,本来就是互相兼容的,仅仅通过 RPC 接口通信。所以预计 EthBFT 可以兼容全部的以太坊执行层客户端。 而 EthBFT 的设计,自然不会和 Geth 或者 CometBFT 有代码层面的耦合,EthBFT 是一个独立的进程,可以单独启动,Geth 也可以单独启动,CometBFT 也可以单独启动,3 个组件之间,彼此通过 RPC 接口通信,具体的 RPC 接口地址等信息则会体现在 EthBFT 的配置文件中。 这就让 3 个组件互相之间,完全解耦了。 对 PBFT 的信心 我之前以为区块链技术的发展会趋于追新,也会趋于去中心化,但是发现似乎不是那样。 从前两年的 Celestia 使用了 PBFT,到 Hyperliquid 改进了 PBFT 共识,再到最近 Arc 项目自己实现了 PBFT 共识,证明在高TPS的场景下,PBFT算法还非常有活力。 PoW 和 PoS 去中心化程度高,但是不能满足高 TPS 的需求,也不能达到最终一致性的要求,这些都是 PBFT 特有的优势,尤其是企业级的应用场景下,没那么在意去中心化。 我们也许会有疑问,如果不在乎去中心化,那直接用 Server 端提供服务不就行了吗,用区块链干什么。在丢失去中心化特性的前提下,至少区块链还保留有数据公开、数据变更可追溯等特点,也是一些不错的优势。 因此,PBFT 这种诞生接近 30 年的算法,将来还会继续发光发热。也因此,去搞一个 PBFT 相关的项目,不会有太大问题。 项目前景 EthBFT 肯定不会受到市场的关注,因为大家只在乎一条链能不能发币,能不能套利,并不在乎你的技术架构是什么。 EthBFT 只是一个工具性质的项目。如果一个开发者,想要一条以太坊完备的链,同时又想要高 TPS,在没有 EthBFT 的情况下,需要怎么做呢。我懒得展开分析对比搭建链的方案了,总之我觉得 EthBFT 可以填补这部分的空缺,非侵入式那种。世界上缺一个这样的工具。 对 AI 技术的怀疑 现在 smallyunet/EthBFT 项目已经有了基本的框架,能跑通最小版本,我把它归档为 v0.0.1 版本。能跑通的表现是 Geth 的区块高度会逐渐增加,CometBFT 也在正常出块,Geth 和 CometBFT 的区块高度保持同步。当然现在还属于非常早期的版本,开发时间有限,功能上肯定有不完善的地方,接下来还会继续改进。 我之前说 鼓吹 Cursor 的人技术能力都差,因为 AI 可以放大你的能力,但是不可能代替你懂。v0.0.1 版本的 EthBFT,全部代码都是 AI 写的,没错,但是以 EthBFT 这个项目为例,现在要干的事情非常清晰,你可以试试,在不懂以太坊和 Cosmos,甚至不懂技术的情况下,完全托管给 AI,能不能搞出一个能运行的、EthBFT 这样的项目。 如果你自己对技术的理解不清晰,或者有错误,关键是 AI 不会纠正你的错误,因为 AI 并不知道你心里想要的 “正确” 是什么。AI 会非常听话地按照你的描述写代码,如果你语焉不详,AI 写出来的代码必然会跑偏,朝着错误的方向发展,而且很多时候 AI 会自己偷偷埋坑,你以为它实现了,结果它要么没写全,放了个 TODO 在那儿,要么按照自己的理解写出一大堆不需要的代码。 所以让 AI 把代码写对,其实不是一件容易的事情,首先你自己得懂,然后你得时刻盯着它干活。AI 始终只是助手而已。

2025/9/6
articleCard.readMore

我的加密货币定投策略(二)

前情提要:《我的加密货币定投策略(一)》 新的定投策略 现在把新的定投策略调整为这样: 定投频率:每周一次 定投金额:100 美元 定投标旳:比特币(BTC) 就这么简单,不再整那些花里胡哨的东西,只投比特币。至于以太坊或者其他平台币,想用的时候买就行了,手续费用不了多少,价格也高不了多少,没必要因为 “可能要用” 而提前布局。 你也许会犹豫,比特币现在的价格这么高了,还可以买吗?定投策略本身就是在消除对入场时机的顾虑,毕竟这是定投,不会一下子把钱砸进去,所以不用那么在意此时此刻的价格。关于定投策略的优点,李笑来的《定投改变命运》里已经有非常详细的解释,这里不多复述。 你也许会纠结,以太坊最近涨了那么多,接近历史新高,ETH/BTC 汇率持续走高,BTC 市占率持续下跌,为什么只选择定投 BTC 这一个币种?我的建议是,不要 FOMO。在接下来半年到一年的时间里,像以太坊一样动人心弦的例子还会有很多,不是可能有,是一定会有,也许每一次都会让你后悔,当初怎么没选这个币?要是早点买早点定投就好了。所以还是那句老话,不要 FOMO。 之前的错误 我之前犯了很严重的错误,也许是出于想体现出自己 “知道的多” 的心理,在定投组合中,选择了非常多小币种。现在差不多一年过去了,根据计算,实际上的收益效果比较差,大概在 22% 左右,这个收益率还不如全买 BTC 的涨幅。 当时有几个月的时间,我确实是按照定投组合在操作,但是后来由于这些原因,我自己都没能坚持下来定投这件事情: 某些币种比例太小,导致金额也小,假如一天定投 10 美元,某个代币占比 5%,一天就是 0.5 美元,一个月下来一共 15 美元。这样的金额即使涨 50% 或者跌 50% 都不会有感觉。而且这种小金额的代币一直放在账户里,也会让人不舒服。所以主观感受上,天天看到这些币种很难受,会想清理掉他们。 由于我时常关注加密行情的变化,有时明明知道价格下跌了,但是由于定投计划的存在,我无法手动加仓;有时明明知道价格上涨了,但是由于定投计划的存在,无法暂停定投。这样的感觉也很难受,像是被定投计划操控一样。 对于山寨币的风险预估不足,后来逐渐认识到山寨币的价值有限,而定投计划一开始给了山寨币比较高的比例,造成我头脑中的计划调整频率很快,快到定投策略跟不上。另外由于我的投资预算的不稳定,也就是收入和支出的不稳定,经常需要动一些钱,导致定投计划不得不暂停、重新开始。 所以不管以前怎么回事,以前的定投策略始终是有问题的,连我自己都无法好好实施。 黄金 注意这里只是 “加密货币” 的定投策略,不包含其他资产类型。比如价格锚定黄金的 PAXG 代币,不属于 “加密货币定投策略”,但是我会买、会定投,策略也是一样的: 定投频率:每周一次 定投金额:100 美元 定投标旳:黄金(PAXG) 美股 要这么说,把 BTC 和 PAXG 两个代币列出来不就行了吗?为什么还要强调黄金是不同于加密货币的资产类型? 因为 Kraken (海妖交易所)上支持买美股了。代币是发行在 Solana 链上的。我不懂美股,现在不能制定美股的定投计划。假如认为价格锚定黄金的代币算加密货币的话,那价格锚定美股的代币,算不算加密货币呢?事情就变复杂了。 美股上链这事还挺有意思,给未来带来了很多可能性。时代在发展,社会在进步,越来越有意思了。 关于卖出策略的疑问 人们常说,你无法同时拥有青春和对青春的体验。同样的道理,你无法同时享受拥有比特币,和比特币价格上涨带来的财富。 假如你现在还年轻,你需要钱,你要买车,你要旅游,你要体验生活。然后你把比特币卖了。等到老年之后,你会不会后悔?也许到时候会想,假如当时没卖掉,现在价值可就翻了很多倍。 要么放弃当下的享受,要么换来日后的后悔。怎么选呢?

2025/8/28
articleCard.readMore

DeFi 进阶: 闪电贷与套利

这是一个 DeFi 系列教程,在动手实践的过程中,学习和理解 DeFi 相关的概念与原理: DeFi 基础: 理解 AMM 定价机制 DeFi 基础: 预言机与报价 DeFi 基础: 借贷与清算 DeFi 进阶: 闪电贷与套利 闪电贷套利是我们经常听到的一个词,在实际的场景中有很多种模式。基于我们之前的 AMM 合约,就足以让我们来模拟一个简单版本的闪电贷套利。 简介 闪电贷的核心逻辑是,利用区块链智能合约的特性,在一笔交易内,借来大额资产(放大收益),拿着大额资产去执行别的操作,干什么都行,干完事情之后,再把借来的资金+少量手续费,原封不动还回去。因为智能合约是可以 revert 的,如果套利合约在执行过程中,发现最后套利没成功,可以回滚整个交易,除了手续费,没有额外损失。 我们接下来要模拟的场景时,有两个 AMM 池子,第一个池子的价格是 2000 USDC/WETH,第二个池子的价格是 4000 USDC/WETH。面对这样的场景,可以先想一下,不用闪电贷的情况下,应该如何套利。套利合约只不过是把我们的操作自动化了。 很简单,在第一个池子化 2000 USDC 买 1 个 WETH,到第二个池子上,直接就能卖出 4000 USDC,净赚 2000 USDC。 合约说明 我们会用到两个合约 FlashLender.sol 和 FlashArbBorrower.sol。这两个合约代表两个角色,其中 borrower 就是套利合约,会从 lender 那里借出一些资金。 也就是说 lender 合约,是有 借出资产功能 的: // 借出资产require(t.transfer(receiver, amount), "transfer out");// 调用 borrower 的回调函数bytes32 magic = IFlashBorrower(receiver).onFlashLoan(token, amount, fee, data);require(magic == keccak256("IFlashBorrower.onFlashLoan"), "bad callback");// 验证在回调函数后,borrower 是否归还了本金+手续费uint256 balAfter = t.balanceOf(address(this));require(balAfter >= balBefore + fee, "not repaid"); 关键就是这 3 行,先借出钱,然后回调,最后判断 borrower 是否还款。 在 borrower 的回调函数里,会写一些具体的 套利逻辑,比如从第一个池子买入 WETH,然后再卖到第二个池子: // 从便宜的池子中买 WETHuint256 wethOut = poolCheap.swap0For1(amount);// 到贵的池子中卖 WETHuint256 usdcBack = poolExpensive.swap1For0(wethOut);// 还款给 lenderuint256 repay = amount + fee; lender 和 borrower 是两个角色,那为什么不把这些逻辑写在一个合约里呢?如果写在一个合约里,意味着只有一个套利合约的角色,自己借钱出去、自己用钱套利,我自己都有钱了还借钱干嘛? 环境准备 合约代码源文件在仓库:smallyunet/defi-invariant-lab@v0.0.4 克隆仓库: git clone https://github.com/smallyunet/defi-invariant-lab/git switch v0.0.4cd defi-invariant-lab 部署第二个 AMM 合约 部署合约: forge create \ --rpc-url $RPC_URL \ --private-key $PK_HEX \ --broadcast \ contracts/amm/SimpleAMM.sol:SimpleAMM \ --constructor-args $USDC_ADDR $WETH_ADDR 30 部署后的合约地址:0xd9c870Ac0a84C3244286d39d870642d218b26532 注入初始流动性 这个 AMM_B 池子我们认为是价格比较高的池子,所以按照 4000 USDC/WETH 的价格注入初始流动性: export AMM_B=0xd9c870Ac0a84C3244286d39d870642d218b26532cast send $USDC_ADDR "approve(address,uint256)" $AMM_B \ 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff \ --rpc-url $RPC_URL --private-key $PK_HEXcast send $WETH_ADDR "approve(address,uint256)" $AMM_B \ 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff \ --rpc-url $RPC_URL --private-key $PK_HEXcast send $AMM_B "addLiquidity(uint256,uint256)" 4000000000000 1000000000000000000000 \ --rpc-url $RPC_URL --private-key $PK_HEX 部署 lender 并注资 部署合约: forge create \ --rpc-url $RPC_URL \ --private-key $PK_HEX \ --broadcast \ contracts/flash/FlashLender.sol:FlashLender \ --constructor-args 5 部署后的合约是:0x3c00AB1eD5dF40f7ae8c1E4104C89445615B9D0a 验证合约: forge verify-contract \ --chain-id 11155111 \ 0x3c00AB1eD5dF40f7ae8c1E4104C89445615B9D0a \ contracts/flash/FlashLender.sol:FlashLender \ --constructor-args $(cast abi-encode "constructor(uint16)" 5) \ --etherscan-api-key $ETHERSCAN_API_KEY 给 lender 转 20 万 USDC 作为初始资金: export LENDER=0x3c00AB1eD5dF40f7ae8c1E4104C89445615B9D0acast send $USDC_ADDR "approve(address,uint256)" $LENDER \ 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff \ --rpc-url $RPC_URL --private-key $PK_HEXcast send $LENDER "fund(address,uint256)" $USDC_ADDR 200000000000 \ --rpc-url $RPC_URL --private-key $PK_HEX 部署套利合约 部署合约: forge create \ --rpc-url $RPC_URL \ --private-key $PK_HEX \ --broadcast \ contracts/flash/FlashArbBorrower.sol:FlashArbBorrower \ --constructor-args $LENDER $USDC_ADDR $WETH_ADDR $AMM_ADDR $AMM_B 部署的合约地址是:0x62363Fe02b83b804fd65FE3b862383631fEffb49 验证合约: forge verify-contract \ --chain-id 11155111 \ 0x62363Fe02b83b804fd65FE3b862383631fEffb49 \ contracts/flash/FlashArbBorrower.sol:FlashArbBorrower \ --constructor-args $(cast abi-encode "constructor(address,address,address,address)" $LENDER $USDC_ADDR $WETH_ADDR $AMM_ADDR $AMM_B) \ --etherscan-api-key $ETHERSCAN_API_KEY 执行一次闪电贷套利 直接调用 execute 函数: export ARB=0x62363Fe02b83b804fd65FE3b862383631fEffb49cast send $ARB "execute(uint256)" 10000000000 \ --rpc-url $RPC_URL --private-key $PK_HEX 我发起的交易哈希是:0x948edfb7eeb5ceb924c1eb39704efd952f1cd3ed435c059a548dd7ea82031f15 浏览器的交易过程比较直观,直接看浏览器的记录就好了: 可以看到在第一个池子里,用 10000 USDC 买了 4.6 个 WETH,紧接着把 WETH 卖掉,换出了 18000 USDC,净赚 8000 USDC。 borrower 的合约里有写把收益金额 emit 为事件: uint256 profit = usdcBack - repay;if (profit > 0) { require(usdc.transfer(owner, profit), "payout fail"); emit Profit(profit);} 所以在浏览器上也能看到真的 触发了事件:

2025/8/21
articleCard.readMore

DeFi 基础: 借贷与清算

这是一个 DeFi 系列教程,在动手实践的过程中,学习和理解 DeFi 相关的概念与原理: DeFi 基础: 理解 AMM 定价机制 DeFi 基础: 预言机与报价 DeFi 基础: 借贷与清算 DeFi 进阶: 闪电贷与套利 我们已经有了两个 ERC-20 代币 USDC 与 WETH,有了 AMM 合约,有了 Oracle 合约。接下来利用之前的合约,尝试和理解一下借贷相关的合约逻辑。 借贷合约要注意的地方是,在计算用户能借出多少资产的逻辑中,需要用到代币的价格。这里的代币价格,来自 Oracle 的报价,而不是 AMM 合约的价格。Oracle 的报价一般基于 AMM 的价格波动,如果 Oracle 遭受攻击,借贷合约也会相应受到影响。 环境准备 合约代码源文件在仓库:smallyunet/defi-invariant-lab@v0.0.3 Oracle 使用的合约是 SimpleLending.sol,先克隆仓库: git clone https://github.com/smallyunet/defi-invariant-lab/git switch v0.0.3cd defi-invariant-lab 部署合约: forge create \ --rpc-url $RPC_URL \ --private-key $PK_HEX \ --broadcast \ contracts/lending/SimpleLending.sol:SimpleLending \ --constructor-args $WETH_ADDR $USDC_ADDR $ORACLE_ADDR 部署地址:0xd4bbFbCe71038b7f306319996aBbe3ed751E9A1C 验证合约: forge verify-contract \ --chain-id 11155111 \ 0xd4bbFbCe71038b7f306319996aBbe3ed751E9A1C \ contracts/lending/SimpleLending.sol:SimpleLending \ --constructor-args $(cast abi-encode "constructor(address,address,address)" $WETH_ADDR $USDC_ADDR $ORACLE_ADDR) \ --etherscan-api-key $ETHERSCAN_API_KEY 用 WETH 抵押借出 USDC 给借贷合约挖 10 万个 USDC,作为初始可以借贷的资产: export LEND_ADDR=0xd4bbFbCe71038b7f306319996aBbe3ed751E9A1Ccast send $USDC_ADDR "mint(address,uint256)" $LEND_ADDR 100000000000 \ --rpc-url $RPC_URL --private-key $PK_HEX 存入 1 个 WETH 作为抵押物: cast send $WETH_ADDR "approve(address,uint256)" $LEND_ADDR \ 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff \ --rpc-url $RPC_URL --private-key $PK_HEXcast send $LEND_ADDR "deposit(uint256)" 1000000000000000000 \ --rpc-url $RPC_URL --private-key $PK_HEX 调用 borrow 函数借出 USDC,借出额度的计算是: function borrow(uint256 amt) external { _accrue(); require(_value(coll[msg.sender]) * LTV_BPS / 10_000 >= borrows[msg.sender] + amt, "exceeds LTV"); borrows[msg.sender] += amt; totalBorrows += amt; debt.transfer(msg.sender, amt);} 我们抵押了 1 个 WETH,按照 2000 USDC/WETH 的价格,合约设定 LTV 最高 70%,也就是可以借出 2000*0.7=1400 个USDC。 来用实际交易试一下,这次借出 1400 个 USDC: cast send $LEND_ADDR "borrow(uint256)" 1400000000 \ --rpc-url $RPC_URL --private-key $PK_HEX 查看借出 USDC 后的余额、负债、健康度: cast call $USDC_ADDR "balanceOf(address)(uint256)" $MY_ADDR --rpc-url $RPC_URL# 799400000000 [7.99e11]cast call $LEND_ADDR "borrows(address)(uint256)" $MY_ADDR --rpc-url $RPC_URL# 1400000000 [1e9]cast call $LEND_ADDR "health(address)(uint256)" $MY_ADDR --rpc-url $RPC_URL# 1904761904285714285 [1.904e18] 这里的健康度,指是否有可能触发清算。当查询结果大于 1,则比较安全。当健康度小于 1,则可以被清算机器人、套利者清算掉。 降低价格到清算线 如果想还债的话,调用 repay 函数就可以了。 现在要体验一次清算逻辑,我们之前抵押了 1 WETH,价值 2000 USDC,借出了 1400 USDC,此时 LTV=1400/2000=70%,正好是 70%,处于安全状态。 当价格下跌到 1000 USDC/WETH,此时的 LTV=1400/1000=140%,已经超过 70% 的安全值,也超过了 75% 的清算阈值。 我们修改下在预言机里的价格,让借贷合约感知到 WETH 价格下跌了(这也就是预言机的主要作用,决定了链上的报价): cast send $ORACLE "post(uint256[])" \ "[99900000000,100000000000,100000000000,100000000000,100100000000]" \ --rpc-url $RPC_URL --private-key $PK_HEX 再查一下健康度: cast call $LEND_ADDR "health(address)(uint256)" $MY_ADDR --rpc-url $RPC_URL# 952380952142857142 [9.523e17] 这里的健康度实际上是 0.9 1e18,已经小于 1 了,处于可以被清算的状态。 执行清算 任何人都可以执行清算,执行清算成功后,可以获得 10% 的清算奖励,这就是很多人需要抢跑交易、优先执行清算的原因。10% 的清算奖励是指,假如你替抵押者还债 200 USDC,让他的仓位健康度大于 1,那么这个时候,按理你可以清算(部分清算)得到 0.2 WETH,由于 10% 的清算奖励,你实际上得到了 0.22 WETH。 我们现在执行交易还债 200 USDC: cast send $USDC_ADDR "approve(address,uint256)" $LEND_ADDR \ 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff \ --rpc-url $RPC_URL --private-key $PK_HEXcast send $LEND_ADDR "liquidate(address,uint256)" $MY_ADDR 300000000 \ --rpc-url $RPC_URL --private-key $PK_HEX 查看执行清算后,一些数据的变化: cast call $LEND_ADDR "borrows(address)(uint256)" $MY_ADDR --rpc-url $RPC_URL# 1200000000 [1.2e9]cast call $LEND_ADDR "coll(address)(uint256)" $MY_ADDR --rpc-url $RPC_URL# 999999999780000000 [9.999e17]cast call $WETH_ADDR "balanceOf(address)(uint256)" $MY_ADDR --rpc-url $RPC_URL# 899987136079723472734 [8.999e20] 小结 可以看到,Defi 的借贷就是在玩这些金钱的数字游戏。 DeFi 开发的难点在于,需要理解一大堆金融相关的公式,看懂合约代码背后表达的业务含义,计算利息、负债率什么的。这个方向对金融行业从业者更友好一点。 Solidity 语言只是表达金融公式的工具,Solidity 的语法本身很简单,普通的开发人员很快就可以掌握。但是掌握 Solidity 语法,不代表能够理解金融体系,不代表能看懂金融公式。

2025/8/21
articleCard.readMore

DeFi 基础: 预言机与报价

这是一个 DeFi 系列教程,在动手实践的过程中,学习和理解 DeFi 相关的概念与原理: DeFi 基础: 理解 AMM 定价机制 DeFi 基础: 预言机与报价 DeFi 基础: 借贷与清算 DeFi 进阶: 闪电贷与套利 预言机(Oracle)的逻辑相对简单,基本功能是,会有链下服务定时向链上提交一些数据,比如 WETH 的价格,合约保存下数据后,就可以被其他智能合约调用,直接获取到价格信息。 那么链下服务的价格信息,从哪里来?简单处理的话,可以来自 AMM 合约的初始流动性的定价。 以下所有操作都在 Sepolia 测试网进行。这些操作步骤,其实都是一些普通的合约交互步骤。主要是在操作过程中,进一步体会和理解合约的代码功能。 环境准备 合约代码源文件在仓库:smallyunet/defi-invariant-lab@v0.0.2 Oracle 使用的合约是 MedianOracle.sol,先克隆仓库: git clone https://github.com/smallyunet/defi-invariant-lab/git switch v0.0.2cd defi-invariant-lab 部署合约: forge create \ --rpc-url $RPC_URL \ --private-key $PK_HEX \ --broadcast \ contracts/oracle/MedianOracle.sol:MedianOracle 部署的合约地址是 0xdE342a228A2A83b47cA4eB3D3852578837E60750。 验证合约: forge verify-contract \ --chain-id 11155111 \ 0xdE342a228A2A83b47cA4eB3D3852578837E60750 \ contracts/oracle/MedianOracle.sol:MedianOracle \ --etherscan-api-key $ETHERSCAN_API_KEY 设置喂价人 调用合约的 setFeeder 函数,设定谁可以向 Oracle 提交数据: export ORACLE_ADDR=0xdE342a228A2A83b47cA4eB3D3852578837E60750cast send $ORACLE_ADDR "setFeeder(address,bool)" \ 0x44D7A0F44e6340E666ddaE70dF6eEa9b5b17a657 true \ --rpc-url $RPC_URL --private-key $PK_HEX 然后能查询到设置结果: cast call $ORACLE_ADDR "feeders(address)((bool))" $MY_ADDR --rpc-url $RPC_URL# (true) 首次喂价 发起交易: cast send $ORACLE_ADDR "post(uint256[])" \ "[199900000000,200000000000,200000000000,200000000000,200100000000]" \ --rpc-url $RPC_URL --private-key $PK_HEX 读取最新价格 读取价格: cast call $ORACLE_ADDR "latest()(uint256,uint256)" --rpc-url $RPC_URL

2025/8/21
articleCard.readMore

DeFi 基础: 理解 AMM 定价机制

这是一个 DeFi 系列教程,在动手实践的过程中,学习和理解 DeFi 相关的概念与原理: DeFi 基础: 理解 AMM 定价机制 DeFi 基础: 预言机与报价 DeFi 基础: 借贷与清算 DeFi 进阶: 闪电贷与套利 AMM 的全称是 Automated Market Maker,自动做市商,作用是不需要订单簿撮合交易,就可以自动完成定价与交易。 这篇文章解释了 Uniswap V2 的核心定价逻辑,并且提供了完整的合约代码示例、命令行操作步骤、实际的链上交易现场等,作为理解 AMM 的配套参考。 AMM 计算公式 基本逻辑 Uniswap V2 用的定价逻辑是恒定乘积做市商(Constant Product Market Maker, CPMM),也是我们的示例 AMM 合约在用的方法。这里有一个恒等公式: x * y = k 意味着池子里有两种资产 x 和 y,当 x 增多的时候,y 就应该减少,y 增多的时候,x 应该减少,k 总是保持不变。 在添加初始流动性的时候,我们第一次确定下来这个 k 的值,比如我们按照 2000 USDC / 1 WETH 的价格注入初始流动性,会得到(不考虑精度): k = 2000 当我们想要用 USDC 换出 WETH 的时候,池子里的 USDC 增多,为了保持 k 不变,合约会计算应该保留多少 WETH,然后把相应数量的 WETH 转给我们。 第一次兑换 当我们想要用 USDC 换出 WETH 的时候,池子里的 USDC 增多,为了保持 k 不变,合约就会把相应数量的 WETH 转给我们了。 例如,我们试图用 500 USDC 换出 WETH,此时加上初始流动性的 2000 USDC,池子里一共 2500 USDC,那么: x = 2500y = k/x = 2000/2500 = 0.8 这个 0.8 意味着,为了保证 AMM 池子里的 k 值恒定为 2000,池子需要转出 0.2 WETH。也就是说,我们会得到 0.2 个WETH。 第二次兑换 我们再来用 500 USDC 买一次,此时池子里一共有 2500+500=3000 USDC,则: x = 3000y = k/x = 2000/3000 = 0.667 这个恒定乘积公式计算得出池子里应该保留 0.667 个 WETH,上一轮交换后还剩 0.8 WETH,所以这一轮我们实际得到 0.8-0.667 = 0.133 WETH。 对比来看,第一次用 500 USDC 可以换出 0.2 WETH,第二次用 500 USDC 就只能换出 0.133 WETH 了。随着池子里流动性的减少,WETH 的价格涨了。 价格曲线 这就是自动做市商的核心逻辑,价格不是写死的,而是根据池子中剩余的流动性算出来的。要注意 x 和 y 的乘积是一条曲线,因为 y=k/x,画成图是这样: 接下来会用实际的操作步骤与链上交互,来体验 AMM 的运作。 示例合约 合约代码源文件在仓库:smallyunet/defi-invariant-lab@v0.0.1 首先准备两个合约,一个是 TestERC20.sol,比起标准的 ERC-20 合约,支持自定义代币精度,以及随意 mint 一些代币。 第二个要准备的合约是 SimpleAMM.sol,提供了对代币增加流动性、代币兑换等功能。合约代码不算很简单,我们会在接下来实际的操作用,逐步体会和理解这个合约的功能,以及解读源代码。 以下所有操作都在以太坊的测试网 Sepolia 上进行。 环境准备 准备好命令行工具,以及设置两个环境变量: foundryupexport RPC_URL="https://ethereum-sepolia-rpc.publicnode.com"export PK_HEX="<YOUR_PRIVATE_KEY_HEX>" 下载合约仓库、进入到仓库根目录: git clone https://github.com/smallyunet/defi-invariant-lab/git switch v0.0.1cd defi-invariant-lab 部署代币合约 部署合约 部署两个测试版本的 ERC-20 代币,一个叫 USDC,一个叫 WETH: forge create \ --rpc-url $RPC_URL \ --private-key $PK_HEX \ --broadcast \ contracts/libs/TestERC20.sol:TestERC20 \ --constructor-args "USD Coin" "USDC6" 6 部署的合约地址是:0x84637EaB3d14d481E7242D124e5567B72213D7F2。 forge create \ --rpc-url $RPC_URL \ --private-key $PK_HEX \ --broadcast \ contracts/libs/TestERC20.sol:TestERC20 \ --constructor-args "Wrapped Ether" "WETH18" 18 部署的合约地址是:0xD1d071cBfce9532C1D3c372f3962001A8aa332b7。 验证合约 如果愿意,可以这样验证下合约: export ETHERSCAN_API_KEY=你的keycast abi-encode "constructor(string,string,uint8)" "USD Coin" "USDC6" 6forge verify-contract \ --chain-id 11155111 \ 0x84637EaB3d14d481E7242D124e5567B72213D7F2 \ contracts/libs/TestERC20.sol:TestERC20 \ --constructor-args "0x000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000855534420436f696e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000055553444336000000000000000000000000000000000000000000000000000000" \ --etherscan-api-key $ETHERSCAN_API_KEYforge verify-contract \ --chain-id 11155111 \ 0xD1d071cBfce9532C1D3c372f3962001A8aa332b7 \ contracts/libs/TestERC20.sol:TestERC20 \ --constructor-args $(cast abi-encode "constructor(string,string,uint8)" "Wrapped Ether" "WETH18" 18) \ --etherscan-api-key $ETHERSCAN_API_KEY 部署 AMM 合约 部署合约 这里的参数 30 指收取 0.3% 的手续费: forge create \ --rpc-url $RPC_URL \ --private-key $PK_HEX \ --broadcast \ contracts/amm/SimpleAMM.sol:SimpleAMM \ --constructor-args $USDC_ADDR $WETH_ADDR 30 部署的合约地址是:0x339278aA7A09657A4674093Ab6A1A3df346EcFCF` 验证合约 forge verify-contract \ --chain-id 11155111 \ 0x339278aA7A09657A4674093Ab6A1A3df346EcFCF \ contracts/amm/SimpleAMM.sol:SimpleAMM \ --constructor-args $(cast abi-encode "constructor(address,address,uint16)" $USDC_ADDR $WETH_ADDR 30) \ --etherscan-api-key $ETHERSCAN_API_KEY mint 代币 声明钱包地址与合约地址: export MY_ADDR=0x44D7A0F44e6340E666ddaE70dF6eEa9b5b17a657export AMM_ADDR=0x339278aA7A09657A4674093Ab6A1A3df346EcFCFexport USDC_ADDR=0x84637EaB3d14d481E7242D124e5567B72213D7F2export WETH_ADDR=0xD1d071cBfce9532C1D3c372f3962001A8aa332b7 挖 100 万个 USDC,精度是 6 位数: cast send $USDC_ADDR "mint(address,uint256)" $MY_ADDR 1000000000000 \ --rpc-url $RPC_URL --private-key $PK_HEX 挖 1000 个 WETH,精度是 18 位数: cast send $WETH_ADDR "mint(address,uint256)" $MY_ADDR 1000000000000000000000 \ --rpc-url $RPC_URL --private-key $PK_HEX 铸币的交易与结果可以直接在浏览器上看到,这个 是挖 USDC 的交易,这个 是挖 WETH 的交易。 给 AMM 合约授权 给 AMM 授权是因为接下来想要给 AMM 添加流动性,添加流动性会调用 addLiquidity 函数,其中用到了 transferFrom,所以需要先给合约授权,让合约可以动用我的 USDC 和 WETH 代币: cast send $USDC_ADDR "approve(address,uint256)" $AMM_ADDR "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" \ --rpc-url $RPC_URL --private-key $PK_HEXcast send $WETH_ADDR "approve(address,uint256)" $AMM_ADDR "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" \ --rpc-url $RPC_URL --private-key $PK_HEX 交易哈希分别是 USDC 和 WETH。 添加初始流动性 添加流动性的 函数 比较简单,大概是合约里有两个变量 reserve0 和 reserve1,调用 addLiquidity 函数的时候,会向 AMM 合约转账参数数量个代币。 先以 2000 USDC / 1 WETH 的价格,添加初始流动性: cast send $AMM_ADDR "addLiquidity(uint256,uint256)" 200000000000 100000000000000000000 \ --rpc-url $RPC_URL --private-key $PK_HEX 交易 完成后,可以查询到 AMM 合约剩余的代币数量: cast call $AMM_ADDR "getReserves()(uint112,uint112)" --rpc-url $RPC_URL# 200000000000 [2e11]# 100000000000000000000 [1e20] 用 USDC 换 WETH 合约代码解读 我们的合约代码 swap0For1 是这样: function swap0For1(uint256 amtIn) external returns (uint256 out) { require(token0.transferFrom(msg.sender, address(this), amtIn), "t0in"); // 把用户的 x 转进合约 uint256 r0 = token0.balanceOf(address(this)); // 查询当前 x uint256 r1 = token1.balanceOf(address(this)); // 查询当前 y uint256 amtInEff = (amtIn * (10_000 - feeBps)) / 10_000; //计算扣除手续费后,用户转入了多少 x // x*y=k, solve out = r1 - k/(r0) uint256 k = (r0 - amtInEff) * r1; // 计算 k out = r1 - Math.ceilDiv(k, r0); // 计算给用户多少 y require(token1.transfer(msg.sender, out), "t1out");} 函数代码体现了刚才描述的关于 x*y=k 的恒定公式。因为 AMM 合约考虑到收手续费的问题,所以有一个 amtInEff 用来表示用户实际转入了多少 x。 测试第 1 次交换 我们来实际发起交易,看看合约运行后的效果,先试着用 1000 USDC,看能换多少个 WETH 出来: cast send $AMM_ADDR "swap0For1(uint256)" 1000000000 \ --rpc-url $RPC_URL --private-key $PK_HEX 交易 完成后,查看一下代币余额: cast call $USDC_ADDR "balanceOf(address)(uint256)" $MY_ADDR --rpc-url $RPC_URLcast call $WETH_ADDR "balanceOf(address)(uint256)" $MY_ADDR --rpc-url $RPC_URLcast call $AMM_ADDR "getReserves()(uint112,uint112)" --rpc-url $RPC_URL 其实区块链浏览器上能很直接的看到交换的数量,交易哈希是:0xf13bd1d1602d7c106c2acdf4cb3b1ec37fa42d8871a682e32cce3f2049fff5a2 我们转出了 1000 USDC,收到了 0.496019900497512437 个 WETH。这里因为有 0.3% 的手续费,所以收到的 WETH 不是 0.5。 除了手续费,还存在一个价格的问题,按理来说,随着剩余 WETH 数量的减少,WETH 的价格会越来越高。 测试第 2 次交换 再来用 1000 USDC 兑换一次,看能换出多少 WETH: cast send $AMM_ADDR "swap0For1(uint256)" 1000000000 \ --rpc-url $RPC_URL --private-key $PK_HEX 这次兑换的交易哈希是:0x1ee9ceb0707d77d78669bfb6cc1179bf9d6b31c57b868f5f52ed2f01a4127481 这一次,花费了 1000 USDC,收到了 0.491116179005960297 个 WETH。与上一次兑换的结果相比,收到的 WETH 真的减少了。 用 WETH 换 USDC 可以自己测试玩一下。

2025/8/20
articleCard.readMore

Go 语言 GMP 调度器的原理是什么

声明:我看不起 “Go 语言 GMP 调度器的原理是什么” 这种技术话题。 我平时没兴趣研究这种问题。因为在面试中被问到的频率太高了,现在想花 2 个小时的时间来了解下。一方面研究下这个问题背后到底有多大的技术含量,另一方面把这个问题的答案写下来。但是我不会让这种内容停留在我的头脑里,所以下次面试被问到,我肯定还说不会 😏 基本概念 GMP 是一个缩写: G(goruntine):就是协程,代码里每 go 一个,G 的数量就多一个 M(Machine):就是系统级别的线程,在其他语言里的 thread P(Processor):数量为 GOMAXPROCS,通常默认是 CPU 核心数。 GMP 的意思是,启动多少个 M(线程) 来执行 G(协程),最多允许 P(核心数)个 M 并行执行。 三个不变量 无聊的(简化后的)定义来了: 只有拿到 P 的 M 才能执行任务 可运行的 G 只会在某个 P 的本地 runq 或者全局队列 当 M 进入阻塞状态(syscall/cgo)时,会及时把 P 让出 这几句话看着很费劲,不需要现在理解,接下来会用一些代码例子来说明他们的含义。 GMP 的调试日志 这是一个最简单的代码文件,用来演示启动一个协程: package mainimport ( "fmt" "sync")func main() { var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() fmt.Println("Hello from goroutine") }() wg.Wait()} 然后带上调试参数运行一下: go build demo0.goGODEBUG='schedtrace=200,scheddetail=1' ./demo0 注意不要用 go run,因为会引入一些 Go 语言运行时的日志。这个二进制版本的日志比较干净,内容是: SCHED 0ms: gomaxprocs=10 idleprocs=7 threads=5 spinningthreads=1 needspinning=0 idlethreads=0 runqueue=0 gcwaiting=false nmidlelocked=1 stopwait=0 sysmonwait=false P0: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0 P1: status=1 schedtick=0 syscalltick=0 m=2 runqsize=0 gfreecnt=0 timerslen=0 P2: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0 P3: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0 P4: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0 P5: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0 P6: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0 P7: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0 P8: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0 P9: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0 M3: p=0 curg=nil mallocing=0 throwing=0 preemptoff= locks=1 dying=0 spinning=true blocked=false lockedg=nil M2: p=1 curg=nil mallocing=0 throwing=0 preemptoff= locks=6 dying=0 spinning=false blocked=false lockedg=nil M1: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=2 dying=0 spinning=false blocked=false lockedg=nil M0: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=1 G1: status=1() m=nil lockedm=0 G2: status=4(force gc (idle)) m=nil lockedm=nilHello from goroutine 这些日志显示了这些信息: 第一行 SCHED 开头的是汇总信息,告诉我们程序启动了 10 个 P(gomaxprocs=10)。 只有 P1 被 M2 拿着运行 P0 被 M3 拿着处于 spinning 状态,也就是等待任务的状态。 没看到 print 相关的 G,是因为任务运行时间太短了,没被 trace 捕获就结束了,这里主要展示 GMP 的详细信息可以用 debug 命令来看。 抢占式调度 package mainimport ( "fmt" "runtime" "time")func busy(tag string, d time.Duration) { end := time.Now().Add(d) x := 0 for time.Now().Before(end) { x++ } fmt.Println(tag, "done", x)}func main() { runtime.GOMAXPROCS(1) go busy("A", 1500*time.Millisecond) busy("B", 1500*time.Millisecond)} 这个代码的运行结果是,有时候 A 在 B 前面,有时候 B 在 A 前面。 我们已经用 runtime.GOMAXPROCS(1) 设定只有一个 P,但是 Go 语言的 GMP 调度器,仍然会 10ms 释放一次时间片,也就意味着,即使 go busy("A") 处于阻塞状态,时间片之后也会让出执行权,交给主线程去运行 B。 可以用这个 busy 的函数定义来让抢占式调度更加肉眼可见: func busy(tag string, d time.Duration) { end := time.Now().Add(d) next := time.Now() for time.Now().Before(end) { if time.Now().After(next) { fmt.Print(tag, " ") // 每 ~100ms 打印一次 next = time.Now().Add(100 * time.Millisecond) } } fmt.Println(tag, "done")} 程序的打印结果会是 B A B A B A A B A B A B A B A B A B A B A B A B B A B A B A B done。这意味着不是 tag 为 A 的 P 一路执行到底,也不是 tag 为 B 的 P 一路执行到底,他们在 GMP 调度器中交替执行。 P 偷活干(work-stealing) 来看这个代码示例: package mainimport ( "runtime" "sync" "time")func spin(d time.Duration) { deadline := time.Now().Add(d) for time.Now().Before(deadline) { } // 纯CPU忙等}func main() { runtime.GOMAXPROCS(1) // 先让所有 G 挤到同一个 P 的本地队列 const N = 120 var wg sync.WaitGroup wg.Add(N) for i := 0; i < N; i++ { go func() { defer wg.Done(); spin(500 * time.Millisecond) }() } time.Sleep(30 * time.Millisecond) // 给点时间把队列堆满到 P0 runtime.GOMAXPROCS(4) // 突然放大并行度:P1~P3 会去“偷” P0 的一半 wg.Wait()} 这个代码干了什么呢,首先设定之后一个 P,然后启动 120 个 G 给这个 P 去执行。30 毫秒后,突然增大 P 的数量。 用 debug 日志能看到,运行后半段有这样的日志: P0: status=1 schedtick=46 syscalltick=2 m=0 runqsize=17 gfreecnt=0 timerslen=0P1: status=1 schedtick=58 syscalltick=0 m=4 runqsize=5 gfreecnt=15 timerslen=0P2: status=1 schedtick=60 syscalltick=0 m=2 runqsize=5 gfreecnt=18 timerslen=0P3: status=1 schedtick=42 syscalltick=0 m=3 runqsize=17 gfreecnt=0 timerslen=0 也就是说,本应该 G 全在 P0 上运行,等到 P1、P2、P3 出来后,它们发现 P0 很忙,就去 P0 的队列里拿了几个任务过来执行。 P 的 runq 队列和全局队列 一个 P 想找活干的时候,上面的代码是偷其他 P 的示例。更严谨的流程是,P 先从本地 runq 队列找,再到全局队列找,找不到再去偷其他 P 的。 什么是 runq 队列,什么是全局队列?可以看这个代码: package mainimport ( "runtime" "sync" "time")func spin(d time.Duration) { end := time.Now().Add(d) for time.Now().Before(end) { } // 纯CPU忙等:保持 runnable}func main() { runtime.GOMAXPROCS(1) // 只有 P0:所有新 G 先进入 P0 的本地 runq const N = 600 // 让它明显超过本地 runq 容量(当前实现通常是 256) var wg sync.WaitGroup wg.Add(N) for i := 0; i < N; i++ { go func() { defer wg.Done(); spin(800 * time.Millisecond) }() } time.Sleep(500 * time.Millisecond) // 给运行时时间把“溢出的一半”推到全局队列 runtime.GOMAXPROCS(4) // 其它 P 进场,会先从“全局队列”拿活(不是偷) wg.Wait()} debug 状态运行: go build demo4.go GODEBUG='schedtrace=200,scheddetail=1' ./demo4 &> demo4.log 日志会比较多,日志前面几行像这样: SCHED 0ms: gomaxprocs=10 idleprocs=9 threads=2 spinningthreads=0 needspinning=0 idlethreads=0 runqueue=0 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false P0: status=1 schedtick=0 syscalltick=0 m=0 runqsize=0 gfreecnt=0 timerslen=0 P1: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0 其中首行的 runqueue=0 就是全局队列,P0 后面的 runqsize=0 是 P0 的本地队列,P1 后面的 runqsize=0 是 P1 的本地队列。可以看到此时的 P1 状态是 0,也就是不可运行。 随着程序的运行,P0 会启动非常多个 G,日志状态是这样: SCHED 200ms: gomaxprocs=1 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=3 runqueue=395 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false P0: status=1 schedtick=10 syscalltick=2 m=0 runqsize=204 gfreecnt=0 timerslen=1 一般 P 的本地队列默认是上限是 256,达到这个峰值后,就会把任务溢出到全局队列。 再然后,P1、P2、P3 启动,开始从全局队列拿任务(全局队列有任务则不需要偷其他 P 的): SCHED 826ms: gomaxprocs=4 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=217 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false P0: status=1 schedtick=35 syscalltick=2 m=0 runqsize=179 gfreecnt=0 timerslen=0 P1: status=1 schedtick=14 syscalltick=0 m=3 runqsize=90 gfreecnt=0 timerslen=0 P2: status=1 schedtick=14 syscalltick=0 m=4 runqsize=64 gfreecnt=0 timerslen=0 P3: status=1 schedtick=13 syscalltick=0 m=2 runqsize=46 gfreecnt=0 timerslen=0 另外,当 P 依次从本地 runq、全局队列、其他 P 都找不到任务时,会再去问一下 netpoll(问一下 OS)有没有新的 G,要是有就执行,没有就自旋(待命)。这就是 P 执行任务的逻辑。 阻塞 syscall 会及时让出 看这个代码例子: package mainimport ( "fmt" "runtime" "time")func main() { runtime.GOMAXPROCS(2) go func() { time.Sleep(2 * time.Second) // 类比阻塞 syscall/cgo fmt.Println("blocking done") }() go func() { for i := 0; i < 6; i++ { time.Sleep(300 * time.Millisecond) fmt.Println("still running", i) } }() time.Sleep(3 * time.Second)} 运行结果会是: still running 0still running 1still running 2still running 3still running 4still running 5blocking done 这个代码示例的含义是,第一个 G 明明会阻塞任务队列,一直占着 P 执行,但实际上第二个 G 仍然在运行。 说明 GMP 调度器不会因为某个 G 的阻塞,影响到其他 G 的执行。(其实这是协程调度器很基本的要求) 关闭异步抢占 对于这个代码示例: package mainimport ( "fmt" "runtime" "time")func spin() { for { /* 紧密循环 */ }}func main() { runtime.GOMAXPROCS(1) go spin() time.Sleep(100 * time.Millisecond) fmt.Println("I should still print unless preemption is off")} 可以分别用两个命令来运行,一个是 go build demo7.goGODEBUG='schedtrace=1000,scheddetail=1' ./demo7 另一种是: go build demo7.goGODEBUG='schedtrace=1000,scheddetail=1,asyncpreemptoff=1' ./demo7 用 asyncpreemptoff=1 可以关闭异步抢占。也就是说,如果没有关闭,没有带这个参数,程序会正常运行,打印出: I should still print unless preemption is off 如果关闭了异步抢占,则程序会被死循环卡住。这个例子主要可以体现 GMP 主动让出 CPU 的特点,当关闭了主动让出的能力后,GMP 就会被阻塞住了。 Go 语言源码 我没有深入看源码,比如 G、M、P 的常量定义在 src/runtime/runtime2.go 文件: 再比如 src/runtime/proc.go 文件中的 runqputslow 函数,功能就是判断本地队列有没有满,如果满了就放到全局队列: 进一步深入 这篇文章肯定有不全面和不到位的地方,我不想进一步深入了,也许有人喜欢折腾这些吧。 Go 语言的 GMP,就是协程调度器的一种具体的工程化的实现,估计很多人在意的,是这种工程化实现背后的细节,比如怎么用栈结构来管理任务队列、怎么实现抢占、让出逻辑等。协程调度器的具体实现方式可以有各种各样的变化,但它们的基本原理都是 continuation。只是 Go 语言把协程作为卖点了。只要其他语言愿意,也是可以开发出自己版本的协程调度器的。 那么问题来了,那些喜欢研究 GMP 原理的人,你们有没有了解过其他语言的协程(coroutine)、虚拟线程、异步函数、Process 是怎么实现的,它们都是比线程更轻量的类似于协程的东西,和 Go 语言的 gorountine 有什么区别?横向对比一下? 如果什么时候,我的工作需要,只有我了解这些内容,才能把工作做好,那么我肯定去把这些东西搞明白。 疑问 我之前写过一个观点: Go 语言 “千辛万苦” 做出了自动的垃圾回收,减轻程序员对于内存管理的头脑负担。而有些面试官 “千辛万苦” 去搞明白 Go 语言 GC 的原理是什么,怎么标记怎么释放之类,不但引以为豪,而且拿来考察候选人。作为 Go 语言的教徒,你知不知道你的行为在否定 Go 语言设计者的努力?如果真的相信用头脑来管理内存的力量,为什么不去搞 Rust?好比我是一个汽车驾驶员,我要去考驾照,难道需要我搞清楚发动机的工作原理、是怎么把汽油燃烧转变为机械动力的、能量转化公式是什么?我又不是在制造汽车,也不是在开发编程语言。 同样的道理: Go 语言为了让广大程序员能便捷简单地、用上轻量级的协程,“千辛万苦” 搞出来一个 go 关键字,然而有些人却费尽 “千辛万苦” 研究这个调度器是怎么实现的,懂原理则说明会 Go 语言,不懂则说明 Go 语言水平不行,这是什么道理?作为 Go 语言的教徒,你在否定 Go 语言设计者的努力,明白吗?如果这个语言需要你搞清楚协程调度的原理,才能写出好的代码,那就说明这个语言实现的不到位,偏离了设计者的初衷,没有达到设计者本来的意图。 如果你是编程语言的开发者,需要在另一种语言中借鉴、实现、优化 Go 语言的调度器,那么你就尽情研究吧,这样的工作确实需要懂 GMP 调度器的原理。如果不是那样的工作呢?

2025/8/18
articleCard.readMore

Web3 项目分析计划

Web3 项目分析系列文章专用的 Paragraph 频道地址是: https://paragraph.com/@smallyu 计划内容 经过几天时间的尝试,我觉得 Web3 项目分析计划是一件很有意义的事情。不清楚看到分析文章的人有没有收获,但是从我自己理解项目、学习技术的角度,是有收获的,所以我需要把这个计划继续下去,变为一件常态化的事情。 具体计划内容是,每周分析一个 Web3 行业的项目,从看白皮书开始,到理解项目的运作模式、当前商业状态等,尤其关注技术理念和技术创新方面,然后写成分析报告,不需要很专业的那种报告,大概相当于学习笔记就可以了。具体分析哪个项目是经过主观挑选的。最终的分析报告也许会有质量,有看点,但也许会比较短,没有质量。因为我并不能在一开始选定项目的时候,就知道这个项目有没有含量,尤其是技术含量。 工程代码没有价值 这个计划有点像是区块链研究员干的事情,而不是区块链程序员应该干的事情。为什么我的计划不是每天写 100 行代码,开发一个区块链小工具,或者每天积累一点,开发一个大的区块链工程? 因为工程代码如果脱离项目背景,就没有价值。我在几年的工作中写过很多代码,但是如果现在把那些代码拿出来,会发现毫无意义。工程化的代码,往往是为了完善项目的功能,而项目需要某些方面功能,是为了迎合运营和宣发的需求,一定是有商业目的的。如果需求背景不存在,代码就毫无价值。 尤其是随着 AI 的日益强大,写工程代码这件事情更是越来越廉价。AI 可以几分钟写出上万行代码,堆砌代码的能力绝对超过人类。如果我想靠每天写几百行工程代码来训练和提升自己,那我一定会失败的很惨。所以不能干这样的事情。 什么样的工程代码是有意义的呢?就是已经找准了产品需求和定位的情况下,想把功能落实跑通,然后让 AI 来干活,把代码写出来。AI 写的代码有时候会跑偏,需要手动修复一下 bug,这种情况下,手动写出的工程代码才有意义。现在的开发节奏已经应该是这样了。 以前的时代,人们喜欢说 “Talk is cheap. Show me the code”,但是现在时代变了, prompt 比 code 更有价值,也许这句话会变为 “Code is cheap. Show me the prompt”。 文章更能表达思想 不去计划每天写一些工程代码,另一个原因在于,我已经做过了一些尝试,去试图开发小的区块链工具,或者大的区块链工程。目前来看,我之前的想法,无论是做小工具的思路,还是做大工程的思路,都是没有结果的,因为需求本身也许不存在。没有任何正反馈,根本做不下去。 与代码相比,写文字、写文章、写观点更有意义一点。一个产品创意背后,可能有 100 行代码,也可以有 10000 行代码,需要付出的时间成本完全不同,但如果最终的关注量都是 0,那么结果就是一样的,9900 行代码白写了。而文字是能够体现思想的。 你也许想反驳,怎么能说工程代码没有价值呢?以太坊的客户端同一份 Spec,有五六种工程化的实现,用了不同语言、做了不同优化,市场占有率有高有低,难道不是工程化代码价值的体现吗?当然是,他们拿着以太坊基金会的赞助开着公司写着代码,而且已经有了明确的项目背景,工程代码自然是有价值的。我指的是没有项目背景的工程代码。 虽然工程代码没有价值,但教学性质的代码是很有意义的,我仍然会复习计算机课的练习题,以保证自己的代码水平。我已经是第三轮做那些练习题了,这次我严格限制自己的做题速度,一天最多做一道题。一方面是保证有足够的时间消化练习题包含的知识,相信潜意识的力量。另一方面,得分配时间到其他事情上,不能整天只反复做同样的题。而且由于做题比较慢,可以逐渐培养自己每天做题的习惯,不至于遗忘计算机课的知识。 提高宏观理解能力 为什么我觉得对项目做分析是有意义的?因为其实我对区块链技术的理解,很大程度上,来自于几年前读了很多白皮书。我当时按照币种市值的排名,逐一下载了排名前几百的币种白皮书,还用 A4 纸都打印出来看。 记得几年前有人发邮件问我,如何学习区块链技术。我当时认真写了个回复,说我是从哪个网站下载的白皮书,以及看了哪些书之类。后来对方回复我说,这不是他想要学习的区块链技术,他想要学习的是如何写代码。那个时候我才意识到,不同的人,对技术的定义是不同的。 以前没有 AI,我没能认识到代码的价值,现在有了 AI,我还是认识不到代码的价值。 研究能力的重要性 在币圈,人们常说 DYOR(Do Your Own Research),这个词经常出现在 KOL 推广和夸赞某个代币的时候,用来声明不做投资建议,你要自己对自己负责。“研究能力” 一直都是非常重要的能力,如果不具备好的研究能力,你连自己的钱都管理不好。事实上什么事情都需要研究,研究如何学英语、研究如何找工作、研究假期去哪儿玩、研究写代码、研究科学技术、研究如何哄女朋友开心,等等,都是研究。Web3 项目分析计划的目标正是研究项目、锻炼研究能力。 具备好的研究能力的人,不管学习什么都会变得轻松。试想,你觉得去研究明白怎么把代码写好,尤其是工作中用的普通代码,需要多长时间?很多时候连 “研究” 都用不着!那么,你觉得能把某种技术研究明白的人,会没有能力研究清楚怎么写代码吗? 那么为什么我觉得自己可以写出分析报告?我以前没专门写过,但是有时会根据技术来对项目做横向对比,所以专注于对某个项目做技术分析,应该不是难事。我工作过的项目,假如让我写分析,肯定能写出其中的细节,只是因为项目还在,不能写。写项目分析对我来说也是一个学习和积累的过程。 实际上分析区块链项目的方法论,我早在《看懂任意区块链项目的技术架构》就写过了,到现在都不觉得那篇文章内容有什么问题,无非就是链上链下交互,不同项目往里面填充不同的业务逻辑而已。 写作平台的选择 我对于博客上应该放哪些文章,是比较纠结的,我不希望一打开博客,满屏幕都是 “对 XX 项目的分析”。为了保持文章列表的简洁,这些项目分析系列的内容应该换一个平台放。最近看到 Paragraph 不错。Paragraph 是一个 Web3 领域的 Newsletter 平台,功能类似于 Web2 的 Substack,每篇文章的全部内容都会提交到 Arweave 区块链上,包括作者的名字、头像、文章正文、配图等。(这也就意味着文章一旦发布,就不可能被删除。) 为什么不选择其他平台呢?比如发到知乎、掘金,甚至是头条、百度、登链等平台,再加上 Meidum、X、Mirror 一类,文章访问量肯定可以高很多,关注量也会高很多。 因为那些充斥着低质量内容的平台,不值得去发布高质量内容的文章。那些人是看不懂的,看不懂我在写什么。看看掘金首页上有什么?10 篇文章 8 篇讲 Cursor,很难想象用户素质得多低。知乎就更不用说了,内容杂乱、商业化,关键是网页访问弹窗,不是让登陆就是让下载 APP,正经人谁去那种平台啊。我在脉脉的职言区,匿名账号下,发布过几千条帖子,总阅读量超过几千万,发的都是观点偏激、引战一类的内容。那种阅读量有意义吗?没有意义。 所以继续努力吧,等自己成为 somebody,再考虑访问量的问题。没有人会关心 nobody 写的东西。 为什么要做出计划 其实要按照我自己喜好,我觉得自己真正有价值的文章,是吐槽同事、吐槽公司、吐槽面试经历等情绪宣泄类内容。那些是包含了亲身经历、切实体会、真情实感在里面的,耗费了时间和心情才得到的、宝贵的人生体验,比技术文章有意思多了。对行业的见解、对公司的不满、对同事的吐槽,是我的文章永远超越 AI 的地方,因为 AI 没有情绪,不会生气、不会沮丧。单纯讲技术知识点,AI 一下子就能生成很多,但是 AI 永远无法体会到作为人的情感。 反正人总要做选择,要么忙着活,要么忙着死。

2025/8/14
articleCard.readMore

对 0G 项目的分析

首先我不是很看好 0G 的技术含量,因为 0G 是中国团队开发的项目。0G 是一个 AI 赛道的项目,3 月份在 TinTinLand 上发布过招聘信息,大概 9 月份要发币的样子,猜测在 AI 方面的噱头大于技术积累。我因为最近加了一个 TinTinLand 的学习群,和 0G 合作推出社区课程那种,所以稍微有点兴趣来分析下这个项目。 0G 的官网地址是 0g.ai,在官网上就极尽所能的把各种名词摆上了,”the next generation”、“decentralized AI”、”DeAIOS”、”RWA”,用词口径越大通常不是一个好兆头。 项目背景 0G 在 2024年8月 发布了 白皮书,单从白皮书目录和篇幅来看不是很乐观,目录结构比较简单,一共只有 20 页的内容。篇幅长度是肤浅的判断方式,比特币的白皮书也才 9 页。主要是目录结构,作为一个 AI 技术导向的项目,如此简洁的章节会给人草台的感觉。 首先来看看摘要里怎么说,0G 在解决的是 AI 模型训练过程中透明度的问题: 话说,看到 modular 这个词我有点不好的预感,尤其是看到 DA 这个词后,心想该不会用的 Celestia 吧,结合官网首页上宣称的 2500/s 的 TPS,有哪条链能做到呢?Cosmos 有点像。不过到这里还不理解首页上说的 8K 个 validator 是什么含义,Cosmos 可做不到这个。 好在不是 Celestia,白皮书里没详细说技术选型的事,但明显和 Celestia 是并列关系,自己搞了个叫 0G DA 的链。 白皮书里详细解释了 PoRA(Proof of Random Access)的挖矿机制,这个是有技术含量的部分,与 Filecoin 冷储存的模式不同,0G Storage 强调链上可以即时访问数据,所以设定了 8TB 的挖矿窗口,要求矿工可以快速在范围内验证数据完整性。 PoRA 的局限性在于,通过随机抽样验证的方式,可以验证矿工是否拥有完整数据,但是不能证明矿工拥有的数据是唯一的,也就是缺少 Filecoin 的 PoRep 提供的能力。这与网络面对的场景以及经济模型设计有关,0G Storage 只希望保证数据的可用,从矿工的奖励方式上限定了作恶是不能得到更多奖励的,所以整体机制上奏效。而 Filecoin 是根据算力高低给奖励,要面对的问题不一样。 从官网的第一篇 博客文章 中能更直观看到一些信息,0G 包含两个关键组成部分:0G Storage 和 0G DA,本质上在解决的就是 DA 的问题,主要是试图把这种 DA 能力用到 AI 场景中,所以分类到 AI 赛道了。项目背景上是一个分布式存储类的区块链项目。 0G 去年得到了 3 千万美元的种子轮融资,还是挺有资本的。 具体到工程实现上,可以看到 0G Storage 的 代码 基于 Conflux 的节点代码,在其之上做了一些功能开发: PoRA 的工程实现部分就不深究了。 项目架构 刚才从项目背景的角度,只提到了 0G Storage 和 0G DA 两部分,除此之外,0G 这个项目还有两个角色,0G Chain 和 0G Compute Network。估计一开始的项目规划里没有,所以白皮书里没提。 0G Chain 是一个用 Cosmos SDK 开发的链节点(终于看到 Cosmos 的身影了),而且是直接用了 evmos 来兼容以太坊智能合约的做法: 0G Chain 的仓库最后一次提交代码是在 5 个月前,也许已经放弃了用 Cosmos SDK 的路线。因为有一个近期比较活跃的仓库 0g-geth,看起来是在做 Geth 的二次开发,通过集成预编译合约的方式,加入对 0G DA 的支持。 0G Compute Network 是真正和 AI 模型训练相关的部分,现在已经支持一些 预训练模型 的使用。用户层面的使用比较简单,类似于 OpenAI 的 SDK 一样,发起请求,得到响应,就是一个 Client 层的 SDK。 给 0G Compute Network 的模型提供算力的节点叫 Provider,代码仓库是 0g-serving-broker,代码仓库里有体现模型训练的代码,比如 finetune.py 这个脚本是基于 Transformer 做文本模型的微调,Docker 容器是直接基于 pytorch 2.5.1-cuda12.4-cudnn9-devel 的容器打包。 所以从 LLM 模型训练的角度看,0G 有一些工程方面的技术内容。只不过 0G 在干的事情是微调(Fine-tuning),也就是基于预训练(Pre-training)好的模型,进一步用较小的算力训练,达到执行某种特定任务的效果。而我们平时看到的 OpenAI 和 Grok 等大公司,动辄 1 TB tokens 的训练量,干的事情才是预训练。 比如 OpenAI 训练并开源出一个 GPT-3 模型(实际上没开源),那么 0G Compute Network 就是基于这个 GPT-3 模型,结合自己的语料进行一些微调,训练出一个自己版本的 GPT-3 模型。大概就是这个意思。 更准确一点说,0G Compute Network 是提供了一个训练的场地,结合了区块链相关的经济模型、奖励机制等交互,让用户可以给微调这件事情提供算力并获得收益,另一些用户可以使用微调之后的模型。 至于 Provider 与链上合约交互的部分,应该就好理解了。0G 是用 Solidity 写的合约 0g-serving-contract ,对合约的调用自然也是以太坊生态的那一套组件。而 0G 需要做的,就是把模型微调(训练)的结果,以及关于训练任务的分发、奖励记录、惩罚机制等,用合约来实现,然后在链下的算力节点上集成对合约的交互。 总结 综合来看,我需要改正一开始的态度,0G 是有一些技术含量在的,只不过更加侧重于工程方面的技术,无论是区块链方面的 DA,还是 AI 方面的模型微调,其实做的都不错,业务逻辑上已经能形成闭环。 但是说实话,写 0G 项目的分析,比之前写其他项目的分析,思路稍微不清晰一点,因为白皮书和文档都不是很完善,项目的技术路线又不是特别统一,所以没有非常好的资料自上而下的贯彻整个项目结构。不过经过以上内容的分析,我想应该已经刨析清楚了 0G 这个项目的技术情况。

2025/8/6
articleCard.readMore

对 Arcium Network 项目的分析

项目背景 Arcium Network 是 Solana 生态首个专注于隐私计算的项目,今年 5 月份获得了 GreenField 领投的 5 千万美元的 融资。 Arcium 的 Purplepaper 中提到,Arcium 是一种去中心化的隐私计算协议,主要包含了 MPC 和经济模型两个关键的组成部分: Arcium 整体的项目结构不算难理解,尤其是和区块链结合的部分,就是直接用了 Solana 的智能合约: 但是 Arcium 在有很多硬核的技术基础。比如在这篇 博客文章 中,Arcium 用简化的例子说明了 MPC 的技术原理。 MPC(Secure Multi-Party Computation,安全多方计算)这种技术不是新概念,存在很多年了,我们最为熟知的就是 MPC 钱包,币安钱包和 OKX 钱包都默认使用这种模式。从学习的角度,可以辨析一下 MPC、多签、MPC 钱包、TSS 聚合签名、BLS 聚合签名这几个有点关联但容易混淆的概念。 回到 Arcium 的文章,简化后的 MPC 大概是这个意思:假如有 3 个参与方 a,b,c 进行计算,计算的内容分别对应 +1,+2,+3,并且参与方会对自己的计算结果加盐分别是 +10,+20,+30,那么经过全部参与方计算后,最终得到的结果是 66,减去参与方的盐值总和 60,得到最终结果 6。 去盐的过程按理也是轮流来的,比如初始值是 66,从 a 到 c 依次渐去各自的盐值,得到最终结果,而不是一下子就减去了 60。对于每一个计算参与方来说,它只知道初始值,以及自己计算的结果值,并不知道执行的顺利,是先 a 执行,还是先 c 执行?协议约定这个信息不是公开的。 真实的 MPC 是一个复杂的交互协议,需要很复杂的工程化实现。总之 Arcium 就是在对 MPC 技术大做文章,试图把这种密码学技术,引入到更多实际的应用场景中,这是一个不错的方向。 Arcium 在研究的技术方向和尝试 很多,比如这篇 文章 中提到的 Confidential SPL Token,是结合了 Solana 的 SPL 标准代币、Token-2022 标准、和 Arcium 的 MPC 聚合架构,提出的一种带隐私能力的代币标准。此外还搞了链上 Dark Pools 的 Dapp,挺有加密风范的技术感。 项目架构 Arcium 的 项目架构 看似很复杂,乍一看 MXEs、arxOS、Arcis 什么的,各种名词。这种是典型的发明概念,就是给自己项目里用的某个组件,起了个高大上的名字,根本不是某种技术名词。几乎每个项目都会有一套自己的术语定义,让外人觉得很厉害。 从这个 Developer 版本的 文档 能更好的理解 Arcium 的架构。作为使用 Arcium 的开发者,实际上是在开发 Solana 智能合约,我们自己开发一个合约(MXE program)来描述计算任务,比如加法运算,a+b,期望得到 c,把这个逻辑写在合约上,然后调用官方部署的合约(Arcium Program)的合约,把计算任务提交到 Arcium 的任务池里: 这样,Arcium Program 就知道了有这么一个任务,而真正执行隐私计算任务的 MPC Cluster(arxOS),根据链上的交易记录,得到事件也就是任务信息,开始进行计算,并且将计算结果提交到链上的 Arcium Program(合约)。回到我们的 MXE program 这边,自然是有一个 callback 函数来接收隐私计算的结果,然后触发一个事件通知我们的客户端: 在合约代码层面,能够实现哪些计算,如加法、减法、除法之类,要依赖于 Arcium 提供的框架,支持哪些计算方式。 那么抛开凌乱的技术名词,Arcium 整体上,是通过链上合约提交计算任务,链下节点计算任务结果后,再提交回链上的模式,这是和区块链交互的部分。此外就是链下计算的过程,Arcium 把 MPC 折腾的很明白,提供了便于使用的客户端(合约)框架出来。 经济模型 具体执行多方计算的节点,Arcium 把它们叫做 StakHodlers,有点复杂,总之就是要么提供硬件设备、经过一系列配置之后参与到计算节点中,得到收益,要么把自己的 ARX 代币委托给某些计算节点,赚一点利息。 在经济模型方面,Purplepaper 里有提到 ARX 的代币总供应量,会随着网络算力的使用量自动调整,达到自动平衡的效果: 这种自动平衡期望的效果是,ARX 的质押率平衡在 50%,如果低于 50%,会自动增发,如果高于 50%,会自动销毁。这个经济模型的设计是利好计算节点的,是希望持有 ARX 的用户能积极参与到质押和计算生态中。但是这种经济模型不是很利于非质押者,因为增发与他们无关,销毁与他们有关,相当于放大了以太坊 PoS 模式的弊端。 不过 Arcium 的销毁模式和以太坊的销毁模式还不太一样,Arcium 会把协议费收上来的 SOL,通过荷兰拍卖换成 ARX,再销毁。这种模式给整体供给带来的影响更复杂一点,得进一步关注和分析了。 综合来看,无论是技术架构上,还是经济模型上,Arcium 都有非常深厚的积累,已经能形成闭环,有很大的进一步观察的空间。Arcium Network 目前是测试网阶段,路线图里还在规划主网的上线时间。

2025/8/5
articleCard.readMore

对 Camp Network 项目的分析

Camp Network 的愿景是在 AI Agent 场景中保护作者的知识产权,并且有可能获得来自 AI 分享的创作者收益。Camp Network 今年 4 月底宣布,一共获得了 3 千万美元的 A轮融资,来自不同的投资机构。 只看官网首页的简介的话,也许会有点疑惑,这个项目的立意肯定没错,保护知识产权嘛,但是具体怎么做呢,官方宣发文章 里提到的 Proof of Ownership and Pirority,岂不是几年前的概念吗?而这些 Proof of… 又是什么意思? Camp Network 没有公开出 Github 代码仓库,导致没办法从代码层面解读。我一开始还奇怪,为什么不公开 Github 账户?后来我明白了。 Camp Network 的 文档 里说明,Camp Network 是一条使用了 Celestia 区块链做 DA 层的链,基于 ABC Stack 搭建。ABC Stack 又是什么呢,是 Celestia 生态中的一个项目,在架构上明确区别于以太坊的 “L2 Rollups”,而是自己发明了一种架构叫 “Rollup L1s”。 ABC Stack 框架是 Abundance 团队开发的,号称每秒 GB 级别数据的 EVM 完备的 Rollups。也就是说,Camp Network 直接使用了 ABC Stack 的底层技术,搭了一条链出来,在链上做一些应用,所谓知识产权保护、DID、AI 什么的。 那么 ABC Stack 的框架怎么用呢,代码仓库在哪里,怎么操作,需要开发哪些代码?完全不需要,有一家 Celestia 生态的公司 Gelato,提供了 BaaS 平台的服务,比如一键部署 Op Stack 的链、一键部署 Arbitrum 的链,等等,其中就包括 ABC Stack 的选项。而这个 BaaS 平台是收费的,部署到主网需要一个月 3000 美元,部署到测试网只需要一个月 100 美元。Camp Network 目前是测试网阶段。 这就是 Camp Network 连代码仓库都不需要的原因,直接用 BaaS 服务就好了,而且也没有其他选择,ABC Stack 本身就没提供代码。 Camp Network 的 架构文档 里还提到一个东西,BaseCAMP 是刚才提到的用 ABC Stack 搭出来的链,SideCAMP 则是应用专属的链,从文档描述来看,Camp Network 是计划给每一个应用场景,比如 AI、音乐、艺术等 Dapp,都单独部署一条链,同样也是用 ABC Stack 的技术。 有那么多条链的话,BaseCAMP 和 SideCAMP 之间怎么通讯和交互呢?ABC Stack 对 Hyperlane 的跨链技术做了 封装,依此来实现 ABC Stack 链之间的跨链通信。 至于 Camp Network 到底是怎么实现保护知识产权的?Camp Network 提供了一个 SDK,大意是作者需要通过链上交易来声明自己的身份,比如绑定自己的链上地址和 X 的用户名信息,Camp Network 的这个 Origin 框架,就会到 X 上查询我发布的内容,自动生成 IP NFT,然后其他玩家可以来购买这个 IP NFT,相当于购买了版权。 话说,其实是挺老套的玩法。可能以后凡是看到提 DID 这种老套概念的项目,都得多加小心,因为没什么技术,只玩生态,而且玩的是不存在的生态。 Camp Network 整体的技术情况就是这样,连代码仓库都没有,直接用了 ABC Stack 提供的服务,搭建了几条链,然后在链上面做应用。Camp Network 的 测试网 上现在已经有不少 Dapp 了,可以操作和交互。Camp Network 虽然没有硬核的底层技术,但是生态实力很强大,测试网上参与活动的项目非常多。

2025/8/4
articleCard.readMore

对 Psyche Network 项目的分析

项目背景 Psyche Network 是 AI + Web3 赛道的一个项目,由 Nous Research 团队研发,两个月前获得了 Paradigm 机构 5 千万美元的 A 轮融资。 Psyche Network 的项目背景在 官方说明文章 里有详细介绍。Nous Research 团队研发出了一种去中心化的算法 DeMo,这种算法能够把大语言模型(LLM)的训练,放到分布式网络里进行,不需要集群服务那种高耦合。就类似比特币挖矿的矿池一样,会把大的计算任务,拆解为小的计算任务,分发给不同的 Client 节点进行计算,计算之后再把结果汇总起来。 当然 LLM 的训练和矿池的挖矿,从算法原理上完全是两码事,这里只是想类比说明便于理解。具体 DeMo 是怎么从算法角度把任务拆解和合并的,可以看 官方的解释,反正我没看懂,就是一堆向量、权重、loss function 什么的术语。关于怎么防止节点提交虚假数据之类,我认为也都在算法的设计范畴,后续就不多讨论算法本身的有效性了。 DeMo 的 论文 里用了 100 billion 的 tokens 做训练测试,得到了比较好的结果。100 B tokens 是什么概念呢,比如 DeekSeek-V3 的 tokens 数量是 15 TB,可见 DeMo 在实验阶段的 tokens 数量级,距离商用产品还差很多。可以对比一些其他模型的 tokens 数量: 模型参数量预训练 tokens 数量公开来源或泄露信息 GPT-3175 B≈ 499 B论文及后续综述 GPT-3.5175 B推测 ~1 T 左右— GPT-41.7 T≈ 13 T tokensSemiAnalysis / The Decoder 报告 Llama 370 B> 15 T tokensMeta 官方模型卡 DeMo OLMo1 B0.1 T tokens(100 B)DeMo 论文 Psyche Network 基于 DeMo 的算法原理,结合区块链来构建分布式网络,第一阶段的目标是训练出 40 B parameters, 20 T tokens 的模型。关于 parameters 和 tokens 这两个指标,我的理解是,parameters 是训练一开始就定义好的固定指标,tokens 则是需要不断进行计算和训练的,而 DeMo 解决的是 tokens 的分布式计算。Psyche Network 官网上有实时显示当前的训练进度,目前已经达到了 1 TB 的 tokens 数量: 这个模型训练完,也许可以接近 GPT-3 的水平。对比来看虽然 tokens 数量比 GPT-3 多,但是 parameters 比 GPT-3 少,所以最终效果应该不如 GPT-3。 项目结构 Psyche Network 的 文档 里有介绍整体的项目结构,比较好理解,有一个中心化的 Coordinator 负责创建训练任务,其余的 Client 负责接收任务、提交任务结果。在没有区块链的场景下,Coordinator 与 Client 之间的通信是通过直接的 TCP 连接完成的。而有了区块链之后,Coordinator 和 Client 之间就是通过区块链来传递消息了。 Psyche Network 的 代码仓库 里同时保留了 centralized 和 decentralized 两个版本的代码架构,这其实不太是好事,因为说明这个项目原本可以中心化运行,只是现在在做一些去中心化改造。这样的项目去中心化程度肯定是有限的。 而所谓去中心化版本的部分,Psyche Network 选择了 Solana 来作为运行智能合约的区块链平台,这也许和 Psyche Network 原本的项目就是用 Rust 语言有关。 代码仓库的 decentralized 目录下,有一些 Solana 的合约代码,这些 Solana 合约承担起了创建训练任务、计算每个 Client 节点的奖励、分发奖励的功能。 Psyche Network 目前只是测试网阶段,链上交易也都是在 Solana 的 Devnet 上进行,可以直接看合约文件里的 declare_id!() 语句,里面写的就是合约地址,比如 coordinator 的合约地址是 HR8RN2TP9E9zsi2kjhvPbirJWA1R6L6ruf4xNNGpjU5Y,能在 区块链浏览器 上看到频繁的交易记录。 至于奖励的计算,因为有 Coordinator 这个中心化角色的存在,所以事情比较简单,Coordinator 在收到 Client 地任务结果后进行验证,如果没问题,则发起一笔链上交易,给 Client 记分。具体代码是 这两行: 每个 Client 的分数都记录在合约里,Client 想领取奖励,就自己到 treasurer 合约上 claim,treasurer 会根据分数和汇率计算并转账代币。 那么 treasurer 分发的奖励是哪个代币呢?具体代币是 Coordinator 在创建任务的时候 指定的,只要是标准的 SPL 代币都可以。 所以整体来看,Psyche Network 是利用 Solana 区块链来记录任务 Meta 信息、计算任务奖励、分发奖励等。只要 Client 的加入是 permissonless 的,Psyche Network 就确实达到了和宣传一样的效果,让 LLM 模型训练的算力去中心化。 而代币的分发和奖励虽然是区块链项目的常规操作,但是至少附加了公开透明等特性,而且不出意外的话,Psyche Network 最终会走到发币的一步,到时候任务奖励可能全用 Psyche Network 自己的代币进行,或者演变为 LLM 训练的任务平台,任何第三方都可以创建任务和分发奖励之类,像 Eigne Layer 那样。

2025/8/1
articleCard.readMore

如何开发一个比特币符文(Runes)协议

比特币符文(Runes)在技术原理上比较简单,很容易理解,实现 Runes 只需要用到比特币脚本中的 OP_RETURN 操作符。也是正因为简单,所以在技术特性上, Runes 相对干净一点,没有铭文和 RGB 那么复杂的链下状态。而 Runes 厉害的地方在于,能把这样一个生态给玩起来,虽然现在也凉了,但是我们不那么关心市场表现,从技术的角度,非常切实的看一下 Runes 是如何实现的,并且我们自己会一步一步开发一个简化的 Runes 协议出来。当我们真正理解了 Runes 协议,就可以直接看懂更复杂的项目,比如 Alkanes,一个在比特币上支持 WASM 智能合约的协议。 这篇文章的操作基于《比特币脚本开发教程》中的知识,如果缺少对比特币脚本的基本了解,可以先看一下基础教程。 1. 定义数据结构 首先把 Runes 相关的操作定义为 json 格式的数据结构。用什么语言都可以,这里用的是 Rust。为了简化教程,可以省去一些实际中常用的元素,比如 transfer 的操作定义中,应该允许一次性转账给多个目标地址,但这里只有一个目标地址,没有用数组: struct IssueRune{ op: u8, // 固定为 0,代表发行 symbol: String, // Rune name supply: String, // 发行总量}struct TransferRune{ op: u8, // 固定为 1,代表转账 id: u64, // rune_id vout: u32, // 转账目标 amount: String, // 转账金额} 这个结构定义中,唯一可能有点迷惑的是 vout 字段,一般我们要转账给一个目标地址的时候,会使用目标地址的字符串作为值,但是 Runes 协议中,为了节省链上空间,使用 vout 也就是当前这笔交易、第几个输出的索引作为 Runes 转账的目标。因为每一笔交易的第 0 个输出会包含 OP_RETURN 的值,转账操作的字符全部会放到 OP_RETURN 的内容中,所以在一笔交易里,vout 只用数字就足以表明转账的目标地址是哪个。交易数据的结构大概是这样: tx { vin: [...] vout: [ { vout: 0, scriptPubKey: OP_RETURN <json数据> }, { vout: 1, scriptPubKey: OP_0 <转账地址1> }, { vout: 2, scriptPubKey: OP_0 <转账地址2> } ]} 接着给数据结构定义一下序列化函数,让结构体可以转变为 json 字符串: impl IssueRune{ fn toJson(&self) -> String { format!( "{{\"op\":{},\"symbol\":\"{}\",\"supply\":\"{}\"}}", self.op, self.symbol, self.supply ) }}impl TransferRune{ fn toJson(&self) -> String { format!( "{{\"op\":{},\"id\":{},\"vout\":{},\"amount\":\"{}\"}}", self.op, self.id, self.vout, self.amount ) }} 2. 发行 Runes 代币 接下来的操作都基于本地启动的 regtest 节点,所以记得先启动起来,同时验证下有没有加载钱包、钱包里有没有余额。然后准备一下要发行 Rune 的 json 数据,比如这样,发行的 Rune 叫 Doge,总发行量 1000 个: fn issue_rune(){ let issue = IssueRune { op: 0, symbol: "Doge".to_string(), supply: "1000".to_string(), }; println!("Issue Doge JSON: {}", issue.toJson());} 运行这个函数,就会得到这样的输出,后面的 json 数据很重要,我们稍后会把这个数据发送到链上: Issue Doge JSON: {"op":0,"symbol":"Doge","supply":"1000"} 然后运行这个命令行,把 json 数据转变为 16进制字符串: echo -n "{"op":0,"symbol":"Doge","supply":"1000"}" | xxd -p -c 999 我得到了这样的输出: 7b6f703a302c73796d626f6c3a446f67652c737570706c793a313030307d 这就是会放到 OP_RETURN 后面、用来上链的数据。注意 OP_RETURN 最多支持 80 个字节,所以这个数据不能太长。 接着查看并挑一笔未花费的输出,因为 Runes 所有的操作都必须绑定到 UTXO 上。用这个命令查看你的钱包有哪些 UTXO 可用,然后挑一个你喜欢的: bitcoin-cli -datadir=./ -regtest listunspent 比如我要用的 UTXO 是这样: { "txid": "8bfd524e9fc150dab11289d7e6d07860b2b5d6acb54b278a5dc1d1d7631bc8fa", "vout": 0, "address": "bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw", "amount": 50.00000000, // ...} 然后生成一个找零地址,用来接收比特币余额,这里用了 legecy 格式的地址,这个不是强制的,用 SegWit 的地址也不影响: bitcoin-cli -datadir=./ getrawchangeaddress legacy 我生成的地址是 n4Ybvvzm9vRQepuMpXBnTWWbYuTgsPSZCV,接下来可以用这个地址构建交易了: bitcoin-cli -datadir=./ createrawtransaction \ '[{"txid":"8bfd524e9fc150dab11289d7e6d07860b2b5d6acb54b278a5dc1d1d7631bc8fa","vout":0}]' \ '[{"data":"7b6f703a302c73796d626f6c3a446f67652c737570706c793a313030307d"},{"n4Ybvvzm9vRQepuMpXBnTWWbYuTgsPSZCV":49.99}]' 注意这个命令给找零地址的金额为 49.99,这种操作是不可以直接在主网使用的,需要精确计算余额和手续费的差值,然后给找零地址,不然会有很大的资金损失。这里只是懒得计算精确值。 生成交易数据后,对交易进行签名: bitcoin-cli -datadir=./ signrawtransactionwithwallet 0200000001fac81b63d7d1c15d8a274bb5acd6b5b26078d0e6d78912b1da50c19f4e52fd8b0000000000fdffffff020000000000000000206a1e7b6f703a302c73796d626f6c3a446f67652c737570706c793a313030307dc0aff629010000001976a914fc9ab9cd801c625c9fe323fe669e6a3e362eed8088ac00000000 发送签名后的交易到链上: bitcoin-cli -datadir=./ sendrawtransaction 02000000000101fac81b63d7d1c15d8a274bb5acd6b5b26078d0e6d78912b1da50c19f4e52fd8b0000000000fdffffff020000000000000000206a1e7b6f703a302c73796d626f6c3a446f67652c737570706c793a313030307dc0aff629010000001976a914fc9ab9cd801c625c9fe323fe669e6a3e362eed8088ac02473044022004a2553cc5348dd4521c093149b0ba5e5603fe4134d06a455e12abeac097ea19022076e72632b2488e1316e54559ed733b37de9ce7fd04119e78a59546a3d2c1faea0121020b396a9dfa1655feef066fe03b403d3e4bdee41ef9b26551497c0921acbf6bc100000000 要留意这个命令会输出一个 txid,这个 txid 比较重要,我们后续会从这个 txid 来转出 Doge 代币,所以要记得留下这个 txid 的记录,我的交易哈希是:e2061d0b8b2f98ee47ba6564c1e7409872432354c7617d278fe0e8c4485ff04a。挖一个区块来确认交易: bitcoin-cli -datadir=./ generatetoaddress 1 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw 如果一切顺利,交易数据就应该已经上链了。那么链下的解析器在拿到这笔交易后,会看到发行 Rune 的操作,并且解析出来。如果想确认下 OP_RETURN 的数据是不是写对了,可以解码一下刚才广播的交易详情: bitcoin-cli -datadir=./ decoderawtransaction 02000000000101fac81b63d7d1c15d8a274bb5acd6b5b26078d0e6d78912b1da50c19f4e52fd8b0000000000fdffffff020000000000000000206a1e7b6f703a302c73796d626f6c3a446f67652c737570706c793a313030307dc0aff629010000001976a914fc9ab9cd801c625c9fe323fe669e6a3e362eed8088ac02473044022004a2553cc5348dd4521c093149b0ba5e5603fe4134d06a455e12abeac097ea19022076e72632b2488e1316e54559ed733b37de9ce7fd04119e78a59546a3d2c1faea0121020b396a9dfa1655feef066fe03b403d3e4bdee41ef9b26551497c0921acbf6bc100000000 输出的结果是这样: "vout": [ { "value": 0.00000000, "n": 0, "scriptPubKey": { "asm": "OP_RETURN 7b6f703a302c73796d626f6c3a446f67652c737570706c793a313030307d", // ... } }, { "value": 49.99000000, "n": 1, "scriptPubKey": { //... } } ] 很明显看到了在第 0 个输出中,有 OP_RETURN 十六进制数据 的内容,如果还是不放心,可以 decode 一下 16进制字符串: echo -n "7b6f703a302c73796d626f6c3a446f67652c737570706c793a313030307d" | xxd -r -p 应该得到: {op:0,symbol:Doge,supply:1000} 3. 转账 Runes 代币 刚才创建了 Doge 代币,发行量是 1000,我们接下来通过一笔转账交易,来转出这 1000 个 Doge。 首先得计算一下 rune_id,也就是我们刚才发行的 Doge 的唯一 ID 是什么,因为 Symbol 字符串是有可能重复的,而且占用字符空间也多,一般会做一些工程上的折中,比如对 txid 按照字节反序,然后取前 8 字节,得到一个 u64 长度的数字,像这样: fn calc_run_idby_txid(){ let txid = "e2061d0b8b2f98ee47ba6564c1e7409872432354c7617d278fe0e8c4485ff04a".to_string(); // 按字节反序,然后取前 8 字节 let mut bytes = hex::decode(txid).unwrap(); bytes.reverse(); let run_id = u64::from_le_bytes(bytes[0..8].try_into().unwrap()); println!("Run ID: {}", run_id);} 这个函数运行后会得到 10367542271932362826,我们把这个数字作为 rune_id,去构建转账 rune 需要的 json 数据: fn transfer_rune(){ let transfer = TransferRune { op: 1, id: 10367542271932362826, vout: 1, amount: "1000".to_string(), }; println!("Transfer Rune JSON: {}", transfer.toJson());} 这个参数里要留意 vout 的值,它是接下来构建交易的时候,要转出到某个地址的 vout 的索引,和创建代币时候的交易没有任何关系。代码运行后得到这样的结果: Transfer Doge JSON: {"op":1,"id":10367542271932362826,"vout":1,"amount":"1000"} 接下来就可以重复之前的步骤,把 json 转为 16进制字符串: echo -n "{"op":1,"id":10367542271932362826,"vout":1,"amount":"1000"}" | xxd -p -c 999 我得到 7b6f703a312c69643a31303336373534323237313933323336323832362c766f75743a312c616d6f756e743a313030307d。 创建一个新地址用于接收 Doge: bitcoin-cli -datadir=./ getnewaddress 我的新地址是:bcrt1qc250507tws9z9wkurfcv3jue2nls6npzaqt7ka。 利用刚才得到的参数,组装一笔转账 Doge 的交易: bitcoin-cli -datadir=./ createrawtransaction \'[{"txid":"e2061d0b8b2f98ee47ba6564c1e7409872432354c7617d278fe0e8c4485ff04a","vout":1}]' \'[{"data":"7b226f70223a312c226964223a31303336373534323237313933323336323832362c22766f7574223a312c22616d6f756e74223a2231303030227d"},{"bcrt1q0n2x7030x59j5ql9pp6mw0tps74ag0znrdp45r":0.01},{"n4Ybvvzm9vRQepuMpXBnTWWbYuTgsPSZCV":49.9798}]' 这里和之前的交易略有不同,包含两个输出,第一个是接收 Doge 的地址,金额随意,因为重点在于 Doge 余额,而不是 BTC 余额。第二个参数则是找零地址,我们前面的交易里用到过。 剩下的操作轻车熟路,对这笔交易签名、把交易广播出去、挖一个新区块让交易确认: # 对交易签名bitcoin-cli -datadir=./ signrawtransactionwithwallet 02000000014af05f48c4e8e08f277d61c7542343729840e7c16465ba47ee982f8b0b1d06e20100000000fdffffff0300000000000000003d6a3b7b226f70223a312c226964223a31303336373534323237313933323336323832362c22766f7574223a312c22616d6f756e74223a2231303030227d40420f00000000001600147cd46f3e2f350b2a03e50875b73d6187abd43c53601fe729010000001976a914fc9ab9cd801c625c9fe323fe669e6a3e362eed8088ac00000000# 广播交易bitcoin-cli -datadir=./ sendrawtransaction 02000000014af05f48c4e8e08f277d61c7542343729840e7c16465ba47ee982f8b0b1d06e2010000006a47304402201437a9e83ae0c6842ebd9d355af9c7be1f6f2eaa070b5d7a6e02e13ca8f2d13102206d05753c428f526b8c6636022991591517cc7d7982badfc633519cb44715957a0121026f441e8156148d0bb4963edaff187873f9800a37bb5f0731256e38d632031283fdffffff0300000000000000003d6a3b7b226f70223a312c226964223a31303336373534323237313933323336323832362c22766f7574223a312c22616d6f756e74223a2231303030227d40420f00000000001600147cd46f3e2f350b2a03e50875b73d6187abd43c53601fe729010000001976a914fc9ab9cd801c625c9fe323fe669e6a3e362eed8088ac00000000# 得到交易哈希:80709a25e5355d51ee6d7fb625c40e9c4c49b049afa3aca18aeaa03bc685c1f0# 确认交易bitcoin-cli -datadir=./ generatetoaddress 1 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw 到这一步,转账 Doge 的交易就完成并且上链了。 4. 解析 Runes 交易 你也许有点纳闷,这不就是发了两笔普通的比特币交易吗,只是放了两个 json 数据到交易上。Runes 就是这样,所有的操作,就在 OP_RETURN 允许的那 80 个字节的空间里完成。链上只记录 Runes 的操作,而不维护 Runes 的最终状态。包括有哪些代币、代币余额等信息,全部需要链下程序根据协议进行解析,状态也全部在链下程序维护。 我们首先可以通过已知的交易哈希,获取到这两笔交易的全部详情数据。如果交易哈希不是已知,可以监听扫描全部区块的全部交易,然后按照协议约定解析就行了。这里简化一点。 在 Cargo.toml 文件中导入依赖包: [dependencies]hex = "0.4"bitcoin = "0.31" # Script & consensus decodebitcoincore-rpc = "0.18" # RPC clientserde = { version = "1.0", features = ["derive"] }anyhow = "1.0" 通过 RPC 查询节点上的交易数据: // 导入必要依赖use bitcoin::{Transaction, Txid};use bitcoincore_rpc::{Auth, Client, RpcApi};use serde::Deserialize;use std::path::PathBuf;use std::str::FromStr;fn parse_tx(){ // 去启动比特币节点的数据目录下,找用来 rpc 鉴权的 cookie 文件 let mut cookie = PathBuf::from("/Users/smallyu/work/github/bitcoin-regtest"); cookie.push("regtest/.cookie"); let rpc = Client::new( "http://127.0.0.1:18443", Auth::CookieFile(cookie), ).unwrap(); // 我们已知的交易哈希 let issue_txid = Txid::from_str("e2061d0b8b2f98ee47ba6564c1e7409872432354c7617d278fe0e8c4485ff04a").unwrap(); let transfer_txid = Txid::from_str("80709a25e5355d51ee6d7fb625c40e9c4c49b049afa3aca18aeaa03bc685c1f0").unwrap(); // 这里会得到完整的交易数据 let issue_hex = rpc.get_raw_transaction_hex(&issue_txid, None).unwrap(); let transfer_hex = rpc.get_raw_transaction_hex(&transfer_txid, None).unwrap(); println!("Issue Hex: {}", issue_hex); println!("Transfer Hex: {}", transfer_hex); // 调用函数来解析交易 parse_op_return(issue_hex); parse_op_return(transfer_hex);} 这个函数在运行的时候,会从链上节点,查询出真实的已经上链的交易数据。接下来可以对这两笔交易的 Runes 操作做解析: fn parse_op_return(tx_str: String){ let tx: Transaction = bitcoin::consensus::deserialize(&hex::decode(tx_str).unwrap()).unwrap(); let script = tx.output[0].script_pubkey.clone(); // OP_RETURN, DATA let mut iter = script.instructions(); let mut op_return = iter.next(); let mut data = iter.next(); // 解析数据 match op_return { Some(Ok(op_return)) => { match data { Some(Ok(data)) => { match (data) { bitcoin::blockdata::script::Instruction::PushBytes(bytes) => { let json_str = std::str::from_utf8(bytes.as_ref()).unwrap(); println!("{}", json_str); } _ => panic!("Expected OP_RETURN with data"), } } _ => panic!("No data found in OP_RETURN"), } } _ => panic!("No OP_RETURN found in script"), }} 解析数据的代码部分,写法上嵌套有点多,只是因为我不喜欢用语法糖。想看起来更舒服的话,也可以在代码写法上做调整,变得更精简,不过无论写法如何,代码干的事情都一样。这个函数会从交易数据里,经过层层解析,打印出这样的结果: {op:0,symbol:Doge,supply:1000}{"op":1,"id":10367542271932362826,"vout":1,"amount":"1000"} 这样,我们就看到了期望的两个 Runes 动作,第一个是发行 Doge,第二个是对 Doge 进行转账。 以上过程就是 Runes 协议比较核心的内容,剩下的只需要把链下程序扩充一下,记录 Runes 状态、根据 rune_id 关联 Rune 操作、储存和显示余额变更等信息就可以了。

2025/7/15
articleCard.readMore

比特币脚本开发教程

比特币脚本有点像房间里的大象,大家都知道这个东西,但是大家都看不见,或者不在乎。这个教程将从最基本的操作开始,理解比特币脚本的原理,学会自己写比特币脚本。因为比特币脚本不是图灵完备的,所以包含很多命令行操作,以及需要观察输出结果。 1. 启动本地节点 运行这个命令安装 bitcoind 的二进制,然后用 bitcoind --help 来测试是否安装成功: brew install bitcoin 创建一个用于测试使用的目录,比如我的目录名称是 bitcoin-regtest: mkdir ./bitcoin-regtestcd ./bitcoin-regtest 在这个目录下新建一个叫 bitcoin.conf 文件,复制这些配置内容进去: regtest=1txindex=1fallbackfee=0.0001 这是本地节点的配置文件,后续我们的比特币脚本将基于本地启动的开发节点来测试。这个配置文件中的 regtest=1 比较关键,指明了节点的类型是本地开发网络,不会真的到公网上同步区块数据,本地节点的块高度将从 0 开始。另外两个配置 txindex=1 是指启动本地节点对所有交易的索引,方便我们后续查看交易,fallbackfee=0.0001 则是指明交易手续费的大小。 停留在包含配置文件的当前目录下,执行这个命令来启动节点。这里的命令行,以及后续的命令行,都会带上 -datadir 参数,因为我们希望节点数据是隔离的,每一个工作目录都是一份新的环境,不至于污染电脑的全局环境,而且默认环境的路径比较长,不同操作系统不一致,虽然我们在后续的命令里都需要带上这么一个参数,看起来有点麻烦,但同时也避免了很多其他问题,比如找不到系统默认目录在哪儿之类: bitcoind -datadir=./ -daemon 命令成功执行会看到 Bitcoin Core starting 的字样。为了测试节点是否真的在运行,可以用这个命令查看节点的状,会得到一个 json 数据: bitcoin-cli -datadir=./ getblockchaininfo 如果还是对节点的运行状态不放心,可以直接查看节点的日志文件。这就是我们指定了数据目录的好处,日志文件在这个位置: cat ./regtest/debug.log 如果想要停掉节点,避免后台进程一直在电脑上运行,用这个命令来停止节点: bitcoin-cli -datadir=./ stop 注意启动节点用的是 bitcoind,停止节点用的是 bitcoin-cli。前者属于 server 端的命令,后者属于 client 端的命令。 另外,如果在停止节点后重启节点,发现钱包(下一小节内容)不能用了,可以用这个命令来导入钱包: bitcoin-cli -datadir=./ loadwallet learn-script 2. 创建钱包 运行这个命令来创建一个比特币钱包: bitcoin-cli -datadir=./ createwallet "learn-script" 我们刚提到命令行中使用 -datadir 参数来指定数据目录,那么钱包的文件其实也会在数据目录下保存,可以看一下 ./regtest/wallets 目录,有一个 learn-script 的文件夹,我们刚才创建的钱包就在这个文件夹内。 查看钱包地址的命令,比如我的地址是 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw: bitcoin-cli -datadir=./ getnewaddress 接着在本地节点上,给钱包地址挖一些钱出来,这里的参数 101 是指挖 101 个区块。为什么是 101 个区块呢?一般我们挖的区块数量会大于 100,因为比特币网络有 100 个区块的成熟期,也就是区块奖励需要在 100 个区块之后,才可以消费。假如我们只挖了 99 个区块,虽然理论上应该得到很多区块奖励,但实际上是不能花费的。 bitcoin-cli -datadir=./ generatetoaddress 101 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw 这个命令运行输出的是每个块的区块哈希。运行结束后,我们就可以查看钱包地址的余额了,余额应该是 50: bitcoin-cli -datadir=./ getbalance 为什么是 50?因为比特币的区块奖励每 4 年减半,第一次减半之前的块奖励,每个区块都是 50 BTC。为什么挖了 101 个块,但只能查到 50 BTC 的余额?因为后 100 个区块的成熟期,奖励是不到账的。 3. 发送交易 那么现在我们已经有了本地在运行的节点,以及有余额的钱包,接下来可以发起一笔普通的转账交易。先生成一个用于接收转账的新地址,我生成的地址是 bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc: bitcoin-cli -datadir=./ getnewaddress 可以查看验证一下,新生成的地址余额为 0。这个命令中的参数 0 意味着查询结果包含未确认的交易。 bitcoin-cli -datadir=./ getreceivedbyaddress bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc 0 接着使用发起交易的命令,来向新生成的地址转账 0.01 BTC: bitcoin-cli -datadir=./ sendtoaddress bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc 0.01 这个命令会返回交易哈希,比如我的哈希值是 81be2e97507d7a274029ec4d5ce9728a54fe6d885aa0f12a13ec6f54eee66c26。我们需要用这个交易哈希来查询交易结果和交易详情,像这样: bitcoin-cli -datadir=./ gettransaction 81be2e97507d7a274029ec4d5ce9728a54fe6d885aa0f12a13ec6f54eee66c26 这笔交易此时就已经提交到链上了,但是也许你会注意到,查询交易详情返回的交易状态中,有一个 "confirmations": 0,意味着交易还没有被确认,而且区块高度还停留在 lastprocessedblock: 101 上。因为比特币不会自动出块,这个时候查询接收地址的余额,能看出差异: # 查询到余额是 0.01bitcoin-cli -datadir=./ getreceivedbyaddress bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc 0# 查询到余额是 0bitcoin-cli -datadir=./ getreceivedbyaddress bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc 1 因为我们之前有说明,最后一个参数是 0 代表包含未确认的交易,否则只查询确认的交易。我们刚刚发送的交易就还没有确认。如果想确认下来,就得用之前的 generatetoaddress 命令再挖一个区块出来: bitcoin-cli -datadir=./ generatetoaddress 1 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw 现在再去查询交易状态,无论是确认数还是钱包余额,就都符合预期了。 4. 查看交易脚本 我们刚才发送的是一笔 P2WPKH 交易,因为现在比特币客户端默认使用原生 SegWit 的地址格式。 先了解一下 P2PKH 是什么,全称是 Pay to Public-Key Hash,我们使用的比特币地址本身就是一个公钥的子集,而 P2PKH 交易以账户地址为接收参数,所以命名为 P2PKH。我们常说的比特币原生地址,就是指 P2PKH 格式,一般以 1 开头, 相比 P2PKH,原生 SegWit 的地址格式叫 P2WPKH,中间多了个字母 W,全称是 Pay to Witness Public-Key Hash,特点是会把签名数据放在 witness 字段里,而不是每一笔 UTXO 的输出里,我们可以具体看一下,首先根据交易哈希,查询得到交易的全部数据: bitcoin-cli -datadir=./ getrawtransaction 81be2e97507d7a274029ec4d5ce9728a54fe6d885aa0f12a13ec6f54eee66c26 会得到一大段编码后的数据,用这个命令来解码交易数据: bitcoin-cli -datadir=./ decoderawtransaction 020000000001018f4e8514038b93d6cc1d4f77b011f4726ba765d338bfdf1e6724d1844bc5d36e0000000000fdffffff0240420f0000000000160014400a517208b473618b98817840328c09a77d6b123eaaf629010000001600147ef4555b42b71e6ebecd687170c92ab64cce35500247304402202417ff3f6959a7d449849ae78fd5272826339cd7096ab02cdd7eccfc7779fb14022077e43ce155259a602b6172261b1d830d30e0de8b06cd6479cac02ea7c6928ff10121020b396a9dfa1655feef066fe03b403d3e4bdee41ef9b26551497c0921acbf6bc196000000 查询得到的数据结构是这样: { // ... "vin": [ { // ... "txinwitness": [ "304402202417ff3f6959a7d449849ae78fd5272826339cd7096ab02cdd7eccfc7779fb14022077e43ce155259a602b6172261b1d830d30e0de8b06cd6479cac02ea7c6928ff101", "020b396a9dfa1655feef066fe03b403d3e4bdee41ef9b26551497c0921acbf6bc1" ], } ], "vout": [ { "value": 0.01000000, "scriptPubKey": { "asm": "0 400a517208b473618b98817840328c09a77d6b12", "desc": "addr(bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc)#nry368tt", "hex": "0014400a517208b473618b98817840328c09a77d6b12", "address": "bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc", "type": "witness_v0_keyhash" } }, { "value": 49.98998590, "scriptPubKey": { // ... } } ]} 首先关注 txinwitness 这个字段,它是一个数字,有两个部分,第一个部分是签名数据,第二个部分是公钥,这就是我们之前提到的 SegWit,对金额的签名不放在 vout 里,而是放在了 vin 里。 然后再关注 scriptPubKey 里的 asm,ASM 是 RedeemScript 的意思,表示满足什么样的条件就可以消费脚本中锁定的金额。是的我们即使是发起普通转账,实际上也是一种比特币脚本,金额锁定在了脚本中。我们查询到的脚本内容分为两段,第一段是 0,表示比特币脚本中的一个操作码 OP_0,第二段是 400a517208b473618b98817840328c09a77d6b12,其实就是钱包地址,经过 bech32 编码后会变成熟悉的样子 bcrt1qgq99zusgk3ekrzucs9uyqv5vpxnh66cjtwl6zc。 5. 用 btcdeb 调试 刚才提到了 OP_0 这个操作码,它具体是什么呢?操作码是比特币脚本的关键,我们可以用 btcdeb 工具调试和观察一下。btcdeb 没有提供一键式的安装命令,可以按照 官方的教程 先下载源码,然后编译安装。验证安装结果: btcdeb --version OP_0 这个操作码本身干的事情很简单,就是把空数据压进栈结构里,尝试运行命令: btcdeb OP_0 会看到这样的输出: script | stack --------+--------0 | #0000 0 前面的 script 表示有一个操作 0, 也就是 OP_0,这里显示的时候自动隐去了 OP_ 前缀。后面 #0000 0 则表示目前栈里内容为 0(空)。接下来的输入 step 命令,让 btcdeb 真正运行 OP_0 这个步骤,运行结果是这样,可以看到推了一个空数据到栈里,这就是 OP_0 干的事情: step <> PUSH stack 为了增加理解,我们举一个别的操作码例子来观察栈内数据的变化,尝试这个命令: btcdeb '[OP_2 OP_3 OP_ADD]' 然后输出 step 命令,一直按回车直到脚本结束,输出内容的过程像是这样。默认内容是这样,此时脚本里有 3 个操作码等待执行,分别是 OP_2、OP_3 和 OP_ADD: script | stack --------+--------2 | 3 | OP_ADD | #0000 2 第一次回车执行了脚本的第一个步骤 OP_2,对应操作把数字 2 压入栈,执行结束后脚本里剩 2 个操作码了,同时 stack 中有了数字 2: step <> PUSH stack 02btcdeb> script | stack --------+--------3 | 02OP_ADD | #0001 3 第二次回车继续执行了 OP_3 操作码,把数字 3 压入栈,此时脚本里只剩 1 个操作码,栈中有数字 2 和数字 3: <> PUSH stack 03btcdeb> script | stack --------+--------OP_ADD | 03 | 02#0002 OP_ADD 第三次回车执行 OP_ADD 操作码,这个操作码会从栈里弹出两个数字,计算加法后把结果推回栈内,得到结果 5: <> POP stack <> POP stack <> PUSH stack 05btcdeb> script | stack --------+-------- | 05 因为 btcdeb 的命令行输出并不是特别直观,所以这里尽管占用篇幅,也有必要把整个过程的输出都复制过来,还拆分了步骤,方便理解每一步在干什么。可以看到每一个操作码都会对应一些行为,这个行为是比特币程序里定义的,包括加法、减法等各种运算,也有一些行为更复杂的操作,或者对简单的操作码进行排列组合,达到实现更复杂功能的目的。我们还看到比特币脚本的执行是基于栈的,全部行为都发生在栈结构里,栈结构也就意味着完全没有动态内存分配之类的东西。 6. 自己编写比特币脚本 (1) 刚才尝试了在 btcdeb 调试工具里运算加法,现在试着在实际的比特币交易中,写入脚本代码,并且在链上运算。这段是原始的操作码形式的脚本,要注意这个脚本是不安全的,属于自验证的脚本,任何人都可以花费这个脚本中的金额,只是在花费过程中,脚本表示的数字运算会在链上执行: [OP_2 OP_3 OP_ADD OP_5 OP_EQUAL] 首先需要把操作码转变为十六进制形式,这个编码过程需要手动,或者写代码来操作。我们使用手动的方式,这个 比特币文档 中列出了全部支持的操作码,以及对应的十六进制字符,到我们这个小脚本这里,对应关系就是: 操作码十六进制 OP_252 OP_353 OP_ADD93 OP_555 OP_EQUAL87 因此我们按照依次拼接的顺序,得到了的十六进制脚本: 5253935587 接着生成 P2SH 地址。P2SH 的全称是 Pay to Script Hash,意思是支付到脚本哈希,或者说锁定金额到脚本中,相当于链上脚本的地址: bitcoin-cli -datadir=./ decodescript 5253935587 命令输出中有一个 p2sh-segwit 字段,值是 2NAzGPjCcg8DiykVTKLJRYbU2fejCEbdPbX,把这个 P2SH 地址用作参数生成脚本的校验和,校验和是构造比特币交易必须要的一个参数: bitcoin-cli -datadir=./ getdescriptorinfo "addr(2NAzGPjCcg8DiykVTKLJRYbU2fejCEbdPbX)" 得到 descriptor 的值为 addr(2NAzGPjCcg8DiykVTKLJRYbU2fejCEbdPbX)#s260u65e,后续用这个值作为脚本参数构造交易。 不过到这里还有个坑,比特币的 P2SH 脚本,只能用观察模式的钱包导入,所以需要新创建一个没有私钥的钱包: bitcoin-cli -datadir=./ createwallet "arith-watch" true true "" true 用刚刚创建的新钱包,导入 P2SH 脚本。看到这个命令返回 "success": true,才表示导入成功: bitcoin-cli -datadir=./ -rpcwallet=arith-watch importdescriptors '[{"desc":"addr(2NAzGPjCcg8DiykVTKLJRYbU2fejCEbdPbX)#s260u65e","timestamp":"now","label":"arith-2+3=5"}]' 现在有了 P2SH 的脚本地址,并且已经把脚本导入到钱包,接下来可以给脚本打钱了。这个命令从 learn-script 钱包转账 0.01 BTC 给脚本: bitcoin-cli -datadir=./ -rpcwallet=learn-script sendtoaddress 2NAzGPjCcg8DiykVTKLJRYbU2fejCEbdPbX 0.01 挖一个区块让交易确认: bitcoin-cli -datadir=./ generatetoaddress 1 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw 现在,这个脚本就上链并且有余额了。 7. 自己编写比特币脚本 (2) 目前这个脚本地址里的钱,任何人都可以消费,消费的同时会运算一下 2+3 这个表达式,并且判断结果是否为 5。接下来构建一笔花费脚本金额的交易,真正花掉刚才存进脚本的钱。准备一个收款地址: bitcoin-cli -datadir=./ -rpcwallet=learn-script getnewaddress 我新建的地址是 bcrt1q0n2x7030x59j5ql9pp6mw0tps74ag0znrdp45r。用这个地址构建一笔交易,注意这里 inputs 中的 txid,是刚才给 P2SH 转账的那一笔交易哈希: bitcoin-cli -datadir=./ -named createrawtransaction \ inputs='[{"txid":"b952acd06a4f7edd7b2d5da0d509d01dfbb8e49fa15123d9cd5d3d23f944cdc2","vout":0}]' \ outputs='{"bcrt1q0n2x7030x59j5ql9pp6mw0tps74ag0znrdp45r":0.009}' 在构建的交易中添加自动找零参数: bitcoin-cli -datadir=./ -rpcwallet=learn-script \ fundrawtransaction 0200000001c2cd44f9233d5dcdd92351a19fe4b8fb1dd009d5a05d2d7bdd7e4f6ad0ac52b90000000000fdffffff01a0bb0d00000000001600147cd46f3e2f350b2a03e50875b73d6187abd43c5300000000 关键的一步,用钱包给这笔交易签名,注意这里是给找零之后的交易数据进行签名,如果不找零,节点会把找零金额当作手续费,而节点默认还有手续费的上限值,如果这一步没找零,下一步会触发手续费上限报错: bitcoin-cli -datadir=./ -rpcwallet=learn-script \ signrawtransactionwithwallet 0200000001c2cd44f9233d5dcdd92351a19fe4b8fb1dd009d5a05d2d7bdd7e4f6ad0ac52b90000000000fdffffff02a0bb0d00000000001600147cd46f3e2f350b2a03e50875b73d6187abd43c5360a0d92901000000160014a3e136e24d5a8db14f15016b99fb21ea4b0b69da00000000 最后,把签名好的交易数据广播出去就行了: bitcoin-cli -datadir=./ sendrawtransaction 02000000000101c2cd44f9233d5dcdd92351a19fe4b8fb1dd009d5a05d2d7bdd7e4f6ad0ac52b90000000017160014c2d5ade24c1d0b9f27f651a71c3fe49d23d0ae13fdffffff02a0bb0d00000000001600147cd46f3e2f350b2a03e50875b73d6187abd43c5360a0d92901000000160014a3e136e24d5a8db14f15016b99fb21ea4b0b69da024730440220406a51d43ade05b240fcf2d14b58c90f31ebc705ab262189949355cac54d0431022051b592c570ef960a35e8509766e903ba836e3bcd1fb3c5cc211f0ff3442283550121021ff283ca8c9ecb45c8e19eacb7e8ae6fcb27d8addd38011d633e396487db44e300000000 记得再挖一个区块让交易确认: bitcoin-cli -datadir=./ generatetoaddress 1 bcrt1q6c8d9vw62rdee72xcqx3d97w8qh8mfg8ky8zjw 查看交易状态,验证交易已被花费,如果返回空值,说明已被花费。这里查的交易哈希是当时用钱包给脚本转账 0.01 BTC 那一笔交易的哈希: bitcoin-cli -datadir=./ gettxout b952acd06a4f7edd7b2d5da0d509d01dfbb8e49fa15123d9cd5d3d23f944cdc2 0 8. Troubshooting 我本地的操作环境以及软件脚本是: OS: MacOSbitcoind: v29.0.0btcdeb:5.0.24

2025/7/10
articleCard.readMore

区块链技术面试题(2025年版)

比起 2023 年版本相对宏观视角的《区块链技术面试题》,这个版本稍微侧重工程实践一点,包含了更多技术细节。这两个版本的内容是互相补充的,不是升级性质的关系。这些题目仅仅只是基于我的个人经历,就像很多面试官在做的那样,自己会什么才问什么,问不出自己不会的东西,所以问出来的问题,无论广度和深度,都是受限于个人水平的,我也是: 以太坊客户端为什么分为执行层和共识层? 以太坊的 PoS 运作流程,如何初始化一个 PoS 网络? 以太坊 PoS 的软分叉和恢复机制?Cardano 的 PoS 和以太坊一样吗? 以太坊节点有哪些类型,分别适用于什么场景? EVM 的执行为什么是单线程的?为什么至今全世界的团队都做不出来 “并行EVM” 这种东西? Solidity 语言有 GC 吗?是如何处理内存动态分配问题的? Solidity 什么场景下需要内联汇编? PBFT 共识有了解吗,大体流程是怎么样的? PBFT 的容错能力公式是怎么来的,为什么是那个数字,而不是其他数字? PBFT 为什么需要第二次投票? Solana 的共识机制大体是怎样的?TowerBFT 是在对区块投票吗? 为什么 Solana 的智能合约可以并行执行,以太坊的不可以? Cosmos 节点的升级流程是怎样的?和以太坊有什么不同?这种模式有什么风险? Op Rollup 的大体流程?ZK Rollup 在 Op 模式的基础上,优化了哪个环节? 以太坊 L2 的资产跨链?与不同网络之间的资产跨链相比,技术上有什么异同? 以太坊最近有个大版本升级,引入的 EIP-7702 是干什么的?和 AA 钱包是什么关系? 自己平时思考过哪些区块链相关的、有意思的技术类话题? 这些是我现在能想到的全部问题了。比这些问题更加有深度的工程化的内容,我也只是大概知道点方向,没亲手搞过。这两年的经历还算丰富,对比两个版本的面试题列表能看出不少变化。希望我自己可以再接再厉,不要迷路。 如果你是区块链行业的求职者,尤其是经验尚浅的工程师,千万不要被上面列出来的问题给吓到了。真实的面试过程中,几乎不会出现如此有深度的思考题。更多的问题类似于,“以太坊交易有哪些常用字段?”、“怎么取消一笔已经发送的交易?”、“Solidity 的可重入攻击是什么?”、“Op Stack 有哪几个组件?”、“以太坊合约的 create2 是什么?” 等等。放心大胆的去求职,真正懂技术的人没有那么多。 现在的区块链行业有个问题,就是没有系统化的理论知识,只有一些工业界前沿的、散碎的工程化尝试。比如对比编程语言专业,从丘奇和图灵的计算模型,到函数式编程语言、编译器、类型系统等,经过几十年学术界和工业界的发展,有高度抽象的理论支撑,有实际落地的工业应用,已经比较成熟。而区块链这种东西比较新,2008 年诞生,2013 年开始步入大众视野,短短几年的时间远没有建立起学术体系,行业内的项目方则各自为营,都在搞自己的标准、各自定义术语,账户模型、共识、合约、跨链,每条链都不一样。有人能统一区块链的理论体系吗?Vitalik 来都不行,要是 Satoshi 出山也许有希望。 因此不需要相信什么大学里的 “区块链专业”,没有出过校门的老师和教授,怎么可能有时间把区块链的技术抽象成理论、写成教材、编成课程,然后给学生讲课呢,这个周期得多长?也因此不要太相信已经出版的技术类书籍,书籍的出版需要几年时间,等书发表出来,世界已经变了。今年下半年有个比特币会议,两年前发明了铭文这个概念的项目方,可能又要发布新东西了,难道学校的课程或者书籍能跟得上这种节奏吗?行业最前沿的技术,只能来自各个项目方切实的探索和尝试,也自然就会造成不成体系的现象。

2025/7/6
articleCard.readMore

Rust 语言容易让新手困惑的一个“过度优化”

假如我们现在要写一些代码,随便用 cargo new 一个项目就行,然后写一个函数 append,函数的功能很好理解,就是把两个传入的字符串给拼接起来,第一个参数是字符串(的引用类型),第二个参数也是字符串,假如我们的参数是 Hello 和 , world,函数调用后会返回 Hello, world 给我们。函数具体这样写: fn append(s1: &String, s2: &String) -> String { return s1.clone() + s2.clone().as_str();} 不需要关心 return 后面的语句写法,这不是我们关注的重点。在入口函数 main 里调用这个 append,运行一下,输出的内容会和我们预期一样,打印出拼接后的字符串 Hello, world: fn main() { let s1: String = String::from("Hello"); let s2: String = String::from(", world"); println!("{}", append(&s1, &s2));} 那么现在,保持 append 函数完全不变,在 main 函数里修改两个字符串的定义,整个 main 函数变成这样,猜一下输出结果会是什么?注意 Rust 是静态类型的语言,编译器对于变量类型往往具有严格的定义和判断: fn main() { let s1: Box<String> = Box::new(String::from("Hello")); let s2: Box<String> = Box::new(String::from(", world")); println!("{}", append(&s1, &s2));} 我们首先的直觉是应该编译报错,因为 s1 的类型是 Box<String>,调用 append 函数的时候,传入的参数为 &s1,对应的类型为 &Box<String>,而显然 append 函数的定义是没有修改的,接收的参数类型仍然是 &String。那么这种情况下,为什么编译器没有报错,而且代码还能正常运行,输出了 Hello, world 的结果?(先别管这里的 Box 是什么,反正是一种类型) 我们接着再修改一下 main 函数的内容,把字符串的定义改为这样: fn main() { use std::rc::Rc; let s1: Rc<String> = Rc::new(String::from("Hello")); let s2: Rc<String> = Rc::new(String::from(", world")); println!("{}", append(&s1, &s2));} 代码能通过编译吗?能正常运行吗?append 函数的定义仍然没有变,这里 main 函数中 s1 的类型变成了 Rc<String>,相应的传入 append 函数做参数的时候,类型变为了 &Rc<String>。但是为什么,编译器没有报错,而且还能正常运行出结果,输出 Hello, world?(同样别管 Rc 是什么,也是一种类型) 根据刚才的代码片段,我们观察到一个现象:当函数的参数类型是 &String 的时候,既可以接受 &String 类型的参数,也可以接收 &Box<String> 类型的参数,还可以接收 &Rc<String> 类型的参数。 再疯狂一点,如果把 main 函数改成这样呢? fn main() { let s1: Box<Box<Box<Box<String>>>> = Box::new(Box::new(Box::new(Box::new(String::from("Hello"))))); let s2: Box<Box<Box<Box<String>>>> = Box::new(Box::new(Box::new(Box::new(String::from(", world"))))); println!("{}", append(&s1, &s2));} 如果把 main 函数改成这样呢? fn main() { use std::rc::Rc; let s1: Rc<Rc<Rc<Rc<String>>>> = Rc::new(Rc::new(Rc::new(Rc::new(String::from("hello"))))); let s2: Rc<Rc<Rc<Rc<String>>>> = Rc::new(Rc::new(Rc::new(Rc::new(String::from(", world"))))); println!("{}", append(&s1, &s2));} 结果是 main 函数都可以正常运行,输出 Hello, world 的结果。 为了进一步观察关于类型的问题,现在新写两个 append 函数,append2 函数接收的类型是 &Box<String>,而 append3 函数接收的类型是 &Rc<String>: fn append2(s1: &Box<String>, s2: &Box<String>) -> Box<String> { let mut result = (**s1).clone(); result.push_str(s2); Box::new(result)}use std::rc::Rc;fn append3(s1: &Rc<String>, s2: &Rc<String>) -> Rc<String> { let mut result = (**s1).clone(); result.push_str(s2); Rc::new(result)} 接下来分析一下,对于下面的 main 函数代码,编译器会在哪一行报错? fn main() { let s1: Box<Box<Rc<Rc<String>>>> = Box::new(Box::new(Rc::new(Rc::new(String::from("hello"))))); let s2: Box<Box<Rc<Rc<String>>>> = Box::new(Box::new(Rc::new(Rc::new(String::from(", world"))))); println!("{}", append(&s1, &s2)); println!("{}", append2(&s1, &s2)); println!("{}", append3(&s1, &s2));} 这样呢,字符串的类型再扩展一下,编译器还会报错吗,在哪一行? fn main() { let s1: Box<Box<Rc<Rc<Box<Box<String>>>>>> = Box::new(Box::new(Rc::new(Rc::new(Box::new(Box::new(String::from("hello"))))))); let s2: Box<Box<Rc<Rc<Box<Box<String>>>>>> = Box::new(Box::new(Rc::new(Rc::new(Box::new(Box::new(String::from(", world"))))))); println!("{}", append(&s1, &s2)); println!("{}", append2(&s1, &s2)); println!("{}", append3(&s1, &s2));} Rust 把这种语言特性叫做人体工学设计,为了减轻开发人员的负担。但是 Rust 在设计动不动会把变量给 move 掉、不得不使用 ' 单引号写法的时候,却放弃了人体工学,把内存安全放在了更重要的地位……倒是也没什么错,毕竟 Rust 只有内存安全是绝不能放松的。 最后再来个进阶难度的,假如在实际的业务场景中,有一个叫 do_something 的函数,接收泛型类型的参数,我们需要对这个函数基于原有逻辑做一些改动,原本的函数逻辑是这样: fn do_something<T1, T2>(t1: T1, t2: T2) { println!("{}", append(&t1, &t2));} 现在新增加一些处理: fn do_something<T1, T2>(t1: T1, t2: T2) { // 增加一个函数来处理 t1 handle_t1(&t1); println!("{}", append(&t1, &t2));} 那么问题来了,参数 t1 的类型是什么?handle_t1 函数的参数类型应该如何定义?在原有逻辑中,t1 作为参数对 append 函数进行了调用,是否意味着 t1 的类型是 &String?如果不是 &String,t1 的类型可能是什么?

2025/6/30
articleCard.readMore

Solana 智能合约开发教程 (3)

这个一个零基础的系列教程,可以从最基本的操作开始学会 Solana 智能合约的开发。 《第一篇》:基础环境安装、HelloWorld 合约部署、链上合约调用 《第二篇》:实现 USDT 合约的最小模型,自定义数据结构与方法 《第三篇》:使用官方 SPL 库复用合约功能,完成标准化代币的发行 你也许注意到,在编写智能合约的过程中,对于程序逻辑的描述反而是轻量的,比较复杂的部分是不同类型的 #[account] 宏,以及去了解宏接受的参数,比如是否允许自动创建账户、如果创建应该租用多少个字节的空间等,因为 Solana 的全部账户数据需要加载到节点服务器的内存中,价格比较昂贵,所以要求开发者对于空间的占用计算比较精细。而 Solana 的账户体系又有点复杂,需要稍微理解一下。 1. 命令行工具发行代币 对于发行 USDT 这种经典场景,Solana 已经封装好了智能合约的库函数,可以直接调用,甚至封装好了命令行工具,只需要简单的操作,不需要写合约,就可以发行代币。Solana 把这些代币统称为 SPL Token。创建一个 6 位精度的 SPL Token 的命令是这样,注意不需要写代币名字: spl-token create-token --decimals 6 命令行运行结束后,会输出一个 Address,这个就是 SPL Token 的代币地址,比如我得到的地址是 E75GMXAfJ91XuRboSpjwkDmta45Etgt3F3Gf5WLZvLbV,可以在 区块链浏览器 上查到。 接下来需要一个操作,来给你本地的账户,在这个 USDT 代币上创建一个关联账户(Associated Token Account,ATA)。这个创建关联账户的动作,相当于在合约上实例化一个数据结构,这个数据结构里保存了你的 USDT 余额等信息,如果没有这个数据,USDT 代币的合约上就找不到你。 用 “账户” 这个词可能有点迷惑,我本地已经有账户了,还能用 solana address 命令看到账户地址,为什么还需要专门调用 USDT 的合约,创建什么 ATA 账户?可以理解为,合约里本来有个空的 map{},创建 ATA 账户就是向 map 里插入了一条数据,key 是你本地的账户地址,value 是 USDT 的余额信息。如果 map 里没有你的信息,你甚至不能接受 USDT 的转账。 那么为什么 Solana 要这么设计,必须先在 map 里开辟空间,才能接受转账呢?因为一开始有提到过,对于 Solana 来说,链上空间是比较珍贵的,map 里开辟一个键值对的空间,也就是创建 ATA 账户,需要占用 165 个字节的内存,这 165 字节不是免费使用的,可以使用命令 solana rent 165 来计算字节数对应的费用,比如这里就会输出 0.00203928 SOL,也就是你创建 ATA 账户的交易,在手续费之外,会多支付这么些租金。所以必须要有创建 ATA 账户这个操作,主要是为了收费。 回到我们的操作,创建 ATA 账户的命令是: spl-token create-account E75GMXAfJ91XuRboSpjwkDmta45Etgt3F3Gf5WLZvLbV 这个命令会显示 Creating account,后面是你的 ATA 地址,比如我的是 E5XmcEJhhGUri8itThLGk8QfPzY1acFid8JmVyo5DWUo,同样的,可以在 区块链浏览器 中看得到。 对要注意,ATA 账户是有单独的地址的,比如你本地的账户地址是 a,在 USDT 代币上创建的 ADA 账户地址将是 b,是不一样的。而后续接受 USDT、发送 USDT,将全部通过 ATA 账户来进行,而不是你本地的那个账户。SPL Token 提供了命令来查看本地钱包账户和 ATA 账户的关系: spl-token address --verbose --token E75GMXAfJ91XuRboSpjwkDmta45Etgt3F3Gf5WLZvLbV// 输出是这个样子Wallet address: 75sFifxBt7zw1YrDfCdPjDCGDyKEqLWrBarPCLg6PHwbAssociated token address: E5XmcEJhhGUri8itThLGk8QfPzY1acFid8JmVyo5DWUo 那么现在,可以用这个命令,来查询 USDT 的余额,balance 后面的参数是指代币地址,而不是 ATA 地址: spl-token balance E75GMXAfJ91XuRboSpjwkDmta45Etgt3F3Gf5WLZvLbV 当然默认是 0,现在给这个地址挖一些 USDT 上去。这个命令有点长,有 3 个参数,第一个参数是代币地址,第二个参数是代币数量,第三个参数是 ATA 地址,意味着要挖哪个代币、挖多少、挖给谁: spl-token mint E75GMXAfJ91XuRboSpjwkDmta45Etgt3F3Gf5WLZvLbV 5 E5XmcEJhhGUri8itThLGk8QfPzY1acFid8JmVyo5DWUo 命令执行成功后,就可以查询到余额,也能直接在浏览器上看到余额了,类似的,转账 USDT 的命令是: spl-token transfer <MINT> 1 <ATA> Solana 为了避免用户不记得自己的 ATA 账户地址,也提供了人性化的命令,最后一个参数可以直接用本地的钱包地址,而不需要 ATA 地址,这也就是为什么我们平时使用 Solana 的钱包,并没有感觉到 ATA 账户这种东西存在的原因: spl-token transfer <MINT> 1 <RECIPIENT_WALLET> 2. 用 spl 标准库写智能合约 我们尝试一下在智能合约里调用 spl 库函数,这种官方提供的、系统级别的库函数是经过严格安全审计的,比我们自己写要安全,所以有了这些库函数,我们可以更加关注自己定制化的业务逻辑,不需要关心太底层的东西,比如 USDT 余额计算是否精度有损失之类的问题。先创建一个新项目: anchor init usdt_spl 导入 anchor-spl 依赖,这个命令可以把最新版本的库函数导入进来,命令运行后,可以在 programs/usdt_spl/Cargo.toml 文件的 [dependencies] 部分,新增了这样一行 anchor-spl = "0.31.1",说明是成功的: cargo add anchor-spl 开始写合约代码程序。先在最开始两行导入 spl 的依赖。我们之前有使用过 Anchor 框架自带的账户类型如 Account 和 Signer,那么这里 spl 也是提供了多种数据类型,比如 TokenAccount 就表示 ATA 账户的数据结构: use anchor_spl::token::{self, MintTo, Token, TokenAccount, Mint}; 接着定义 mint 行为相关的账户规则: #[derive(Accounts)]pub struct MintToCtx<'info> { #[account(mut)] pub mint: Account<'info, Mint>, #[account(mut)] pub to: Account<'info, TokenAccount>, #[account(mut)] pub authority: Signer<'info>, pub token_program: Program<'info, Token>,} 这几行代码中,mut 关键词我们之前用到过,表明账户数据要允许被写入。Account 类型是 anchor 框架自带的,我们也使用过。Mint 类型则是新出现的,是从 spl 框架里导入的,我们之前不是自己定义过一个用 #[Account] 宏标注的 Mint 结构体,然后在 #[derive(Accounts)] 里使用吗。现在有了 spl 库,我们不需要自己定义 Mint 结构体的类型、参数个数,直接使用就好。 同样的,TokenAccount 和 Token 也都是 spl 框架提供的类型。这么看似乎使用 spl 框架比自己写简单了不少?不能高兴的太早,还有一段代码没有写上: impl<'info> From<&MintToCtx<'info>> for CpiContext<'_, '_, '_, 'info, MintTo<'info>>{ fn from(accts: &MintToCtx<'info>) -> Self { let cpi_accounts = MintTo { mint: accts.mint.to_account_info(), to: accts.to.to_account_info(), authority: accts.authority.to_account_info(), }; CpiContext::new(accts.token_program.to_account_info(), cpi_accounts) }} 这段代码乍一看眼花缭乱,可能要晕了,为什么那么多尖括号,为什么那么多单引号和下划线。这就是 Rust,为了迎合独特的内存管理设计,不得不让语言在语法形式上变得复杂。 impl ... From<...> for ... 是 Rust 的语法规则,大意是让一种类型变为另一种类型,我们这里就是让 From<&MintToCtx<'info>> 类型变为 CpiContext<'_, '_, '_, 'info, MintTo<'info>>。其中 MintToCtx 是我们上面自己用 #[derive(Accounts)] 宏定义的类型,然后作为泛型参数传递给了 From,而这个 From,是 Rust 标准库提供的一个包装类型,用来接受我们传入的参数。 至于后面的 CpiContext 部分,Cpi 的全称是跨程序调用 Cross-Program Invocation,用于把要调用的外部程序,以及账户类型,都打包到一个统一的数据结构中。前三个参数不用管,最后的 MintTo 是我们真正传入的类型,这个类型是 spl 库提供的。 那么也许这里有疑问,为什么还涉及到调用外部程序?CpiContext 又是如何知道要调用哪个外部程序的?这个和 Solana 智能合约的设计有关,SPL Token 不止是一些类型定义,而且是实际已经部署在 Solana 网络上的程序。我们在使用 spl 依赖库的过程,实际上就是去调用那些已经预先在 Solana 网络上部署的 spl 合约。智能合约在运行的时候,发现你要调用 spl,就去找 spl 的合约地址,执行一些操作,然后返回结果。相当于整个网络上的智能合约都在复用同一套 spl 合约。 所以要留意 Solana 智能合约依赖库的实现方式,和其他网络是有不同的。Solana 在设计上让程序和数据分离,以致于可以实现程序共享的模式。为什么我们不自己部署一套 spl 合约,或者每个人都各自部署一套 spl 合约,然后自己使用呢?一方面是需要付出额外的手续费成本,另一方面是 Solana 的智能合约本来就允许程序共享,你要是自己部署一套,用户都不知道你有没有偷偷修改标准库的代码,反而不安全了。 还有最后一部分 #[program] 里的程序逻辑要补齐: pub fn mint_to(ctx: Context<MintToCtx>, amount: u64) -> Result<()> { token::mint_to((&*ctx.accounts).into(), amount)} 3. 编译合约 现在代码没问题,但是如果现在编译合约项目,会遇到报错。需要修改下 programs/usdt_spl/Cargo.toml 文件,把这两行的特性打开: [features]idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"][dependencies]anchor-spl = { version = "0.31.1", features = ["token", "idl-build"] } 因为静态编译的时候,命令行默认没有把 spl 标准库给带上,在配置文件里指明就可以了。现在项目可以编译成功: anchor build 4. 写单元测试 安装 spl 相关的 nodejs 依赖,注意单元测试用的是 ts 语言,不是 Rust 语言: npm i @coral-xyz/anchor@^0.31 @solana/spl-token chai 把单元测试代码复制到 tests/usdt_spl.ts 文件中: import anchor from "@coral-xyz/anchor";import { Program } from "@coral-xyz/anchor";import { createMint, createAssociatedTokenAccount, getAccount, TOKEN_PROGRAM_ID,} from "@solana/spl-token";import { assert } from "chai";const { AnchorProvider, BN } = anchor;describe("usdt_spl / mint_to", () => { const provider = AnchorProvider.env(); anchor.setProvider(provider); const program = anchor.workspace.UsdtSpl as Program; let mintPubkey: anchor.web3.PublicKey; let ata: anchor.web3.PublicKey; it("creates mint, mints 1 USDT into ATA", async () => { mintPubkey = await createMint( provider.connection, provider.wallet.payer, // fee-payer provider.wallet.publicKey, // mint authority null, // freeze authority 6 // decimals ); ata = await createAssociatedTokenAccount( provider.connection, provider.wallet.payer, // fee-payer mintPubkey, provider.wallet.publicKey // owner ); await program.methods .mintTo(new BN(1_000_000)) // 1 USDT .accounts({ mint: mintPubkey, to: ata, authority: provider.wallet.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) .rpc(); const accInfo = await getAccount(provider.connection, ata); assert.equal(accInfo.amount.toString(), "1000000"); });}); 运行单元测试,会看到成功的输出: anchor test 5. 部署合约到 devnet 确保账户里余额足够,然后用 anchor 来部署合约: anchor deploy --provider.cluster devnet 这个命令偶尔会因为网络问题执行失败,抛出 Operation timed out 错误。可以直接把 provider 的参数改为自己的 rpc 地址,如果网址比较长,可以用双引号括一下: anchor deploy --provider.cluster "<your-rpc-url>" 因为网络问题带来的麻烦有可能还不止如此,比如本地存在写入了一部分但是为完成的 buffer、链上存在 buffer 但是本地不存在导致状态不一致等问题,为了直接跳过那些问题,可以直接这种这样的命令: solana program deploy \ target/deploy/usdt_spl.so \ --program-id target/deploy/usdt_spl-keypair.json \ --url "<your-rpc-url>" 这个命令更加好用。如果没有带 --program-id 参数,这个命令会自动新生成 keypair,也就意味着会把合约部署的新的地址,这个根据自己的需求来选择。部署成功后,就可以去 区块链浏览器 上查看了。 6. 使用 SDK 调用链上合约 我们之前使用过 SDK,现在再来使用和复习一下,编辑 app/app.js 文件,把代码复制进去: // scripts/mint_to.js (CommonJS)const anchor = require("@coral-xyz/anchor");const { createMint, createAssociatedTokenAccount, getAccount, TOKEN_PROGRAM_ID,} = require("@solana/spl-token");const fs = require("fs");const os = require("os");const path = require("path");const { Keypair, Connection, PublicKey } = anchor.web3;const RPC_URL = process.env.RPC_URL || "https://api.devnet.solana.com";const connection = new Connection(RPC_URL, { commitment: "confirmed" });const secret = Uint8Array.from( JSON.parse(fs.readFileSync(path.join(os.homedir(), ".config/solana/id.json"))));const wallet = new anchor.Wallet(Keypair.fromSecretKey(secret));const provider = new anchor.AnchorProvider(connection, wallet, { preflightCommitment: 'confirmed',});anchor.setProvider(provider);const idl = JSON.parse(fs.readFileSync(path.resolve("target/idl/usdt_spl.json")));const prog = new anchor.Program(idl, provider);(async () => { const mint = await createMint(connection, wallet.payer, wallet.publicKey, null, 6); const ata = await createAssociatedTokenAccount(connection, wallet.payer, mint, wallet.publicKey); const sig = await prog.methods .mintTo(new anchor.BN(1_000_000)) .accounts({ mint, to: ata, authority: wallet.publicKey, tokenProgram: TOKEN_PROGRAM_ID }) .rpc(); console.log("tx:", sig); console.log(`explorer: https://explorer.solana.com/tx/${sig}?cluster=devnet`); const bal = await getAccount(connection, ata); console.log("balance:", bal.amount.toString());})(); 如果一切顺利,可以看到这样的运行结果: ~/work/github/sol_contract/usdt_spl main ❯ node app/app.jstx: 3MgHxsfnJp68mrrABvCh9iwNm6MSXp1SEvk7vDYHoW7KhTEHfVNyMWsbfbEAXTC9gLzcmWu5xbkzia8hgZrcZ18iexplorer: https://explorer.solana.com/tx/3MgHxsfnJp68mrrABvCh9iwNm6MSXp1SEvk7vDYHoW7KhTEHfVNyMWsbfbEAXTC9gLzcmWu5xbkzia8hgZrcZ18i?cluster=devnetbalance: 1000000

2025/6/28
articleCard.readMore

Solana 智能合约开发教程 (2)

这个一个零基础的系列教程,可以从最基本的操作开始学会 Solana 智能合约的开发。 《第一篇》:基础环境安装、HelloWorld 合约部署、链上合约调用 《第二篇》:实现 USDT 合约的最小模型,自定义数据结构与方法 《第三篇》:使用官方 SPL 库复用合约功能,完成标准化代币的发行 我们已经学会了如何创建智能合约项目、部署合约以及调用连上合约,接下来深入了解一下智能合约编程语言的写法,关注如何写出自己想要的逻辑。我们将会以写一个简单的 USDT 代币合约为例,分析相关的代码,并且理解 Solana 智能合约的写法。 1. 创建项目 用我们已经学会的命令,来创建一个新的项目: anchor init usdt_clone 2. 配置文件 可以注意到项目路径 programs/usdt_clone/Cargo.toml 下的这个文件,Cargo 是 Rust 语言常用的包管理器,这个 Cargo.toml 则是包管理器的配置文件,指定了要引入哪些依赖库,以及依赖库的版本。我们自动生成的配置文件里有这么两行: [dependencies]anchor-lang = "0.31.1" Anchor 提供的宏是 Solana 智能合约的关键,宏的形式如 #[program]、#[account] 等,这些宏会告诉 Solana 的 SVM 虚拟机,程序从哪里开始、数据结构在哪里定义等。如果没有 Anchor 这个依赖,合约项目就是普通的 Rust 语言项目了,Solana 的智能合约系统无法识别和解析。这也就解释了,Solana 的智能合约,是如何利用 Rust 语言来实现的。 3. 合约地址 我们近距离看一下合约的代码文件 usdt_clone/programs/usdt_clone/src/lib.rs。文件的第一行内容是这样,use 把 Anchor 常用的类型一下子全部导入进来了,这没什么问题,不需要修改,方便我们后续编写程序。: use anchor_lang::prelude::*; 第二行内容是一个对 declare_id 函数的调用,declare_id 声明了当前这个智能合约项目的 Program ID,也就是合约地址是什么,之前我们提到过,Solana 的智能合约地址,是可以离线生成的。 declare_id!("CFmGdHuqDymqJYBX44fyNjrFoJx6wRkZPkYgZqfkAQvT"); 这个合约地址是一个随机值,但不是随意格式的值,它是一个 Ed25529 的公钥。假如你手动把最后一个字符 T 改为 t,这整个字符串就不是一个合法的公钥了,所以这个值可以随机生成,但是不能随便改。那么既然是公钥,它的私钥在哪里呢?在初始化项目的时候,会自动生成一个私钥,文件位置在 target/deploy/usdt_clone-keypair.json,可以打开看到是一些字节数组,declare_id 使用的公钥,就是根据这个私钥生成的。 4. 储存数据结构 接下来我们需要新增一些自己的逻辑,在 declare_id 语句的下方,写入这个代码: #[account]pub struct Mint { pub decimals: u8, pub mint_authority: Pubkey,} 可以理解为 #[account] 宏是用来定义数据结构的,Anchor 黑魔法会在背后进行一系列操作,让我们可以针对这个数据结构在链上进行读写操作。这里的代码很简单,我们定义了一个叫 Mint 的结构体,这个结构体包含两个属性,decimals 指定 USDT 代币的精度是多少,mint_authority 指定谁可以来挖新的币。 我们继续定义另一个结构体,用来储存每一个用户的代币数量。owner 就是用户地址,balance 则是用户的余额: #[account]pub struct TokenAccount { pub owner: Pubkey, pub balance: u64,} 5. 账户约束结构 你可能注意到当前的代码文件最底部,还有两行自动生成的 #[derive(Accounts)] 开头的代码。这个宏是用来给账户写一些约束规则的。我们可以在 #[derive(Accounts)] 内部定义一些函数,然后再用 #[account] 来定义结构体,那么这个结构体就自动拥有了所有函数。类似于给结构体定义成员函数的意思。 把原本的 Initialize 代码删掉: #[derive(Accounts)]pub struct Initialize {} // 删除 然后写入我们自己的逻辑: #[derive(Accounts)]pub struct InitMint<'info> { #[account( init, payer = authority, space = 8 + 1 + 32 )] pub mint: Account<'info, Mint>, #[account(mut)] pub authority: Signer<'info>, pub system_program: Program<'info, System>,} 这段代码有点复杂。我们先看 #[account(...)] 这一段,这里给 account() 函数传递了 3 个参数进去,account() 函数的参数类型是 Anchor 框架定义的,第一个参数 init 是一个固定的关键字,不需要值,表示如果账户不存在,则创建一个新的账户。第二个参数 payer 是需要值的,表示谁来支付创建账户的手续费。第三个参数 space 的值则是我们自己计算的,系统必须预留 8 + Mint 结构体的第一个字段类型 u8 需要空间 1 + Mint 结构体的第二个字段类型 Pubkey 需要空间 32。 这个 #[account(...)] 的宏用来修饰 mint 成员变量。我们接着看 mint 这个成员变量,Account 是 Anchor 框架提供的内置的账户类型,可以对储存数据结构进行读写,例如我们之前定义的 Mint 或者 TokenAccount 结构,这个 mint 成员变量实际操作这些类型的数据。而 Account 接受两个泛型参数,第二个参数 Mint 指明了这个账户是在处理 Mint 类型的结构,而不是 TokenAccount 或者其他。 接着看 #[account(mut)] 这个宏,mut 的意思是账户金额可以变化。authority 也是一个成员变量,它的类型同样是一个 Anchor 内置的账户类型 Signer,与 Account 不同的是,Signer 意味着需要传入账户持有者本人签名,才符合类型定义。后面的 ‘info 则是一个泛型参数,其中 info 是结构体的泛型传递进来的。至于 info 前面的单引号 ',是 Rust 语言里的一个特性,可以简单理解为对参数的引用传递。整体来看,这两行代码的宏和语句,共同定义了一个可以对其扣费的账户地址作为成员变量。 最后的 system_program 成员变量,可以把这一行理解为固定写法,只要合约需要转账 SOL,就得写上这一行。总的来说,这几行代码定义了一个新的结构体 InitMint,这个结构体是基于 Mint 进行包装的,包装后的 InitMint 拥有了一些账户相关的属性。 6. 代币合约初始化 接下来开始关注 #[program] 宏定义的函数。这个宏用来标注智能合约的程序入口,也就是真正执行合约逻辑的部分。我们当前文件里有几行默认的代码: #[program]pub mod usdt_clone { use super::*; pub fn initialize(ctx: Context<Initialize>) -> Result<()> { // 删除 msg!("Greetings from: {:?}", ctx.program_id); // 删除 Ok(()) // 删除 } // 删除} 删掉这个项目自动生成的 initialize 函数,我们自己写一个函数: pub fn init_mint(ctx: Context<InitMint>, decimals: u8) -> Result<()> { let mint = &mut ctx.accounts.mint; mint.decimals = decimals; mint.mint_authority = ctx.accounts.authority.key(); Ok(())} 把这个 init_mint 函数放在原先 initialize 函数的位置。如果抛开 Anchor 的宏,这个函数则是一个普通的 Rust 语法定义的函数。Context 类型是 Anchor 提供的包装类型 所以你也许好奇我们明明没有定义 Context,但是这里却直接使用了。InitMint 类型是则我们上一个步骤定义好的。 这个函数接受两个参数,第一个参数的类型是 InitMint,表示哪个账户拥有铸币权限。第二个参数类型是 u8,表示 USDT 的精度是多少位。这个函数返回一个空的元组 (),说明如果成功什么都不返回,如果失败则会报错。 函数内部的逻辑相对好理解,函数把参数接收进来的数据,赋值给了一个叫 mint 的变量,要注意这不是普通的新定义的变量,而是从 ctx.accounts 反序列化过来的、mut 声明的可变类型的变量,相当于直接修改一个引用类型的结构体内的属性值,所以只要给 mint 赋值,结构体内的数据都会保存下来,也就是保存到链上。 7. 单元测试 可以先到目录下,运行一下编译,看程序是否写对了,如果编译报错,可能是哪里复制漏了。由于 Rust 语言的编译器非常严格,所以即使没有错误,也会有很多 warning,暂时不用管那些警告信息: anchor build 接下来到 usdt_clone/tests/usdt_clone.ts 文件,复制这些代码进去: import anchor from "@coral-xyz/anchor";import { Program } from "@coral-xyz/anchor";import { SystemProgram, Keypair } from "@solana/web3.js";import { assert } from "chai";const { AnchorProvider, BN } = anchor;describe("usdt_clone / init_mint", () => { const provider = AnchorProvider.env(); anchor.setProvider(provider); const program = anchor.workspace.UsdtClone as Program; const mintKey = Keypair.generate(); it("creates a Mint with correct metadata", async () => { const txSig = await program.methods .initMint(new BN(6)) .accounts({ mint: mintKey.publicKey, authority: provider.wallet.publicKey, systemProgram: SystemProgram.programId, }) .signers([mintKey]) .rpc(); console.log("tx:", txSig); const mintAccount = await program.account.mint.fetch(mintKey.publicKey); assert.equal(mintAccount.decimals, 6); assert.equal( mintAccount.mintAuthority.toBase58(), provider.wallet.publicKey.toBase58() ); });}); 这段代码使用本地的单元测试框架,构造了一些参数去调用我们在合约里写的 initMint 方法,比如指定精度为 6 位,传递了 InitMint 结构体需要的 3 个参数等。模拟交易的执行结果赋值给了 txSig 变量,可以在输出日志中看到交易哈希。并且在交易结束后,用语句 program.account.mint.fetch 查询了合约的 mint 属性的值,它的精度应该等于我们的参数,authority 也应该是我们本地发起模拟交易的账户地址。 运行这个命令来查看单元测试的效果: anchor test 如果一切顺利,会看到 1 passing (460ms) 的字样。 8. 开户和转账 基于上面我们已经看懂的语法规则,可以继续在合约代码中新增这样两个账户结构的定义,分别用来开户和转账。这里的 #[error_code] 是新出现的宏,比较容易理解,它是一个枚举类型,用于程序报错的时候调用: #[derive(Accounts)]pub struct InitTokenAccount<'info> { #[account(init, payer = owner, space = 8 + 32 + 8)] pub token: Account<'info, TokenAccount>, #[account(mut, signer)] pub owner: Signer<'info>, pub system_program: Program<'info, System>,}#[derive(Accounts)]pub struct Transfer<'info> { #[account(mut, has_one = owner)] pub from: Account<'info, TokenAccount>, #[account(mut)] pub to: Account<'info, TokenAccount>, #[account(signer)] pub owner: Signer<'info>,}#[error_code]pub enum ErrorCode { InsufficientFunds, ArithmeticOverflow,} 然后新增两个方法,分别执行开户的逻辑以及转账的逻辑。注意这里开户的时候,token.balance = 1000 意味着每一个开户的地址,默认都会有 1000 的余额。这里主要是为了简化流程和代码、方便单元测试,这个数字可以随意改动: pub fn init_token_account(ctx: Context<InitTokenAccount>) -> Result<()> { let token = &mut ctx.accounts.token; token.owner = ctx.accounts.owner.key(); token.balance = 1000; Ok(())}pub fn transfer(ctx: Context<Transfer>, amount: u64) -> Result<()> { let from = &mut ctx.accounts.from; let to = &mut ctx.accounts.to; require!(from.balance >= amount, ErrorCode::InsufficientFunds); from.balance -= amount; to.balance = to .balance .checked_add(amount) .ok_or(ErrorCode::ArithmeticOverflow)?; Ok(())} 这是针对开户和转账功能的单元测试代码: const tokenA = Keypair.generate();const tokenB = Keypair.generate();it("initializes tokenA & tokenB, each with balance 1000", async () => { for (const tok of [tokenA, tokenB]) { await program.methods .initTokenAccount() .accounts({ token: tok.publicKey, owner: provider.wallet.publicKey, systemProgram: SystemProgram.programId, }) .signers([tok]) .rpc(); const acc = await program.account.tokenAccount.fetch(tok.publicKey); assert.equal( acc.owner.toBase58(), provider.wallet.publicKey.toBase58() ); assert.equal(acc.balance.toNumber(), 1000); }});it("transfers 250 from A to B (balances 750 / 1250)", async () => { await program.methods .transfer(new BN(250)) .accounts({ from: tokenA.publicKey, to: tokenB.publicKey, owner: provider.wallet.publicKey, }) .rpc(); const a = await program.account.tokenAccount.fetch(tokenA.publicKey); const b = await program.account.tokenAccount.fetch(tokenB.publicKey); assert.equal(a.balance.toNumber(), 750); assert.equal(b.balance.toNumber(), 1250);}); 如果有兴趣,可以试着把这个合约也部署到 devnet 上,然后通过 SDK 来发起对链上合约的调用。

2025/6/26
articleCard.readMore

Solana 智能合约开发教程 (1)

这个一个零基础的系列教程,可以从最基本的操作开始学会 Solana 智能合约的开发。 《第一篇》:基础环境安装、HelloWorld 合约部署、链上合约调用 《第二篇》:实现 USDT 合约的最小模型,自定义数据结构与方法 《第三篇》:使用官方 SPL 库复用合约功能,完成标准化代币的发行 我们将从最基础的操作开始,学习 Solana 智能合约的开发。你只需要普通的编程基础,理解面向对象等概念就可以,不需要事先知道其他网络的智能合约概念,也不需要知道 Rust 语言的编程理念。 1. 安装环境 访问 Solana 官方提供的安装教程:https://solana.com/docs/intro/installation 文档中提供了一键安装全部依赖的单个命令行,也有分阶段安装的详细教程。要注意其中 Solana Cli 是需要修改环境变量文件的。安装好一切后,solana 命令应该是可用的: solana --help 2. 初始化项目 使用 anchor 命令来初始化一个智能合约的项目,这个命令行工具在上个步骤已经安装好了,可以先不用管生成的目录结构是什么样子: anchor init hello_solcd hello_sol 3. 写入合约代码 programs/hello_sol/src 目录下有一个 lib.rs 文件,.rs 结尾意味着这是一个 Rust 语言的代码文件。把这些代码复制进去,注意 declare_id 中的内容是你的项目在初始化的时候,就会自动为你生成,不需要原封不动复制下面的内容: use anchor_lang::prelude::*;declare_id!("3Zbdw1oWu1CiMiQr3moQeT4XzMgeqmCvjH5R5wroDWQH");#[program]pub mod hello_sol { use super::*; pub fn say_hello(ctx: Context<Hello>) -> Result<()> { msg!("Hello, world!"); Ok(()) }}#[derive(Accounts)]pub struct Hello {} 4. 编译智能合约 使用 anchor 命令编译你刚才复制进去的智能合约代码,确保编译是成功的,代码没有写错。编译过程中可能会有一些警告,那些警告不要紧,因为 Rust 语言对于代码非常严格,很小的问题都会抛出大段的警告。如果一切顺利,命令行的输出不会有错误日志: anchor build 5. 设置本地默认网络 运行这个命令,让你本地的 solana 命令默认使用 devnet,因为 devnet 是给开发者使用的,可以用来测试自己的程序,而不需要真的花钱去买 SOL 代币: solana config set --url https://api.devnet.solana.com 6. 创建本地账户文件 这个命令用于在你本地的默认路径下,创建一个用来部署智能合约的 Solana 账户。因为部署智能合约需要消耗手续费,这些手续费需要一个账户来支付: solana-keygen new -o ~/.config/solana/id.json 这个命令的运行结果中,有一行 pubkey: 开头的输出,pubkey 后面的就是你本地的账户地址。因为上一个步骤已经设置了 devnet 为默认网络,所以可以直接使用这个命令来查看你本地账户的余额: solana balance 也可以打开 devnet 的 浏览器,搜索你刚才生成的地址。搜索之后的 URL 形如:https://explorer.solana.com/address/75sFifxBt7zw1YrDfCdPjDCGDyKEqLWrBarPCLg6PHwb?cluster=devnet 当然,你会发现自己的账户余额是 0 SOL。 7. 领取 devnet 上的空投 运行这个命令,你的账户就可以收到 2 个 SOL。其中参数里的 2 就是请求发放 2 个 SOL 的意思。因为领水的额度限制,你只能一次性最多领 2 个。不用担心太少,足够我们接下来的步骤使用了。 solana airdrop 2 8. 部署合约到 devnet 现在我们已经有了智能合约代码,有了本地账户,并且本地账户里有 SOL 余额。现在可以部署合约到 devnet 上了。运行这个命令: anchor deploy --provider.cluster devnet 如果部署成功,会看到 Deploy success 的字样。命令行输出中还有一行需要留意,Program Id: 后面的,就是部署之后的合约地址,你可以直接在 devnet 的浏览器上搜索这个地址,然后看到类似这个 URL 的页面,URL 中的 3Zbdw1oWu1CiMiQr3moQeT4XzMgeqmCvjH5R5wroDWQH 就是我部署的合约地址:https://explorer.solana.com/address/3Zbdw1oWu1CiMiQr3moQeT4XzMgeqmCvjH5R5wroDWQH?cluster=devnet 9. 调用链上合约 到 hello_sol/app 目录下,新建一个叫 app.js 的文件,把这些代码复制进去。简单来说,这段代码读取了你本地默认的账户文件,然后用你的 Solana 账户发起一笔对智能合约调用的交易,这个脚本每执行一次,就会在链上创建一笔交易。: const anchor = require('@coral-xyz/anchor');const fs = require('fs');const os = require('os');const path = require('path');const { Keypair, Connection } = anchor.web3;const RPC_URL = process.env.RPC_URL;const connection = new Connection(RPC_URL, { commitment: 'confirmed' });const secretKey = Uint8Array.from( JSON.parse( fs.readFileSync( path.join(os.homedir(), '.config/solana/id.json'), 'utf8', ), ),);const wallet = new anchor.Wallet(Keypair.fromSecretKey(secretKey));const provider = new anchor.AnchorProvider(connection, wallet, { preflightCommitment: 'confirmed',});anchor.setProvider(provider);const idlPath = path.resolve(__dirname, '../target/idl/hello_sol.json');const idl = JSON.parse(fs.readFileSync(idlPath, 'utf8'));const program = new anchor.Program(idl, provider);(async () => { try { const sig = await program.methods.sayHello().rpc(); console.log('✅ tx', sig); console.log(`🌐 https://explorer.solana.com/tx/${sig}?cluster=devnet`); } catch (err) { console.error('❌', err); }})(); 返回 hello_sol 项目的顶层目录,执行这些命令来安装 nodejs 的依赖: npm init -y npm install @coral-xyz/anchor 然后记得现在仍然是在顶层目录,运行这个命令,来执行刚才写的 app.js 脚本,脚本会到 devnet 上调用我们部署的智能合约: export RPC_URL=https://api.devnet.solana.comnode app/app.js 这里有一个环境变量 RPC_URL 是脚本请求的 API 地址,因为 nodejs 脚本默认不走系统代理,所以对于网络受阻的同学,需要用一个比公开 RPC 更好用的 API 地址。可以使用例如 Helius 的服务,注册一个免费的账号就可以了。假如执行脚本的过程中遇到下面的错误,那就说明是网络问题,换一个好用的 RPC 地址就好了: ❌ Error: failed to get recent blockhash: TypeError: fetch failed at Connection.getLatestBlockhash (/Users/smallyu/work/github/hello_sol/node_modules/@solana/web3.js/lib/index.cjs.js:7236:13) at process.processTicksAndRejections (node:internal/process/task_queues:95:5) at async AnchorProvider.sendAndConfirm (/Users/smallyu/work/github/hello_sol/node_modules/@coral-xyz/anchor/dist/cjs/provider.js:89:35) at async MethodsBuilder.rpc [as _rpcFn] (/Users/smallyu/work/github/hello_sol/node_modules/@coral-xyz/anchor/dist/cjs/program/namespace/rpc.js:15:24) at async /Users/smallyu/work/github/hello_sol/app/app.js:40:17 你也许好奇为什么不需要指定调用的合约地址,这个脚本怎么知道你刚才,部署到链上的合约在哪里?注意看脚本中有一个 idlPath 的变量,你可以直接打开这个路径的文件 target/idl/hello_sol.json 查看,里面是一些合约编译后的元信息,包括合约的地址也在里面,没错合约地址是离线生成的,不需要上链,合约就有属于自己的唯一地址了。 如果执行脚本没有输出错误,就会看到终端打印出了这一次调用合约的交易哈希,以及可以直接复制访问的浏览器 URL,例如这就是一笔调用合约的交易:https://explorer.solana.com/tx/2fnPgKkv3tGKKq72hhRxmW6WFSXuofMzXfY2UYoFZXTdJi37btdESy9NzS2gjpWzXX4CL5F7QfxugpctBVaMcBFY?cluster=devnet 这笔交易页面的最下方,可以看到我们写的智能合约在被交易调用后,打印出了 Program logged: "Hello, world!" 的日志,这正是我们写在合约代码中的 msg。 10. Troubleshooting 如果在执行上述命令或者代码的过程中,遇到了错误,可以优先考虑是命令行工具版本的问题。由于区块链行业和技术迭代比较快,很容易出现版本不兼容的情况。我本地的环境和版本是: rustup: rustup 1.28.2 (e4f3ad6f8 2025-04-28)rustc: rustc 1.90.0-nightly (706f244db 2025-06-23)solana: solana-cli 2.2.18 (src:8392f753; feat:3073396398, client:Agave)archor: anchor-cli 0.31.1node: v24.2.0@coral-xyz/anchor(nodejs): ^0.31.1

2025/6/24
articleCard.readMore

尝试开发一个最小 EVM 虚拟机

我给这个项目命名为 echoevm.com,主要目标是从最简单的堆栈操作开始,逐步实现一个完整的以太坊字节码执行环境。 为什么选择这个方向?解析下以太坊客户端的技术模块: RPC:GRPC 套壳?重点在于协议设计而不是技术实现 P2P:有现成的 libp2p 可用,无非是节点发现、路由表之类,比如深入下 Kademlia DHT? 账户体系:ECDSA?密码学? 交易池:交易分析、密封交易、MEV保护方向 共识机制:共识机制的设计属于研究级别,至少得是个博士发论文、实验室里做研究、出各种测试数据,然后证明在哪方面做出了业界前沿的优化、最后融资雇人做工程化的实现 储存:搞数据库底层的专家应该干什么都一样,哪里都有用武之地,跟区块链没关系 数据结构:去研究 Merkle Patricia Tree 的实现吗? 状态同步:轻节点方向,比如用 Celestia 的核心技术把执行和储存分开,或者 Archive 节点数据的 offload? 综合来看,我倾向于做一件侧重工程而不是学术、同时又有技术含量的事情,无论是从个人技术能力的提升,还是后续有可能带来的成果上,都要有意义。假如这个最小EVM开发出来了,是可以带来一系列成果的,后续也可以基于此延伸出很多更有价值的产品。 从 Solidity 语言到 bytecode 的转换过程,那是编译器专家干的事情,我要做的,是针对 bytecode 做执行,先从最简单的加法运算和 jump 开始,然后是 Gas 的计算、上下文环境的切换,直到能够执行全部以太坊历史交易。 v0.0.1(2025.05.27) 实现了一个非常简单的版本,现在可以用 solc 编译一个 Add.sol 合约,然后让 echoevm 读取生成的 Add.bin 部署代码,就会输出合约部署之后的运行时代码。 在实现这个版本的过程中,学习到的东西是部署代码和运行时代码的区别。我们一般会先部署一个合约到链上,然后再对这个合约产生调用,这实际上是两个不同的操作,但又都在使用相同的 EVM 执行,EVM 并不关心输入的 bytecode 是部署还是调用,只是对不同的操作码处理方式不同。一般部署代码会同时包含 CODECOPY 和 RETURN 两个操作码,可以利用这一点来区分输入的类型。 v0.0.2(2025.06.09) 这个版本增加了运行 runtime bytecode 的能力,也就是先部署合约,然后再针对部署之后的合约内容,进行调用,调用的时候可以带上一些参数,比如: go run ./cmd/echoevm -bin ./build/Add.bin -function 'add(uint256,uint256)' -args "3,5" 这个命令的含义是,会执行 ./build/Add.bin 文件内的 bytecode,并且调用 add 函数,传入参数 3 和 5,最终程序运行结束后,会返回出计算结果 8。 v0.0.3(2025.06.24) 好消息,现在 echoevm 已经可以执行以太坊主网前 10000 个区块的合约交易!因为前 10000 个区块根本没有合约交易 :P 这个版本新增了执行以太坊区块的模式,可以执行单个区块执行,也可以执行区块范围执行。当然,还需要一个获取区块数据的 url,注意对于以太坊早期的区块数据,得找 archive 模式的节点。整个命令行看起来是这样: echoevm -start-block 0 -end-block 10000 -rpc <url> 现在 echoevm 支持的字节码有限,如果执行最新的一些区块交易,会发现报错说不支持某些字节码,这个是正常现象。 v0.0.4(2025.07.05) 这个版本新增加了从 artifact 文件读取 bytecode 数据的能力,就是 hardhat 项目在编译的时候会生成的那个 artifact 文件。之前的版本只能用读取 solc 编译生成的二进制文件,编译合约的命令是这样: # 编译合约生成字节码npx --yes solc --bin Add.sol -o ./build# 运行 echoevm 来执行字节码go run ./cmd/echoevm run -bin ./test/bins/build/Add_sol_Add.bin -function "add(uint256,uint256)" -args "1,2" 现在的版本更简单一点,对于标准的 hardhat 项目,每次执行这个编译命令都会生成 artifact 文件,echoevm 可以直接读取 json 文件并执行: # 编译 hardhat 项目的合约npx hardhat compile# 如果愿意,可以运行 hardhat 项目的测试npx hardhat test# 运行 echoevm 来执行字节码go run ./cmd/echoevm run -artifact ./test/contract/artifacts/contracts/Add.sol/Add.json -function "add(uint256,uint256)" -args "1,2" 这个版本同样新增了一些字节码的支持,但还是不足以执行完整的以太坊区块。接下来会手动按照 Solidity 的语法特性,来逐步增加测试用例和观察字节码的欠缺情况,这也就是为什么这个版本重点优化执行方式的原因。 v0.0.5(2025.07.27) 这是一个小版本,主要是增加了比较完善的 Solidity合约 作为测试用例,涵盖基本数据类型、函数、控制流、modifier、事件、接口、library、内联汇编等 Solidity 的语法特性。 而且提供了便捷的命令,只需要在项目根目录下运行这个命令,就可以看到全部测试的结果: make test-advanced 当然全部测试是通过的。但是目前仍然无法执行以太坊主网的第 10000000 个区块,意味着缺少的 opcode 不属于 solidity 的基本语法特性,可能是别的什么。 v0.0.6(2025.11.27) 这个 小版本 新增了两个命令,debug 命令可以用于展示字节码的执行过程: ~/work/github/echoevm > ./bin/echoevm run --debug 60016002PC OP GAS STACK (Top) ------------------------------------------------------------0002 PUSH1 0 0x10004 PUSH1 0 0x2Return: 0x repl 命令 就像比特币的 btcdeb 一样,可以交互式地输入字节码并执行: ~/work/github/echoevm > ./bin/echoevm repl EchoEVM REPLType opcodes (e.g., 'PUSH1 01 ADD') or hex (e.g., '600101'). Type 'exit' to quit.> PUSH1 10Stack [1]: 0000: 0x10> PUSH1 20Stack [2]: 0001: 0x20 0000: 0x10> ADDStack [1]: 0000: 0x30> exit

2025/5/11
articleCard.readMore

基于 ZK 的链上身份系统设计

我给这个系统取名 zkgate.fun,主要想发挥零知识证明的特性,结合区块链做个小工具。关键功能是实现,用户证明自己属于某一个群组,但是不需要暴露自己真实的链上身份。 目前的设想是这样,管理员首先有一个名单列表,可以是以太坊地址的数组,然后根据这个地址列表,计算出一个 Merkle Root Hash。接着把这个 root hash 提交到智能合约上。处于这个名单中的人,可以使用 Circom 电路的 proving key,来给自己生成一个 zk proof,随后将 zk proof 提交到智能合约上。 在智能合约上,会使用 Circom 电路生成的 verifier.sol,对收到的 zk proof 进行验证,判断用于生成 zk proof 的地址,是否在 Merkle Root Hash 中,最后将判断结果返回。 这样的话,管理员不需要公开自己的群组中有哪些地址,属于群组中的地址也不需要声明自己的身份,只需要提交零知识证明生成的 zk proof,就可以证明自己真的归属于这个群组。我接下来会具体在技术上实现这个设计。 更新 v0.1.0 版本 (2025.05.09) 首先要纠正之前设计中的一个错误的地方,管理员必须要公开自己群组的地址列表,否则无法根据地址列表来生成 Merkle Tree,用户也无法根据树结构,来找到自己地址所在的节点位置、生成路径证明。 其次是很高兴地说,现在跑通了一个非常初级的 Demo(smallyunet/zkgate-demo),这个 Demo 功能并不完善,甚至没有办法在电路中验证地址的所有权,但至少是一个工具链路层面的跑通。 具体实现是这样: 有一个 链下程序 来根据地址列表,以及自己的地址,生成 zk 电路的 inputs.json,这个输入文件包含了 Merkle Root Hash 和验证节点位置所需要的路径 根据 电路代码 来编译出一些 二进制文件,这些编译后的产物是用来生成 witness 文件的 基于公开的 ptau 文件 生成 .zkey 文件 从 .zkey 文件中导出 proof.json, public.json, verification_key.json,这 3 个 json 文件可以做链下离线验证,证明 prove 的有效性 从 .zkey 文件中导出 .sol 文件,也就是智能合约代码,部署到链上 拿着 prove.json 文件和 public.json 文件的内容,作为 参数 调用合约的 verifyProof函数,如果 prove 有效则返回 true,否则返回 false 假如一个地址不在群组列表中,有两种情况: 试图用一个不在群组列表中的 地址 生成 inputs.json,然后拿着 inputs.json 去根据电路生成 prove,会直接被电路拒绝报错 试图用一些假的 prove 参数 提交到链上做验证,最终无法通过链上验证 那么目前这个最初级版本的 Demo,问题在于,构建 prove 使用的是明文地址,比如: const members = [ "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",];const proofKey = toField(members[0]);const { siblings } = await tree.find(proofKey); 这个语句的含义是在让 zk 电路判断,members[0] 是否属于 members 数组构建出来的树结构,这显然是属于的。如果想要用不属于群组的地址构建 prove,只需要替换一下 proofKey 指向的地址: const nonMemberAddress = "0x1234567890123456789012345678901234567890";const proofKey = toField(nonMemberAddress);const { siblings } = await tree.find(proofKey); 也就是说,members 列表必须是公开的,而现在的程序只能判断一个地址在不在 members 里面,但即使 members[0] 不是我的地址,我也能用来构建一个合法的 prove。那还要 zk 干嘛? 所以下一步要解决的问题,是让用户用私钥对某个消息进行签名,然后在 zk 电路中根据签名 recover 出地址,接着判断 recover 出来的地址是否属于 members 数组。 这个过程是不是听起来简单?可实际上用 zk 电路来 recover 出一个 ECDSA 签名算法的地址,别说复杂度非常高,难度就像用乐高搭核电站一样。难怪人们都说,搞 zk 真的很掉头发。 更新 v0.2.0 版本(2025.05.13) 这个版本解决了验证地址所有权的问题,基本思路是让 zk 证明和地址所有权的证明分开,链下用 zk 证明地址的路径在 Merkle Root 上,链上需要用户提交用私钥对 root 的签名,并且将签名提交到链上。然后合约 recover 出签名的地址,跟 zk 电路的 prove 中包含的地址信息对比。 1. zk prove 包含地址信息 -> 链上验证 zk prove -> 得知 zk prove 中的地址信息2. 用私钥对 root 签名 -> 链上得到签名 -> recover 出签名对应的地址信息3. 判断 zk prove 中的地址 == 签名 recover 出的地址 演示代码具体改动的地方有: offchain 部分的代码不需要变动,生成 inputs.json 的脚本中 inputs 里已经有 key 的信息了 电路代码中,需要把 inputs 中的 key 变为 public 合约代码需要接受用户的 签名 作为参数,并且得到 recover 出的地址,将这个地址与 proof key 进行对比 调用合约的脚本,需要用私钥对 root 进行签名,并且把签名数据作为参数调用合约 到此为止,zkgate.fun 实现的功能是,群组管理员不必在链上公开自己的群组成员信息,只需要提交 Merkle Root Hash 到链上。对于群组内的成员,需要完整的成员列表,以及自己地址对应私钥签名后的信息,就可以生成 zk prove 去链上,证明自己确实是群组内的成员。 在这个过程中,使用 zk 唯一隐藏掉的信息,是群组成员的完整信息不必上链公开,只需要一个 Merkle Root Hash。而用户的地址目前无法隐藏,必须提交到链上用于验证。 更新(2025.05.14) 有一个现有的、以太坊基金会支持的、工具链和生态都已经比较成熟的 zk 协议,同样是用来做身份验证的项目,叫 Semaphore,官网是这个,可以直接在上面体验一下包含前端界面的 Demo: https://semaphore.pse.dev/ 在 zkgate.fun 前面两个版本的迭代中,没有选择 Semaphore 使用 EdDSA 账户体系的方案,主要是不想脱离以太坊的账户体系,也不想放弃 ECDSA,而实际上只有 EdDSA 是 zk 友好的,可以使用 Poseidon Hash 签名,zk 电路中也能对签名进行验证,不需要 “链下签名、链上 recover” 这种丑陋的实现方式。 不得不说,从个人学习的角度,虽然没几天的时间,但是我已经大概理解了 zk(工具链)的操作过程。从行业前沿的角度,我仅凭个人力量不可能做的比 Semaphore 更好。即使 zkgate.fun 进一步开发出前端界面、可视化地演示出具体的交互过程,也顶多就是 Semaphore 的这个 Demo 的样子,而且技术上没有 Semaphore 硬核。 所以 zkgate.fun 这个项目不再继续开发,域名一年后会自动到期,不再续费。

2025/4/30
articleCard.readMore

一个 Web3 打赏系统的设计

产品形态 giveme.wtf 是我刚注册的一个域名,计划做一个 web3 打赏的小工具,类似的 web2 平台有: https://buymeacoffee.com/ https://linktr.ee/ 与之不同的是,giveme.wtf 的个人页面上,将显示 web3 钱包的收款地址、二维码,就像 Paypal 的个人收款链接一样,并且同时支持多种链的地址格式,包括比特币、以太坊、狗狗币等,可以自由选择。 giveme.wtf 不做任何资金的中转,仅仅只是展示打赏地址这一信息,比如,访问 giveme.wtf/{username},这个页面将显示出 username 设置好的收款地址信息,包括以太坊地址文本是什么,二维码是什么。就这么简单。 当然 giveme.wtf/{username} 下,也可以设置简单的 bio,头像、域名、社交媒体等,像是一个小型的个人主页,让人知道你是谁,稍微更值得分享出去一点。 技术实现 注册 user 使用 MetaMask 钱包注册,连接钱包后可以设置 username,username 是全局唯一的,在智能合约上管理,user 需要发一笔与合约交互的交易,来将自己心仪的 username 提交到合约上。 profile 信息 绑定好 EVM 地址与 username 的关系后,就可以设置 profile 信息,包括头像、bio、钱包地址等。 填写信息后,前端页面将数据提交到后端,后端用 IPFS 节点保存这些数据(长期开启 Pin),同时生成 CID 信息,将 CID 返回给前端。 前端收到 CID 后,再发起一次合约交互,将 username->CID 的映射关系,写入到智能合约里。这个步骤可以和注册步骤合并,也可以拆开,因为有时候 user 只想注册,不想设置 profile。 展示 合约上的 username->CID 是最权威的数据,前端页面将根据 giveme.wtf/{username} 中的 username,从合约中获取到 CID,再拿着 CID 去 IPFS 的网关查询出具体数据,根据数据渲染出页面。 profile 会是一些非常精简的 json 数据,数据量很小,同时为了加快网关的查询速度,可以用 Cloudflare 提供的 web3 gateway CDN。 网络选择 智能合约部署在 base 上。 扩展优化 后期可以根据链上数据,统计出使用打赏系统的收款地址,以及收到打赏的金额总量,做个排行榜,按照 username 或者链分类,分析出一堆数据。 如果上了排行榜,username 下的 bio 可以增大曝光率。给你心目中的偶像上分吧,让他保持在榜首。 还可以增加一些 24小时榜单、PK 性质之类的排名。 同时也可以扩展到社交系统,如有打赏记录的地址可以形成关系图谱,甚至可以直接以某种 IM 工具的方式通讯、自动拉群等。 username 找回 MetaMask 钱包注册的问题在于,钱包丢了怎么办,是不是就失去了对 username 的控制。这里可以设计一个恢复机制,比如允许 username 设置一个恢复地址列表,只要是这个恢复列表中的地址,都可以找回 username 的控制权,进而改变 username 对应的 CID。这个机制主要是针对钱包遗失的情况。 至于钱包被黑了怎么办,黑客岂不是能直接修改恢复地址的列表。他都已经有 username 控制权了,再改也是改成他的地址,加固他对 username 的控制权。那么有没有钱包被黑还能夺回控制权的办法?web3 里没有。 网络的选择 目前必须要选择一条链来部署智能合约,智能合约是数据正确性的来源。那么选择哪条链其实是个问题,因为作为 user,不一定有链上的代币作手续费。 比如选择了 base,那么 user 首先得有 ETH,其次得在 base 上有 ETH,然后才能后续的操作。光是这两步,就能劝退大多数人。 那么为了解决这个问题,后面可以考虑的方向是手续费代付,用 ERC-4337 (现在差不多凉了)的 paymaster,或者比较原始的 Meta Transaction 方式。但是又得考虑到薅羊毛的问题,代付也得付得起才行。 数据可用性 MVP 里的方案是,数据用 IPFS 存,但仅仅只有一个服务器。IPFS 是比较底层的文件路由协议,可以考虑在上面包一层,像 Filecoin 一样,但是不会有 Filecoin 那么复杂,因为 giveme.wtf 的数据量比较小。PoST 难用的地方就在于需要对文件做加密解密,因为文件太大又不能全量校验,但 giveme.wtf 不一样,往简单了做就行,比如验证一下 Merkle Root Hash,也就是说,后面需要在 IPFS 的基础上,加上适当的文件校验和激励机制,让更多的节点愿意存下 giveme.wtf 完整的数据,然后用一种方式来定期检查每个节点是否真的储存了完整数据,如果存了,就给一点奖励。具体奖励给什么再说。 链下数据缓存 每次前端页面都从合约上查 username->CID,交互太慢了,而且消耗节点的 rpc 资源。需要考虑链下来缓存这部分数据,比如有一个中心化的后台程序,监听合约的事件,实时拿到 username->CID 的内容,然后写入到 Cloudflare Workers KV 服务里。前端页面首先请求 Cloudflare Workers KV,如果没有内容再 fallback 到合约上查。 那么这里又涉及到一个问题,如果中心化的服务作恶,或者被黑了怎么办,username->CID 的映射关系一改,钱直接打到黑客的地址上了。 这个链下数据完整性校验的问题,其实是 Optimistic Rollup 在解决的问题,也有相对成熟的方案。然后结合 Zetachain 的跨链逻辑,可以这样设想。 首先用来缓存的链下程序,将每一个 username->CID 的数据作为子节点,构建一个 Merkle Tree,最终会得到一个 Merkle Root Hash,这个 root hash 将是校验数据完整性的凭证,把这个 root hash 定时提交到合约上,前端页面去合约上查一下这个 root hash,就可以知道从缓存里拿到的 CID 有没有被篡改。 其次链下的索引程序可以有多个,通过 TSS 协商出一个私钥,只有这个私钥,才可以向合约提交 Metkle Hash Root,并且这多个索引程序,只有 root hash 相同,才会协商成功。相当于做了多签。 最后是冷静期+挑战期,Merkle Root 提交之后,在冷静期内不生效,同时任何人都可以发起挑战,如果挑战成功,则新提交的 Root 作废,继续用旧的 Root。当然这个步骤中的挑战是很麻烦的,得考虑到怎么发起挑战,尤其是怎么挑战才算是成功这个机制。但是好在不用着急做那么复杂,这个属于后期可以优化的方向。

2025/4/29
articleCard.readMore

鼓吹 Cursor 的人技术能力都差

有点标题党,不要太介意。我想表达的是不应该过于关注使用的工具。很简单的逻辑,只有自己工程能力不如 Cursor,才会觉得 Cursor 厉害,干什么都行。当然这句话属于比较空的废话。 事实上,Cursor 的代码补全能力没有比 GitHub Copilot 好多少,底层使用的模型是一样的,不是 Claude 就是GPT。而在工程化调教方面,Cursor 和 Copilot 都是在把一些文件作为 context,继续后面的对话。 Cursor 比 Copilot 好一点的地方在于,会自己去当前工程目录下,搜索和参考其他文件的写法,这一点确实有用,你不需要告诉他具体引用那哪些文件,他自己会不断的尝试,Copilot 这种插件是不具备这种能力的,这确实是好的一面,如果项目下存在大量可复用的代码,Cursor 可以比较好的发挥出它的能力。 但有时候又会觉得 Cursor 过度智能,它甚至会自己在你的工程下面创建新文件,而不需要经过你同意,这就导致在用 Cursor 的时候需要时刻关注,他是否改变了你预期之外的代码文件。相比之下,(GoLand 下的)Copilot 只是插件的形式,只会给出代码片段,用不用是你自己的事情。VS Code 下的 Copilot 现在和 Cursor 倒是有类似的体验了,会给你一个接受或不接受更改的选项。 所以相比来说, Cursor 和 Copilot 使用了一样的大语言模型(不会有人觉得 Cursor 自己训练了个模型出来吧),然后 Cursor 拥有更大的、项目级别的控制力,而 Copilot 像他的名字一样,只是辅助级别的能力,这是它们最大的区别。 回到模型本身,o1 是迄今为止最厉害的模型,在日常工作中深有体会,我经常用 o1 来精准定位编程中遇到的 bug,而对于相同的问题,其他模型往往给出错误或者不准确的解释,包括 o3-mini-high 和 4o。 比如,在调用智能合约的时候,原本的命令是 cast call requestPrice(string) "BTC" —-rpc-url=http://eth:8545 现在需要调用另一个函数,比原本的函数多了一个入参,我就直接复制代码写成 cast call requestPrice(string) "BTC" "USD" —-rpc-url=http://eth:8545 不知道你有没有第一时间看出问题?其实 4o 倒是给出了正确的分析,只是没有特别精准,给出了 3 种有可能的错误原因,而 o1 直接就说对了。 当然大模型之间的差异不会体现在这么微小的问题上,只是正好有印象就随便提一下,我想表达的是,从日常体验来看,o1 是最好用的模型。 我严重怀疑鼓吹 Cursor 的人,都是之前没有使用过 AI、体会过 AI 强大能力的人,在用到 Cursor 之后,才明白原来现在的生成式 AI 已经这么强大了,欣喜若狂。而当需要使用生成式 AI 的时候,他们的第一反应不是打开 ChatGPT 的聊天框或者 Gotk3 的界面,而是打开了 Cursor 的代码框,开始去聊天。 所以鼓吹 Cursor 的人,实际上是把生成式 AI 的能力,误以为是 Cursor 的能力,才因此觉得 Cursor 异常强大。 我在工作中就不止一次被强烈推荐使用 Cursor ,让我别再用 GoLand,还说出 “GoLand 就是垃圾” 这种话,Cursor 最好用什么的。 首先我一直觉得喜欢用什么编辑器是个人的选择,管这个干啥。其次如果觉得使用什么编辑器会给使用者带来鄙视链和优越感,未免有点小儿科。最后就是我喜欢用 GoLand 的理由,只有两点:1.箭头非常直观的表达了接口的实现关系。2.前进后退快捷键很好用。基于这两点,我才可以比较快速的读懂和理解代码。 也因此带来一个差异是,使用 GoLand 的人,往往更加关注代码逻辑,比如在寻找代码位置的时候,喜欢通过代码之间的跳转,例如接口的实现关系等。而使用 Cursor(VS Code)的人,更加关注项目的目录结构、文件名、文件的位置,因为 Cursor 没有提供很好的代码跳转功能,所以不得不更加依赖通过项目结构来梳理代码功能。 代码的自动补全方面,GoLand+Copilot 插件能应付日常场景,需要补全的往往是打日志之类的内容,简单用用就可以,我不太敢用 AI 写侧重逻辑的代码。 给我推荐使用 Cursor 可能还有一种心理就是,觉得我不知道怎么使用 AI,或者觉得我不知道怎么使用 Cursor (?),也挺奇怪的。我在 2023 年(ChatGPT 开始大火的那段时间)就试用了 ChatGPT,还写了《不要小瞧 ChatGPT》,而现在不论是日常工作还是生活,都在高频率使用 ChatGPT。 总的来说,我的意思是,你可以喜欢 Cursor,也可以使用 Cursor,但最好不要鼓吹 Cursor。

2025/4/12
articleCard.readMore

关于 Code Review 的礼节

前情提要,推荐王垠的两篇博客文章: 《怎样尊重一个程序员》 《关于Git的礼节》 由于过往不规范的工作经历,我之前是缺少对 Code Review 的理解的。最近因为同事对这个问题情绪化的表达,我开始关注到关于 Code Review 的问题。 基本礼仪:不要用 FYI 谷歌公开出来的 Code Review 规范 《The Standard of Code Review》已经非常具有指导意义,内容很全面,包括我现在实际遇到的流程问题,也完全可以依照这个规范来消化解决。当然前提是所有团队成员事先对这个规范的内容已经达成一致,而不是假设公司的员工已经知道并且开始遵循这个规范。 关于规范(TSCR)中已经提到的流程问题、礼貌问题,这里是不需要赘述的。我关注到的是其中一个小章节《Label comment severity》,也就是对 Code Review 之后的 comment 进行重要程度的区分,并且加上前缀,让 author 可以明确知道哪些留言是必须要改的,哪些是无关紧要的。 除去必须要改的 comment 不加前缀,谷歌的规范中提到有三类前缀,这些都是指站在 Reviewer 的立场,如何去写 comment,本质上这三类前缀都不影响代码的 approve 和合并: Nit (Nitpick): 你应该改,但是不改我也能接受。 Optional:只是建议,你自由选择改还是不改。 FYI (For Your Information):这个 PR 中完全不需要因此有改动,但我觉得这是一个有意思的点,后续你可以关注下。 其中第三个前缀 FYI,每当看到 For Your Information 这个短语的时候,我总是下意识的会把这个短语翻译为中国传统社会普遍流行的一句古话: “为了你好”。 相信在中国本土长大的华人,即使没有遭受过严苛古怪的家庭教育,也都能深刻理解到 “为了你好” 的威力。 为了你好,你要好好学习 为了你好,你不能打游戏 为了你好,你要考公务员 为了你好,你要早点结婚 为了你好,你必须生孩子 …… 一些情况下,父母的 “为了你好” 只是他们满足自己变态控制欲望的借口,另一些情况下,有些父母发自真心的 “为了你好”,然后由于自身有限的眼光给出了不正确的建议。 总的来说,中文语境下的 “为了你好” 绝不是什么好词,如果把这种话语带到工作中,就更匪夷所思了。虽然 For Your Information 并不能直译为 “为了你好”,但是为了避免歧义,还是建议大家不要使用这样的话术。 所以,Code Review 的基本礼仪就是,不要用 FYI。 为什么 “为了你好” 往往是错的 先说结论 因为没有人可以在实际上了解另一个人。 父母真的了解自己的孩子吗?结婚多年的夫妻,真的知道对方的心思吗?审查犯人的警察,足够了解自己在调查的罪犯了吗?心理学的专家,能猜到自己女朋友早上为什么生气吗?对于每一个人,你真的了解自己吗? 所以其实可以得到这样一个和技术无关的结论,出于礼貌,我们应该尽量避免对别人说 “为了你好”。 具体事例 最近的工作中,我提交的代码包含一个简单的事件总线(Event bus)的实现,其中事件的 Publish 和 Subcribe 都用了 RW 锁来保证 map 读写的线程安全: func (eb *EventBus) Publish(event Event) { eb.mu.RLock() defer eb.mu.RUnlock() if ch, exists := eb.channels[event]; exists { ch <- event }} 而我得到的 CR 建议是用 Sync map 改写为: func (eb *EventBus) Publish(event Event) { if ch, ok := eb.channels.Load(event); ok { // 注:这里需要类型断言 ch.(chan Event) <- event }} 这实际上最多是一个 Nit 级别的 comment,我是不太在意的,这个事情的处理起来也非常简单。 而事实上问题在于,我听到了类似于 “为了你好” 的话,大致意思是,为了你好,你要了解清楚 RW 锁、互斥锁、Sync map 的区别,然后选择在 Event bus 场景下最正确的实现方式,并且能够条理清晰地去说服别人。 如果说我从自身技术发展的角度,是不太在乎这种问题的。这个问题本质上是我们平时面试时候说的八股文,随着最近几年面试风向的转变,也越来越多的人开始逐渐达成一致、反感八股文一类的东西了。 当然追求这一类底层问题到极致的人,肯定是有技术追求的人,这并不是什么坏事,我们应该尊重任何努力以及对技术较真的人。只是技术也分很多种方面。 我的兴趣 我算不算有技术追求的人呢?也许有时候算吧。无论是技术还是别的东西,其实我们所有人都愿意追求有趣的东西。 从过往经历来看,我关心的技术话题并且乐于出自兴趣去学习、思考的,比如: PoW 和 PoS 的本质区别是什么,PoW 好还是 PoS 好 《为什么说 PoW 比 PoS 更加去中心化》 以太坊的 PoS 和 Cardano 的 PoS 有什么区别 为什么说 Cardano 的 PoS 是比较纯粹的 PoS,又为什么以太坊的 PoS 更加去中心化 PBFT 的优缺点是什么,PBFT 有哪些优化的空间 《所有 BFT 共识的区块链都是中心化的》 《Ethereum Casper 为什么需要 EIP-7251》 《区块链中的 PBFT 不需要第二次投票》 不同类型的区块链是如何处理分叉的 《对区块链共识机制的理解》 《PoS 类型的区块链如何处理分叉》 区块链可能有哪些有趣的应用场景 《Pebbling Game 鹅卵石游戏》 《一种在区块链上生成随机数的机制》 《“猜均值的2/3” dApp 游戏设计》 知道这些东西有用吗?没什么用,面试的时候几乎不会有人问,工作中更是用不到,就仅仅只是出于兴趣爱好去探索这些技术问题。这些问题的答案,从来也都不是现成的,网络上是搜索不到的,ChatGPT 也是没办法精准回答的,只有经过一段时间的学习,加上查阅论文资料,结合自己的亲身经历和理解,才可以形成技术观点,无论观点本身是对还是错。 因此可能出于某种思维上的惯性,我很少关心太过基础的编程类问题。这种事情因人而异,不能强求,自然也不能强迫别人因为 FYI 就去关心某些问题。 FYI:成为好的 writer (刚说完不要 FYI,我这里就在 FYI) 《Rework》 这本书里有一个章节印象挺深,标题是 “Hire great writers”,书中的观点是,不是因为工作中需要发表什么文章、写什么报告,而是好的 writer 往往具备逻辑清晰表达问题的能力,可以帮助到工作。 回到技术问题上,现在各种形式的技术文章也都非常普遍,比如对锁的使用场景比较有心得的话,完全可以落实到文字上,输出成果形成一篇文章,分门别类的介绍锁的种类、最佳的使用场景,然后发表到各种平台上,获得成千上万人的关注,再然后如果对其他技术细节也有心得,内容逐渐丰富,直到写出了一本书,甚至有出版社看上,或者公开到网络上作为电子书开源……FYI……何必跟我较真呢。

2025/3/25
articleCard.readMore

假如启动一个新的以太坊 PoS 网络

OIIA OIIA(Spining Cat)是最近很火的一只鬼畜旋转猫,对比 PoW 链上的 DOGE,OIIA 将是 PoS 链上的猫主题 memecoin。 OIIA 与 pump.fun 发行的 memecoin,以及 $Trump 之类不同的地方在于,所有代币的发行量都将通过 PoS 挖矿新增,没有任何预分配(可以验证 genesis 文件),未通过挖矿产生的代币,花钱都买不到。 网络动机 《发行加密货币的最好方式》 Oiia 将会提供更便捷的搭建以太坊 PoS 网络的工具,进一步降低搭建以太坊 PoS 网络的难度 为什么使用者要参与 如果参与者没有相关经验,作为学习者,可以: 学习如何搭建一个完整的以太坊 PoS 网络 学习如何启动和运维一个以太坊 Validator 节点,如何质押、如何解除质押 学习如何熟练使用以太坊生态节点相关的工具 发现并解决以太坊生态工具使用上的问题,给以太坊生态做贡献 为什么不用以太坊测试网 作为以太坊网络的学习者,为什么不直接用 Sepolia 这样的测试网去用,而是选择 Oiia Network? 因为 Oiia Network 的定位是 memecoin。 网络 Spec 技术基础 使用以太坊客户端 Geth + Lighthouse 作为初始节点。只修改启动配置和 genesis 文件,不修改代码。 Chain ID 以及 Network ID 十进制: 20220915 十六进制: 0x1348BF3 初始 Validator 128 个,这个是网络启动的最小规模,会直接写入到 genesis.ssz 文件中。 初始 Faucet 由于一开始网络的参与人数会比较少,会预留 128*32 = 4096 OIIA 作为水龙头余额,放到水龙头地址中。 水龙头使用 PoW Faucet(网页挖矿)的形式来分配。 水龙头的目的是提供少量的流通金额用于网络的测试使用,以及早期愿意参与到网络中的 Validator(虽然靠水龙头很难领到 32 个 OIIA)。预留额度上,如果有 128 个 solo-staker,这个网络就算是巨大的成功了,所以认为预留 4096 个 OIIA 够用。 初始发行量 为了避免类似以太坊基金会抛售的问题,OIIA 不会预分配任何金额给开发人员或 DAO,几乎所有网络都会因为预分配受到怀疑。 以太坊的 PoS 共识没有发行量上限,所以网络的初始发行量就是 4096 个 OIIA。网络启动后的流通量全部依靠挖矿奖励来产出,就像比特币一样。 也就是说,从 Oiia Network 的 genesis 文件来看,除了 4096 个 OIIA 预留用于的空投外(创世节点的 128 个 Validator 不体现在 genesis 文件上,价值 4096 OIIA),不会再预分配任何金额给任何地址。 如何成为 Validator 由于网络初始代币发行量特别少,Faucet 上又领不到足够多的 OIIA,所以可以在社区中申请成为 Validator,社区直接从 Faceut 地址转账 32 OIIA 到申请地址。 网络启动进度 由于没有任何商业目的,网络的启动进度会比较随心所欲。

2025/1/28
articleCard.readMore

发行加密货币的最好方式

前几天某个互联网无关行业的知名人物,在 pump.fun 上发行了一个 memecoin。然后这两天看到有人在公开频道里讨论,关于把 memecoin 迁移为公链的方案,比如保留原始地址和余额启动新链之类。 这个 memecoin 的动机很简单,就是发行者需要募集资金,因为他们从事的活动需要资金来源,也因为他们在从事的活动,这个 memecoin 已经被很多钱包屏蔽掉了。 当然我们这里不会讨论发行加密货币的动机,只会讨论发行加密货币的技术手段,并且对技术手段进行对比。 最好的方式 先说结论,发行加密货币,最可靠的技术方案是,直接使用以太坊的客户端,运行一条和以太坊 Chain ID 不同的链。 为什么不是 pump.fun pump.fun 是一个很好的平台,提供了一键发币的能力,能保证合约的安全性,不 rug pull,没有预售,没有安全漏洞。这一点相比于自己写合约发币,要方便和安全很多, pump.fun 最厉害的地方在于,发币即可交易,使用 bonding curve 机制来定义价格,让一个币种即使交易者很少,也可以正常交易,这是其他发币方式做不到的。 不过 pump.fun 也有问题,首先是 SOL 生态,不知道为什么 pump.fun 最初选择了 Solana 而不是 Ethereum,买家在购买新发的币种之前,要先理解和拥有 SOL 才可以继续后续的步骤。虽然 SOL 也非常知名了,但买家为什么要先知道 SOL? 其次是当币种市值在 100k 以下,这个币的交易行为就和 pump.fun 平台绑定了,不访问 pump.fun 这个网站,你就找不到可以交易的地方。如果网站没了,或者域名没了,就真的没有入口了,这对于币种的长期发展并不友好。市值到 100k 以上就会进入 Raydium,那么有多少买家理解 Raydium是什么?岂不是又依赖于一个Dex平台? 所以享受 pump.fun 提供便利的同时,就要承担 pump.fun 这个平台本身的风险,还要承受 Solana 这条链有可能出现的风险,比如,Solana 会长久存在吗?多久算久? pump,fun 对自己的定位还挺准确的,memecoin 发行平台。 为什么不是 PoW PoW 是最去中心化的技术形式,但是发行 PoW 链的成本太高了,不但没有现成的技术框架可以复用,需要硬核的技术,而且维护成本也很高,没有人挖矿就得自己挖,算力还不能太低。 为什么不是 Cosmos Cosmos 生态的项目往往伴随着两个负面的关键词,BFT,联盟链,所以可以排除了。 ATOM 是一个市值仅排名 50 左右的币种,生态上有自己的垂直领域,可以说,如果不知道为什么要用,就不要用。 另外 Cosmos 的生态建设其实不好,到目前都没有一个像 Metamask 一样能连接任意 RPC 的钱包,要真用上 Cosmos 会遇到不少问题。 为什么不是 Polkdot / Avalanche 区块链行业专业的从业者,估计都没整明白、用不来这两条链,不在于它们无法理解,而是理解成本高。 而且像 Polkdot 比如 Existential Deposit 这种特性简直离谱,不明白为什么会存在。类似的未知问题还有很多,是不能轻易选择使用的。 为什么不是 Ethereum Layer 2 以太坊的 Layer 2 本身也不是发币用的,是给项目方挣协议费的,L2 是以太坊的扩展,原生代币仍然是 ETH。而且 L2 虽然技术开源也好用,但是需要中心化的运营,以及不间断提交 fault proofs 到 Layer 1,手续费得用真实的 ETH,挺贵的,如果用户少,手续费都挣不回来。 为什么不在 Ethereum 上发行 ERC-20 智能合约一般人写不明白,主要是安全漏洞风险高,即使发行方觉得合约没问题,买家也很难判断合约安不安全,识别难度很高,所以不推荐这种方式。即使合约没问题,以太坊的手续费也很贵,很不友好。 Layer 2上发行 ERC-20 呢?问题是选择哪个 L2 网络?L2 网络的手续费倒是低,但是不同网络数据又不互通,从这个角度,L2 在杀掉以太坊,至少让以太坊变得分裂,而不是在帮助以太坊。 为什么是 Ethereum 不可否认的几点事实是: 以太坊的 EVM 已经成为区块链行业最广泛认可和使用的智能合约标准 以太坊的 PoS 是除了 PoW 之外最去中心化的共识机制 以太坊的地位无法撼动,ETH Killer 也许会在某些指标上超越以太坊,但 EVM 标准这一点不会 相比于其他公链,以太坊的社区生态更加活跃,基础设施更加完善 新兴公链都在试图兼容 EVM,而不是推翻 EVM 基于这些事实,发行区块链网络最好的方式只有一种: 使用以太坊客户端,修改配置文件和启动参数之后,启动一个 Layer 1 网络 从网络运行的角度,即使只使用目前版本的以太坊客户端,哪怕后续客户端不再跟随以太坊的步骤进行升级,也可以让网络长久稳定运行下去。 至于手续费,币价低手续费就便宜,所以几乎不太可能贵到手续费无法接受。 如何解决初期交易和空投问题 除了 pump.fun 平台和 PoW 链,其余的发币方式都无法解决初始阶段币种交易的问题,初期上不了 CEX 也上不了 DEX,买家怎么买,拿什么买? 唯一能想到的就是以 ICO 的方式预售,在 TGE 的时候正式启动网络。尽管这个过程中往往会产生一大堆不和谐的事件和争议,但是如果用以太坊的节点,只要把 Genesis 文件中的每一个地址都说明,网络本身就可以是大家认可并且没有问题的。 网络创世没问题之后,剩下的就交给网络自身的通胀,也不会有明显的问题,网络就能平稳运行下去了。

2025/1/10
articleCard.readMore

所有 BFT 共识的区块链都是中心化的

首先给出一个共识机制在去中心化程度上的排名,这个排名几乎是毋庸置疑的: PoW > PoS > DPoS > BFT 然后从处理分叉的角度,对比一下 PoS 和 BFT 的差异。 因为 BFT 算法本身决定了,所有使用 BFT 共识的链,都不会存在分叉,无论是软分叉还是硬分叉。没有分叉的链,意味着整个网络同一时刻只会有一个版本,而这个版本取决于项目发行方,哪怕项目发行方不是官方,这一版本也只能来自于某个中心化的组织。所以,使用 BFT 共识的区块链都是中心化的。 假如网络发行方对网络进行了让人无法接受的更改,会发生什么? 在 PoS 共识下,验证者可以选择旧的规则,也可以选择新的规则,这两种规则可以同时存在,直到大多数验证者达成一致,网络恢复一致。如果验证者始终无法达成一致,就会一直分叉下去。 在 BFT 共识下,验证者可以选择旧的规则,也可以选择新的规则,但是如果一方数量达到半数,网络将会停止。直到验证者线下达成一致,网络才会重新启动。 也就是说,当面临本应该分叉的情形时,BFT 会直接停机,这也是为什么 Solona 和 SUI 都出现过网络停止的原因。 到这里你就明白,这里说的中心化,是指在 BFT 网络中不会同时存在两个网络,当然使用其他共识的网络也几乎不会出现这种情况,但是容许这种情况发生。 更进一步的说明,这里说的中心化,是指 BFT 网络中如果一定比例的验证者想要让网络停止,网络就可以停止,只能通过新启动另外一个网络(其实也属于硬分叉的一种)来让网络恢复正常。 这种差异会产生什么影响?以太坊网络中,即使大多数节点已经挂掉,只要还有少数存在,网络就能够正常运行。而 BFT 网络对验证者的容错能力不到一半,如果半数验证者停掉,网络会直接瘫痪,你的所有链上资产无法继续转移。 从投资的角度,如果你打算长期持有某种代币,你觉得哪种网络更安全,更能让你的资产安全受到保障? 不过还要注意的是一点,网络的可靠性不一定来自于去中心化程度,Coinbase 的 Base 网络可靠性来自于美国政府的监管和半合规化,很多交易所和政府机构都会把钱放到 Coinbase Prime 的信托服务里,所以 Base 网络也是比较可靠的。

2025/1/5
articleCard.readMore

对 2025 年区块链行业的预测

2024 年发生的事情: 比特币减半、比特币通过现货 ETF、比特币新高 特朗普喊单 BTC,马斯克喊单 DOGE 比特币叙事熄火,铭文、符文无人问津,Layer 2 技术没有一个出圈(Lighting Network、Taproot、RGB、RGB++),Layer 2 项目没有一个靠谱(Nervos、Merlin、Nubit、Fractal Bitcoin) 以太坊 Cancun 升级引入 Blob,让 Layer 2 成本大幅降低,但是现在 Layer 2 充值到交易所仍然需要25分钟等待期,这一点体验很不好,既然有可能重组,交易就是不安全的 NFT 市场消失,Coinbase 和 Binance 关闭 NFT 交易市场 DEXX 交易所被盗,上千名用户上千万资金,交易所是华人背景,用户也是华人背景,小白多 2025 年的趋势预测: 比特币价格新高到 14 万美元 比特币不需要 OP_CAT 以太坊的地位无法撼动,所有 ETH Killer 都没有潜力,包括 Solona、Ton、Tron、Polkdot、Cardano、Avalanche、Cosmos、SUI 现有的公链格局不会改变,也不会有新的高市值 Layer 1 出现 会有新的技术整合类型的链出现,把低成本的东西作为原生功能,比如预言机、随机数、链上治理、Subnet、Web Assembly、DID 等老技术大杂烩,为了提高吸引力,还会蹭 ZK 和 AI 热度,但会发现实际上 ZK 起不到作用,和 AI 也没有关系 跨链一直是刚需,但一直没有去中心化的方案落地,以后也不会有 新的链一定会兼容 EVM,新的项目也会优先支持 EVM 生态类的项目还会不断出现,这些项目会追随某一条公链的技术,干自己的事情,Ethereum / Polkdot / Cosmos / Internet Computer / Avalanche 都提供了这样的生态环境 作为一个普通人,想参与到区块链中,能做的只有 3 件事: 定投比特币 定投比特币 定投比特币

2024/12/16
articleCard.readMore

Restaking 项目的经济难题

Restaking 是一个相对早期的赛道,其中比较有名的项目是 Eigen Layer,有一段时间很火,因为 Eigen Layer 高薪聘请了以太坊基金会的 Researcher,舆论认为以太坊基金会的成员拿了好处,以至于让以太坊往中心化的方向发展了。 Eigen Layer 搞得声势浩大,不可否认的是 Restaking 的商业模式行得通,因为 Eigen Layer 已经把路走出来了,在运作模式上不用做过多怀疑。 但是回到 Restaking 的定义上,仍然有问题值得思考。比如,为什么是 Restaking,而不是 Staking? 因为 Restaking 项目的本质不是技术问题,而是经济问题。换句话说,Restaking 项目没有技术壁垒,有的是商业模式壁垒,关键看在商业合作上能不能运转起来。 为什么这么说? 为什么要 Restaking?因为收益有限。作为一个质押用户,我先把 ETH 质押成 stETH,可以稳定拿 3% 的质押收益,在此基础上,我把 stETH 质押到 Eigen Layer 上,有收益最好,没有也无所谓,反正是白给的,哪怕没有收益,我还有 3% 的保底收益。 如果是 Staking 呢?就有问题了。我手里拿着 ETH,是去拿 stETH 的稳定 3%,还是去拿 Eigne Layer 上不靠谱的项目收益? 这里就涉及到一个问题,Eigen Layer 的收益来自哪里? PoS 链的质押奖励是原生的,非常稳定。Eigen Layer 呢?奖励只能是来自于使用 AVS 提供服务的用户。那么,AVS 提供了什么服务? 有一个理论是这样: 对于 PoS 链的逻辑,用户质押了 token,来给 validator 提供质押权重,然后,validator 提供对块数据的验证。 Restaking 项目中,用户质押了 token,来给 operator 提供质押权重,然后,operator 可以对任意计算任务提供验证。 听起来是不是很诱人?PoS 链竟然是 Restaking 项目的子集,这么说起来 Restaking 大有可为。 这种理论有一定道理,不过稍微较真一点,和 Restaking 比较的应该是 DPoS 共识,而不是 PoS 共识。在去中心化程度上,DPoS 是要比 PoS 差的。 那么就拿 DPoS 和 Restaking 项目比,Restaking 项目会具有更大的优势吗? 这里得再考虑一个问题,为什么 DPoS 优先被用于对块数据的验证了?而没有拿着用户的质押份额去验证别的东西? 答案很简单,因为没有比用户资产更重要的数据了。比如,是银行存款的余额数据重要,还是明天天气预报数据的准确度重要?所以 DPoS 在此前有且仅有一个场景,那就是验证区块交易数据,而且,想再找到一个比资产数据更重要的场景,是非常困难的。 在明确了这个概念后,回到 Restaking 收益的问题,假如 DPoS 能提供 3% 的质押收益,Restaking 能提供多少收益呢?按道理是少于 3% 的,因为 Restaking 在验证的数据,不会比用户的资产数据更重要。 这就是为什么 Restaking 项目一定要 Restaking 而不是直接 Staking,因为收益率比不过 Staking。在 Staking 的基础上做 Restaking,性质就不一样了。 回到收益来源的问题,其实 DPoS 和 Restaking 的质押收益,都来自于使用服务的用户。 DPoS 中,有一批人质押了 token 成为 validator,另一批人依赖于 validator 提供的资产安全能力。所以用户允许 DPoS 链增发 3% 的 token 作为块奖励。供应总量增加,不就相当于洗劫了所有人嘛。 Restaking 中,有一批人质押了 token 成为 operator,另一批人依赖于 operator 提供的数据验证能力,基本逻辑和 DPoS 是相似的。这里的 “另一批人”,就指使用 AVS 服务的用户,而这些用户,也将会为 Restaking 的收益付费。 Restaking 的收益不可能凭空产生,直接来源就是用户,而付费方式也可以很简单,就是字面意思的付费,我调用一次 AVS 上的某个数据,就付费 0.1 美元,类似这种方式,直接计费就好了,都不需要涉及什么供应量。 用户支付的费用,有多少才够呢,假如有 1M 的 stETH 质押量,为了给质押者提供 1% 的年化,假如有 1 万个用户每天使用一次的话…… 到这里问题就又来了,有哪个冤种用户会愿意为了这种服务花钱?要知道以太坊 L1 的链上交易,一次 1 美元可就贵死了,更是有很多很多人,舍不得开 9 块钱的爱奇艺会员看视频,无数程序员,舍不得为自己日夜使用的 IDEA 买个正版…… 当然,只要泡沫不破裂,大家的财富就都在膨胀,牛市来临之后,没有人是在亏钱的,只要泡沫不破裂,只要能够在泡沫破裂前离场,一切都会很美好。

2024/11/18
articleCard.readMore

如何看懂任意区块链项目的技术架构

基本结构 最基本的区块链,就是一条区块链本身,包含有通过共识出块的能力,可能有很多节点,也可能只有一个节点,每个节点都提供接收交易的 RPC 接口: 因为以太坊的诞生,区块链的交易不仅仅是交易,还具有了智能合约的能力: 总会有一个外部的程序来和智能合约交互,也就是发起交易: 从这里开始需要明确两种行为: 凡是和区块链有交互的,可能是提交交易或者查询交易,都算是链上交互 和区块链没有任何交互的,属于链下行为 向智能合约发起交互一定属于链上交互,同样的,无论发起交易的外部程序,是用什么语言写的,可能是 Javascript 或者 Golang,都叫做智能合约的 SDK: 除了以太坊的 EVM 合约,还有可能是其他虚拟机(SVM、WASM)的合约,或者 Cosmos 直接操作状态数据库的方式(Native合约),可以统一理解为链上智能合约: 只要是链上合约,都会需要链下程序来发起交易,才能够实现某些功能。 而当智能合约有了具体的逻辑,很可能会触发一些事件,这些事件往往由链下节点来监听和处理: 这个链下节点起什么名字都可以,用什么语言写都可以,总之会获取到合约中的事件,一般监听事件的方式有两种: 主动查,不断请求节点的接口,看有没有新的事件 被动接受,比如 websocket 建立的长链接 当合约里触发某个事件后,链下节点监听到事件,会根据事件进入不同代码分支,后面进行多么复杂的操作都可以。 链下节点可能有多个,也可能有多种角色,但是不重要,重要的是,他们都是在和链上的事件进行交互: 一般链下节点之间不会擅自进行通信,而是紧跟区块链的块内容,因为链下节点也需要保持状态的一致,区块链场景下,链本身已经是非常好的能提供状态一致的手段了: 所以,这个时候你就知道,其实对于区块链项目来说,链下节点和链上节点之间,只有两种动作: 通过交易向区块链发送数据 通过监听事件来从区块链查询事件 Cosmos 有一个能支持 EVM 合约的项目,还提供了 evm_hook 的接口,当 EVM 合约触发事件的时候会主动调用接口函数。这种 hook 函数本质上也是合约事件的被动监听,无非是从合约事件到链下节点调用的方式,从 RPC 请求变成了函数的直接调用。唯一增加的复杂度是 EVM 合约触发了 Cosmos 模块的函数,把两种合约的实现方式串起来了: 复杂架构也不会改变的是,链下节点和链上节点之前的交互,只有两种动作。 如果再复杂一点的话,会往什么方向复杂呢,链下节点开始向链上提交交易了: 也许在监听到事件后提交了一笔交易,交易触发了另一个事件,监听到另一个事件后进行了更多的操作。但是总之,链下节点和链上节点之前的交互,仍然只有两种动作。 填充业务逻辑 有了基本的技术手段,再往上填充业务逻辑,就容易理解了。 面对很长的业务逻辑,总是能分清楚哪些部分是链上交互、哪些部分是链下行为,核心区别在于数据状态存在哪里,是区块链上,还是链下节点: 如果是通过交易发送到链上,业务的复杂程度就取决于合约的逻辑。 如果是链下节点监听到事件后的行为,那就取决于链下代码的复杂程度。 如果复杂度超出了上述两种情况,就只能是链下节点之间脱离区块链进行了擅自的通信,并且还产生了不一致的状态,这样的链下节点可以认为已经在区块链项目之外了。

2024/10/15
articleCard.readMore

为什么不要做以太坊的二次开发

道理很简单,以太坊是有核心开发团队的,以太坊基金会为中心,各个以太坊客户端的开发团队,都拿了以太坊基金会的钱给以太坊做开发。Vitalik 来中国融资,拿完钱到某个国家组建开发团队,密集开发一年后有了以太坊。当年以太坊基金会以不到 $1 的价格参与以太坊的 ICO,现在 ETH 的价格已经涨了几千倍,以太坊基金会的钱就是这么来的。 以太坊生态主要有三个部分,Layer 1、Layer 2 以及生态类项目。(Staking 类不需要你开发,Restaking 和技术有关系?) Layer 1 部分,有几大客户端团队,执行层的 Geth、Nethermind、Besu、Erigon、Reth,共识层的 Prysm、Lighthouse、Teku、Nimbus、Lodestar,先不管这些团队和以太坊的利益关系如何,如果你自己或者你所在的公司,说是想要基于以太坊的客户端做二次开发,那么请问,要开发什么? 如果是对以太坊网络本身有益的改进,能够提高性能、优化数据结构之类,你大可以直接给以太坊提交 Issue 以及 PR,甚至建立合作关系,直接让以太坊客户端的官方版本来支持你的优化。何况这些客户端的创始团队仍然处于活跃的开发状态,你觉得自己有理由可以比他们 “自己人” 做的更出色吗?无论是客户端功能方面还是性能方面,无论你是个人还是公司的力量。 例如并行 EVM,试图提高 EVM 交易的执行速度,这是 Geth 团队都没能解决的难题,随便一个小团队能做得成? 如果是对以太坊无益而对自己链有益的改进,你预期以太坊的开发团队不会接受你的提议和改进,那这个问题就更加奇特,你的链是有多么特殊的需求,需要做这些必须 “自有” 的开发?以太坊的完成度已经比较高了,如果遇到这样的场景,似乎需要回到一开始的需求来评估整件事情。 Layer 2 的部分,比较大的团队有 Polygon、Optimism、Zksync 等。以太坊的 Layer 2 为什么能做大做强能火?Layer 2 是 Vitalik 认可的方向。ENS 项目为什么能广泛普及?ENS 是 Vitalik 认可的项目。AA 钱包为什么热闹了一段时间?Vitalik 喊灵魂绑定带起来的。你可以从 Vitalik 的博客中看到,凡是目前比较大的生态类项目,都和 Vitalik 本人有直接的联系,这些项目的创始人都是可以和 Vitalik 说上话的。 Vitalik 是整个以太坊生态背后的大 Boss,那么假如你说你想搞一个以太坊生态的项目,是优先考虑技术能力方面的问题吗?一个项目能不能成,先看什么?先看以太坊的 Roadmap 有没有这个方向,再看以太坊生态里这个方向的头部项目是哪个,然后呢?你会发现跟你没什么关系。以太坊基金会又没有给你钱,你凑什么热闹? 站在开发者的角度,假如你想参与以太坊的生态建设,那么请问,要以太坊的核心团队干什么?如果你想改进 Op Stack 的 Fault Proofs,那么请问,要 Optimism 的核心团队干什么?你作为一个局外人,花费时间和资源去给别人抬轿子?

2024/9/12
articleCard.readMore

为什么不要做智能合约开发和 DeFi 开发

目前市场上的招聘,“合约开发”一般指“以太坊上的EVM合约开发”。而事实上,世界上不只有以太坊一条链,可能有上百条链,也不止以太坊有智能合约,Solona 有 SVM,Polkdot 有 Wasm,Cosmos 在用平行链的方式达到智能合约的效果,等等,还有很多各种各样的实现形式。 所以看出问题了吗?如果一个程序员号称自己是 “合约开发”,说明他把自己局限在了一个狭窄的方向上。Solidity 是以太坊团队创造出来的脚本语言,而“合约开发”把自己的职业生涯交付在了这一种不成熟脚本语言上。至少,咱们应该是 “程序员”,而不是 “Solidity 程序员” 吧。 程序员可以在需要的时候,做一些合约开发的工作,而合约开发者,就只能做开发合约的工作。单就 Solidity 这种语言来说,语法的学习成本是多高呢?一般半个星期左右就可以开始上手写。 再是关于 DeFi 开发,因为做合约开发的大多都在学习 DeFi 开发。这里的问题在于,无论是 Centralized Finance,还是 Decentralized Finance,本质都是 Finance,核心是 “金融”,“去中心化” 只是金融的修饰词。 “金融”是什么?完全和“程序员”是两个行业,全世界的精英都在华尔街搞金融,一个半路出道的程序员,你能搞金融?现在的 DeFi 项目是不是都和杠杆、质押、借贷有关?Luna 暴雷是不是杠杆加太高了?Luna 背后有几家公司的资本力量参与?一般人能整明白 Luna 暴雷的原因吗?整不明白的,专业的金融人士分析半天也许能有点结果。 也就是说,专业的金融人士未必是程序员,而程序员几乎不可能成为专业的金融人士。金融领域的水很深,不是会写代码学一学 Solidoty 就可以的,更不是一个程序员励志要做 DeFi,就能学得会的。 更进一步,假如程序员把 DeFi 学懂了,能做些什么事情?能做的就是给资本大佬打工,让你实现什么业务逻辑就实现什么业务逻辑,有自由发挥的余地吗?难道你要自己设计一种金融逻辑?搞笑呢?有权力控制大量资产的一定不是程序员,而程序员一定没有权力控制大量资产。 我的意思是,如果一个程序员想搞懂金融然后表达对整个金融行业独到的见解,是…几乎不可能的,难度非常非常大,有那样能力的人不会是程序员。而如果你只是想要搞懂某个技术领域的情况然后发表一些观点,还是存在可能性的,至少不需要你拥有(或者替别人管理)大量资产吧。 如果懂金融的程序员自己开一家金融公司呢?你确定?

2024/9/11
articleCard.readMore

我的加密货币定投策略(一)

随着时间的推移以及对市场的更深入了解,定投策略尤其是投资组合会产生非常大的变化,所以在标题中用(一)标识,如果定投策略有变动,可能还会有(二)、(三)……当然也会同时说明变动的原因。 今年以来开始更加关注投资的话题,也因为乱操作损失过一些钱。 最近中了李笑来的毒,定投策略的理论让人感到很兴奋,可以从以下资料更加深入了解: 《定投改变命运》—— 李笑来,所有关于定投的内容都在这本书里 定投改变命运直播公开课 —— Youtube 视频,李笑来本人的解读 除了定投,还有几个其他投资方面的参考资料: 《韭菜的自我修养》—— 李笑来 郭宏才(宝二爷)的 Youtube 频道 我想实践一下定投的投资方式,其中涉及到的几个问题: 是否要加入李笑来的投资实践群?没有必要,尤其是对于有自制力的人 是否要买 BOX?江湖传言,李老师的书必须看,课可以听,币千万别买 定投的方式?Binance 自带的定投功能就可以,0 手续费(要记得时不时提到钱包) 定投的频率是?每天,因为价格变化太快了,如果频率太低,周期会变得太久 定投的周期是?目前规划了用来定投 1 年的钱 定投的标的是? 我没有盲目跟从李笑来 BOX 中标的的选择和比例。李笑来的 BOX 一开始给了 EOS 很高比例,事实证明 EOS 失败了,所以现在 BOX 的成分里已经不包含 EOS。最新的情况是,BOX 中 BTC 占了 92% 的比例。另外 BOX 的成分中一直含有 XIN,说明人都会受到立场的左右,因为我们大多数人可能都不知道 XIN 是什么,而李笑来多年坚定的选择这个币。 我选择的标的和分配的比例是: 序号标的比例标签风险供应量上限 1BTC50%PoW, UTXO低✅ 2ETH10%智能合约平台中❌ 3LTC5%PoW, UTXO高✅ 4DOGE5%PoW, UTXO高❌ 5BCH5%PoW, UTXO高✅ 6ADA5%PoS, UTXO高✅ 7SOL5%智能合约平台高❌ 8FIL5%PoSt非常高❌ 9TON5%智能合约平台非常高❌ 10XEC3%PoW, UTXO非常高✅ 11DASH2%PoW, UTXO非常高✅ 10XMR5%PoW, UTXO非常高❌ (2024.09.26 更新) 可以从这个公开的 Watchlist 看到这些币种的集合,链接会跳转到 CoinMarketCap 网站上。能注意到的是,几乎所有币种的市值排名都在 100 之内,这条原则来自《炒币投资的小 tips》第 4 条。 我的定投组合中,BTC 一定是首位,占 50% 的比例。另外 5 个 PoW 的币总计占了 20% 的比例,也就是说,PoW 币总计占比 70%。PoW 的币大都有供应量上限。 由于 “比特金,莱特银” 的说法,LTC一直比较强势,DOGE 和 LTC 是难兄难弟,挖矿算法一样,矿池都是同时挖这两个币,所以 LTC 和 DOGE 的价格不能简单像 BTC 一样估算 “矿机关机价”,而 DOGE 又属于 memecoin 的老大,历史久,马斯克喜欢,也放不下的,所以 LTC 和 DOGE 要一起出现。 BCH 是 BTC 在手续费超高年代(2017年)的 workaround 方案,Blocksize War 真的已经结束了吗?BCH 是大区块的代表,所以要把 BCH 选进来。BTC 已经脱离 “电子现金” 的目的,变成了 “储存价值”,相信这个问题上的争议还没有彻底结束。那后来 BCH 又分叉出了 BSV,为什么没有把 BSV 涵盖进来?BSV 的生态小,而且生态里有一些活跃但无知的项目(Note 之类),BSV 的创始人还在被法院通缉,等等原因。 DASH 和 XEC 是最没道理的两个,冷门到很多人没听过,也是风险最高的两个。其中 XEC 是因为 BCH 的 节点代码 fork 自 XEC 的代码,相信 XEC 有不错的技术能力。DASH 是 LTC 的一个 fork,更多是凑数性质,因为很多老牌钱包比如 Unstoppable 支持的币种列表大都是 PoW 系列的币,DASH 就在其中,而且币安的矿池服务支持的币种不多,其中也有 DASH,所以就把 DASH 作为 “电子现金” 的高风险备选了。 然后是 ADA,学术能力强,纯粹的 PoS 共识,用了 UTXO 模型,属于 BTC 在 PoS 共识上的平替,而且 ADA 技术上在不断更新,如果有一天 PoS 赢了,ADA 绝对是绕不过去的一个,所以预计 ADA 能有更好的未来。 FIL 的话,属于分布式存储领域。计算机科学有两大方向,分布式计算和分布式存储,ETH 说自己是 “世界计算机”,担起了分布式计算的职责,而 FIL 是存储领域的老大。印象里 libp2p 是 Filcoin 开发的,而很多区块链像 ETH 用的 p2p 模块,就直接用的是 libp2p。以及目前非常广泛使用的 IPFS,也是 Filecoin 实验室开发的。所以 FIL 在研发方面实力很强,未来也许会有得到突破的一天。 最后是智能合约平台类型。其实我不太喜欢智能合约,很多人喜欢把智能合约等同于区块链,或者认为只有支持智能合约的区块链才叫区块链。在囤币方面,币只是智能合约平台的 燃料,又没有供应量上限,囤它干嘛。但是智能合约平台火,玩的人多,价格居高不下。ETH、SOL、TON 的特点都是支持智能合约、现在关注量比较大,所以适当分配一些比例上去。 另外这是一些热门币没有被涵盖进来的原因: 币种未选择原因 BNB平台币,不是链 DOT没有供应量上限、APR 很高 ATOM没有供应量上限、APR 很高 AVAX没有供应量上限、没看到明显优点 APT, SUIBFT 类共识 所有 ERC-20 代币不是链 所有 Memecoin没有长期价值 铭文、符文BTC 手续费太贵 祝自己好运! 更新(2024.09.26) 去掉了原先投资组合中的 XEC 和 DASH。因为在原先的选择中,XEC 和 DASH 本就是理由不充分的两个标的,而他们的总市值排名在 100 左右甚至之外,社区热度也比较差。 10XEC3%PoW, UTXO非常高✅ 11DASH2%PoW, UTXO非常高✅ 更重要的是,现在要用 XMR 来代替它们原先占有的共 5% 的比例。XMR 是暗网使用的 主要货币 之一,暗网仅接收 BTC 和 XMR。XMR 在隐私保护方面非常强。而在暗网的应用领域,XMR、XEC、DASH 其实是经常被 并列讨论 的三个币种。要在这三个同类型代币中选择一个的话,就首选 XMR。 10XMR5%PoW, UTXO非常高❌

2024/8/28
articleCard.readMore

PoS 类型的区块链如何处理分叉

主流公链从共识机制的角度基本上可以分为 3 类,分别是 PoW、PoS、PBFT。选择了不同的共识,也就很大程度上决定了网络的 TPS、去中心化程度、节点规模。 除了 PoW,另外两种共识 PoS 和 PBFT 都面临一个基本的问题,就是当网络发生了软分叉,该如何恢复?由于 PoS 和 PBFT 产生块不需要算力成本,也就不能用和 PoW 一样的最长链原则。 问题背景 共识概览 PoW 系列的链有 BTC、BCH、BSV、LTC、DOGE、ZEC 等。PoW 都使用最长链原则,节点在面对多个发生了分叉的链时,直接选择块高度最高的一条就行了。由于每产生一个块都需要庞大的算力,攻击成本比较高。 PoS 系列的链有 ETH、BNB、TRON、DOT、TON、ADA、AVAX、NEO 等。几乎目前所有智能合约平台类型的链,都属于 PoS 共识。 PBFT 系列的链有 ATOM、SOL、TON、ONT、APT、SUI 等。其中 Cosmos 最为知名,Solana 有超越以太坊的势头,The Open Network 今年也发展的很好。 这里可能会觉得有点奇怪,怎么把 SOL 归类到 PBFT 上了?SOL 不是 PoH 共识吗?SOL 不也有质押的功能,比如在 Solflare 钱包上还可以质押获得收益的吗?TON 也有质押和收益啊? 这里是两个问题。 首先 Solana 的确开发和使用了 BFT 类的共识,叫 Tower BFT,PoH 是用来解决 Solana 链上的时钟问题的,而不是一种完整的共识机制。 其次是 PoS+BFT 类共识,算 PoS 还是 BFT?上面提到的分类,PoS 主要指 PoS、dPoS、PoS Casper 这些,凡是用到 BFT 的都归类为 PBFT 作为区分。最明显的就是 Cosmos 也有质押和收益功能,但很少有人会说 Cosmos 用的是 PoS。 关于分叉 PoS 共识和 PBFT 共识面对分叉问题的时候,有两个方面。 一方面是质押者列表(以太坊叫 Validators,Cardano 叫 Stakeholders)是否一致,因为 PoS 和 PBFT 大都是使用 VRF 从一组候选列表中选择出一个节点作为出块节点,那么 PoS 和 PBFT 类共识在处理这个问题的时候有哪些异同? 另一方面是当网络分叉后,在选择链的规则(以太坊叫 Forkchoice,Cardano 叫 Chain selection rule)方面有什么异同? 质押者列表不一致 联盟链 从最简单的联盟链开始分析。联盟链的特点是没有 coin,也就完全没有质押方面的内容,只是单纯的 PBFT。 联盟链顾名思义,有非常高的准入门槛,需要经过审核或者某种授权也能够成为联盟成员。具体到技术层面,就是想要加入网络,需要在其他节点都知道的情况下,比如所有节点的配置文件里,都包含一个网络成员的列表,列表里定义了网络的节点公钥以及对应的 index,想要增加节点就需要其他所有节点都改一下配置文件。 节点在出块的时候,就会从这个列表中使用 VRF 随机选择一个作为出块节点。一般 VRF 返回的是一个简单的数字,对应公钥列表的 index,出块节点用这个公钥来对块签名。 这样的做法比较笨拙,但也是联盟链的特点。在这种模式下,节点的质押着列表不太可能不一致,如果不一致就是配置文件写错了。而且配置错误的情况下,它将永远是错误的,排查起来很简单。 Cosmos Cosmos Hub 用的共识叫 CometBFT,基本流程是花费不少于 180 个 ATOM 注册成为 Validaotr,然后就有可能会选为出块节点。 由于 BFT 类共识在出块之前就需要投票,所以假如网络中真的出现了质押者列表不一致的情况,在同一个块高度会有两个节点产生出两个块,此时网络中的其他节点会对这两个块进行投票。 这个时候也分两个场景,就是网络正常和网络异常的情况。 在网络正常的情况下,现在有两个块,一定只能有一个块收到大于 2/3 的投票,不可能两个块都收到大于 2/3 的投票。所以在出块之前,就已经把质押者列表和其他节点不一致的节点排除在外了,不会影响后续流程。 在网络异常的情况下,节点感知不到其他节点的存在,即使当前节点的质押者列表正好是当前子网络中的有限几个节点,其他几个节点也不会把票投过来。除非整个子网络都断网了、质押者列表还发生了一样的错误,那这个自网络就自己在局域网玩吧。网络异常本身就是一种异常情况了,与外界隔绝。 Cardano Cardano 的 PoS 是最纯粹的 PoS,没有投票机制。Cardano 的共识经历了很多次演进(内容很多很复杂,我没看完)。 Cardano 网络的规则是,任何人都可以质押任意金额到 Stake pools 中成为 Delegator,这些 Delegator 按照质押金额的比例共享矿池的收益,但是不会有出块的资格。 在 Cardano 网络中真正有出块权限的是 Stake pools,也就是说有可能被选为出块节点的节点,都在 矿池列表上 了,数量不多,目前大概 300 个左右,每个 solt 将从中随机选择一个来产生块。 那么 Stake pools 节点注册之后,如果节点之间出现 Stake pools 列表不一致的情况怎么办?Cardano 的文档中有 描述,当遇到同一 slot 产生了两个块的时候,就开始启用链选择的规则(Chain Selection Rule)了。也就是说,实际上当第二个块被产生出来,链就已经分叉了,然后所有节点都启用链选择的规则,来进行恢复。 Ethereum 以太坊要成为 Validator 需要花费 32 个 ETH 把节点信息注册到 质押合约 上,然后其他所有的 Validator 都会从质押合约获取质押者列表的信息。 那怎么确定其他 Validators 都已经把质押者信息从合约同步到本地了?你可以在 Beacon Chain 浏览器的任意一个 块信息 上,找到一个叫 Eth Data 的字段,这个字段对于质押者列表非常重要,当一个 validator 被选为出块节点时,它会把当前节点同步到的质押者列表信息,一起打包进块里,包括质押者的总数以及 Deposit root 信息。 以太坊网络大概每 17 个小时进行一次 质押者列表的更新。在这个周期中,只有超过半数区块的 Eth Data 包含了新增的 validator,新的 validator 才会真正加入到网络中。 所以以太坊要加入 validators 的过程是漫长而且严格的,首先要确认其他 proposer 已经同步了相关信息,才会真正更新质押者列表。在这样的规则模式下,质押者列表很难不一致。 分叉链选择 Ethereum 以太坊中如果出现了多条分叉的链,选择起来时相对容易的,因为以太坊有投票机制,每一个块上都包含了有多少个 validators 对块进行了投票。可以猜想到,在发生分叉时,只要不断选择投票数多的块就可以了。 而实际上以太坊的分叉选择基于 checkpoint 机制,每个块是一个 slot,每 32 个 slot 是一个 epoch,每个 epoch 都是一个检查点。一个检查点收到大于 2/3 的投票,就进入了 justified 的状态,当一个检查点的下下个检查点也进入 justified 状态,当前检查点就认为是 finalized 状态了。所以在以太坊中,一笔交易最终被标记为 finalized 需要 15 分钟。 这里提到的检查点,也就是 FFG 进行 forkchoice 的依据,每条链会选择 checkpoint 多的链。所以以太坊的共识不是选择 “有最多块的链” 原则,而是选择 “有最多检查点的链” 原则,检查点最多的链就是主链。 Cardano Cardano 最新在使用 Chain selection rule,是由 Ouroboros Genesis 版本提供的。 Ouroboros Genesis 的上一个版本是 Ouroboros Praos,Praos 版本中提出了一套叫 maxvalid 的规则,Genesis 版本基于 maxvalid 做了一点改进,把 moving checkpoint 的特性结合了进来,形成了新版本叫做 maxvalid-mc 的规则。 移动检查点简单理解就是,本地链在面对多条分叉链时,如果没有超过 k 个块,就选最长的链,如果超过了 k 个块,就直接不选它。也就是说本地链只会在 k 个块的范围内,选择链最长的一个。而 k 个块的范围就是所谓的移动检查点(moving checkpoint)。加了这样限制的好处就是可以避免最长链攻击。当然 Cardano 制定这样的规则经过了一系列学术上的推演以及实际场景的检验。 Cosmos PBFT 链在网络正常的情况下,只要保证质押者列表一致,就不会分叉。 总结 总的来说,每种共识的具体实现,都包含了详细的处理分叉的规则,而且这些老牌公链都经过了实际运行的检验。具体如何实现与链的设计理念有关。

2024/8/22
articleCard.readMore

Ethereum Casper 为什么需要 EIP-7251

Casper the Friendly Finality Gadget 是以太坊现在使用的共识机制,属于 PoS 的一种实现。这种关系类似于同样是 PoW 挖矿,Bitcoin 使用 sha256 而 Dogecoin 使用 scrypt。其他的 PoS 实现还有比如 Cardano 的 Ouroboros。 EIP-7251 的主张是增加单个验证者的质押额度上限,原先是 32 ETH,希望改为 2048 ETH,这样可以有效减少验证者的数量,同时有效 P2P 网络的通信量。 这项改动有点迫在眉睫,因为以太坊在测试环境中模拟了大量质押者的情况,测试结果 显示,当质押者数量达到 2.1 M,网络的投票率会不到 50%,已经不能正常进入 Final 状态,意味着检查点机制失效,整个网络处于非常不安全的状态。而以太坊现在的验证者数量已经达到了 1.4M。如果不及时做出改变,以太坊网络将在不久的将来奔溃。 那为什么以太坊会面临这样的困境?PoS 不是公链专属的共识机制,能够适用于大规模网络的吗? 究其原因,Ehtereum Casper 其实是对 BFT 的改进,而不是对 PoS 的改进。 先来看看 Vitalik 是怎么描述 Ethereum Casper 的,他把 Ehtereum Casper 相对于 BFT 的改进视为重中之重: 再来看一下 Ehtereum Casper 的具体流程:节点质押资产成为验证者,然后通过 VRF 来随机选择一个节点出块,出块后所有验证者都对块的有效性进行一次投票。这些投票会先投递给委员会的成员,委员会成员聚合投票结果之后,再在委员会成员之间同步。委员会成员是每隔一段时间随机选举出来的。 对于了解 BFT 但是不了解 Ethereum Casper 的人,在接触到以太坊网络后,当知道只有收到 2/3 投票的块才有资格被标记为 Final 状态时,会不会对 2/3 这个数字有点敏感?因为 2/3 是 BFT 一直在强调的投票比例,以保证 3f+1 的容错能力。 BFT 的投票机制保障了网络绝对不存在分叉,以太坊引入了 BFT 的这个优点,使得 Ethereum Casper 处理分叉场景相对容易,只需要判断哪个区块的得票率最高,就可以认定主流块了。如果验证者同时对两个块投票,验证者会为此受到惩罚,这也是以太坊在众多 PoS 链中唯一一个有 Slash 机制的原因。同时结合 checkpoint 机制,以太坊就可以面对非常复杂的分叉情况,整个网络分叉成树都能从中找出主链。 问题在于,Ethereum Casper 在引入 BFT 优点的同时也引入了 BFT 的缺点,那就是通信量过大。BFT 的通信量是 O(n2) 级别的,一般只能承受 100 个以下的节点规模,例如 这篇报告 就给出了具体的数值。 可以大致计算对比一下 BFT 和 Ethereum Casper 的消息量。 BFT 在 100 个节点的时候大概是 50 tps 的能力,消息膨胀量 O(n2),那么消息数量是: n = (100^2) * 50) = 500000 = 0.5 M/s Ethereum Casper 在 2M 验证者的时候大概 50% 的投票率,以太坊的块时间是 12 秒,一共 64 个委员会,消息膨胀量 O(n),那么消息数量为: n = 2M * 0.5 / 12 * 64 = 1000000 / 12 * 64 = 5 M/s 这样计算比较草率和粗略,结果数字上差了一个数量级,但是考虑到两种共识机制具体实现上有很大差异,包括测试的硬件环境差异,有出入很正常,总体上差不太多。 所以由于以太坊集成了 BFT 的投票机制,导致以太坊网络需要大量的通信量。或者说,Ehtereum Casper 改进了 BFT 并且把 Stake 机制加入其中,使得 BFT 更进一步能够支撑起十万规模的节点数量。 同时,有没有注意到,Ethereum Casper 的消息膨胀量仅仅只是 O(n),为什么呢,因为 Ethereum Caspe 不需要进行第二次投票,一次就够了。 另外,委员会机制有点像联盟链的分层共识。有些国内公司需要在没有 token 概念的前提下,对区块链技术进行改进,但是 BFT 算法最多只能撑起几十个节点的规模,于是有了基于 BFT 的分层共识,基本思路是,从所有节点中选出一部分节点作为提案节点,然后提案节点来进行出块和投票,其他节点只接收数据,并且每隔一段时间换一次共识组(提案节点)。 对于联盟链,VRF + BFT + 分层共识已经是比较完善的技术组合了。 与之相比,以太坊多出来的是 Stake 机制,联盟链中每一个节点都是验证者,都有机会出块,而以太坊想成为验证者,需要事先质押一定量的 token 才行。后面的委员会机制相比分层共识,也有一些改进,委员会机制保留了每一个验证者的投票权,只是选出一些代表来归集投票结果。而分层共识直接剥夺了多数节点的出块权,只有少数节点负责出块。 所以以太坊的共识能简单理解为 Stake + VRF + BFT + 委员会机制。

2024/6/9
articleCard.readMore

区块链中的 PBFT 不需要第二次投票

PBFT 为什么需要进行两次投票,第二次投票的作用是什么?这个问题困扰我很久。 逆向推导 从这个角度想,第二次投票在什么情况下是发挥作用的?在第二次投票的结果和第一次不一致的情况下,才是发挥作用的。如果第二次投票的结果和第一次严格一致,那当然没有必要进行第二次投票。 那在什么情况下,第二次投票的结果会和第一次不一样?只有当恶意节点存在并且刻意在第二次投票阶段投出不同的票,两次投票的结果才会不一样。 这是传统 PBFT 的常规操作流程图,其中节点 3 是错误节点或者恶意节点,从始至终没有响应: 这是去掉 prepare 阶段,只保留一次投票过程的流程图,其中节点 3 仍然是错误节点,没有响应: 关键在于,在这个场景中,节点 0、1、2 都是诚实节点,绝不可能恶意投票或者不投票,那么 commit 阶段的结果一定是和 prepare 的结果一致的,所以即使去掉 prepare 阶段,系统最终也会达成一致。 节点 3 一直都是恶意节点,如果在 commit 阶段,0、1、2 中的某个节点投出了和 prepare 不一致的票,整个系统就存在超过 1 个恶意节点,超出了容错能力。 正向理解 要证明第二次投票是必要的,等同于说明如果没有第二次投票,系统将会无法正常运转。 逻辑上,即使说第二次投票有各种各样的好处,通过冗余来增加系统的容错能力、能够及时发现错误并且快速调整到一致的状态等,也不能说明第二次投票是非要不可的。比如这个 Why is the commit phase in PBFT necessary? 中的高赞回答,说了很多但只是正向解释了 commit 阶段的设计和作用。 我目前看到比较靠谱的一个解释在这里:PBFT: Why cant the replicas perform the request after 2/3 have prepared? why do we need commit phase? 其中提到如果没有 commit 阶段,当 view change 的时候,节点将无法保证请求执行的顺序。 我觉得 StackOverFlow 中的描述和高赞回答提到的论文含义还是有出入的。高赞回答的意思是,节点的 execute 因为缺少 commit 阶段而不一致,有的快有的慢。但即使有两轮投票,节点也可能在 commit 阶段之后 execute 之前发生故障,导致执行上的差异,所以这种故障还不是关键场景。 更加合理的场景是论文 Practical Byzantine Fault Tolerance and Proactive Recovery 中提到的,view change 发生的时候,不同的请求使用了相同的序列号,被打包进不同的 view 中。(这句话很凌乱) Replicas may collect prepared certificates in different views with the same sequence number and different requests. The commit phase solves this problem as follows. 单次投票流程 这个场景基于只投票一次的流程,也就是没有 prepare 阶段的流程。 场景设置 视图 V1 R1 提出提议 P,并广播给 R2, R3, R4。 提议 P 在 R2, R3, R4 被执行,但 R1 未执行 R1: --R2: P --> 执行 PR3: P --> 执行 PR4: P --> 执行 P 视图切换到 V2 假设 R1 发生故障,视图切换到 V2 R2 提出新的提议 P’ R2 提出新的提议 P’ 并广播给 R1, R3, R4 新的提议 P’ 被所有副本执行 R1: -- P' --> 执行 P'R2: P --> 执行 P P' --> 执行 P'R3: P --> 执行 P P' --> 执行 P'R4: P --> 执行 P P' --> 执行 P' 具体示例 假设提议 P 和 P’ 是对相同账户余额的操作: 提议 P:增加账户 A 的余额 10 单位。 提议 P’:减少账户 A 的余额 5 单位。 在视图 V1 和 V2 中的操作顺序和结果如下: 视图 V1 R1: 账户 A 余额 = 100 (未执行 P)R2: 账户 A 余额 = 110 (执行 P)R3: 账户 A 余额 = 110 (执行 P)R4: 账户 A 余额 = 110 (执行 P) 视图 V2 R1: 账户 A 余额 = 100 (未执行 P) --> 执行 P' --> 账户 A 余额 = 95R2: 账户 A 余额 = 110 (执行 P) --> 执行 P' --> 账户 A 余额 = 105R3: 账户 A 余额 = 110 (执行 P) --> 执行 P' --> 账户 A 余额 = 105R4: 账户 A 余额 = 110 (执行 P) --> 执行 P' --> 账户 A 余额 = 105 场景分析 再来重复一下这句话,不同的请求(R2)使用了相同的序列号(R1 认为是 P),被打包进不同的 view (P’)中。相同的序列号应该是指执行的时序,就是当前时间点轮到哪个请求执行了。 在上面这个场景中,确实由于 A 节点故障导致最终状态出现了不一致。 两次投票 两次投票的流程又是如何解决上述场景中的问题? 如果 A 节点故障发生在收到 prepare 结果之后、开始 commit 之前,所有节点都不会进入 execute 阶段。 如果 A 节点故障发生在收到 commit 结果之后、开始 execute 之前,A 节点会根据 commit 结果再次尝试执行 P,然后再执行 P’ 场景分析 是不是注意到,第 2 条存在一点不公平? 两次投票的场景下,A 节点可以根据 commit 结果再次尝试执行 P。 单词投票的场景下,A 节点并没有根据 commit 的结果再次尝试执行 P,而是直接执行了 P’。 那么其实两次投票并没有完全避免在 execute 之前节点故障导致的状态不一致,仅仅只是通过增加一次通讯的形式,来反复确认其他节点的状态和自己预期是一致的,减少状态不一致的风险。 两次投票把发现故障的时间提前了,如果节点 A 没有在 commit 阶段发出投票,其他节点就知道 A 节点故障了,而不是等到自己已经 execute 了,才发现 A 没有 execute。多一次确认多一份保障,减少系统 execute 后回滚的成本,尽可能在 execute 之前就商量好。两次投票最大的作用应该也就这样了。 总的来说,第二次投票始终都没有体现出必须存在的意义,而只是带来了一些好处,加强了系统的安全性。这个问题可能类似于,TCP 为什么需要 3 次握手才能建立连接?2 次不行吗?估计 1 次也行,只是会引起一些麻烦,3 次确认足够保险。 无状态与有状态 为什么 PBFT 需要反复确认,尽量避免 execute 之后的状态不一致呢?也许任何系统的回滚都是一件非常慎重的事情,所以不惜增加 execute 之前的沟通成本。 无状态 回到上面单次投票的场景,出故障的 A 节点在什么情况下就不会执行 P’ 了? A 节点知道自己执行 P 失败了 执行 P’ 之前一定要执行 P 满足这两个条件,即使是单次投票,也可以实现和两次投票一样的效果。 对于无状态的系统,如果节点只记录了一个最终的数字,那还挺难办的,节点知道自己没有执行 P,然后收到了一个 P‘,节点 A 将无法分辨 P’ 的位置,是在 P 后面还是和 P 同等位置。 正常顺序是: O -> P -> P' 对于 A 节点来说,知道自己没有执行 P,但是收到了一个 P’: O -> (P')? 要不要执行呢?A 节点就执行了,状态就错乱了。 基于这一点原因,无状态的系统的 execute 是非常慎重的。 有状态 区块链属于有状态的系统,天然记录了自己的执行记录(区块),以及会对请求进行强制的排序(区块哈希、父哈希)。 一个节点收到了区块,它一定能够判断出这个区块的位置,是否应该本轮执行,以及自己是否缺少区块,及时从其他节点把区块同步过来。 所以在区块链的使用场景下,如果只是为了达到多数节点最终状态一致的效果,完全没有必要进行第二次投票。 疑问 PBFT 为什么需要进行两次投票?这个问题在 GPT-4o 的知识边界,详细追问它,它就会开始胡说八道了,这符合 GPT-4o 不了解就开始编造的特点。 以我有限的互联网信息搜索能力,我一直没有找到一个足以让我信服的理由,证明 PBFT 中的第二次投票是必要的。 经过我自己反复的推演,我能得到的结论只有二次投票并不是必须的,仅有一次投票,也可以达到多数节点一致的结果。 可为什么长久以来,PBFT 包括各种变体 Tendermint、HotStuff,都保留了两次投票的流程?为什么从来没有人质疑过第二次投票其实不需要? 我到底错在哪里?也许是对 PBFT 了解不够深入,还没有触及到第二次投票真正发挥作用的场景吧?可如果真的存在这样的场景,为什么没有找到资料把这种场景直接了当地描述出来?

2024/6/3
articleCard.readMore

开发者的思维方式

前情提要:《创造者的思维方式》——王垠 发现一个现象,当你说 A 好的时候,别人会说 A 没有 B 好,所以 A 不好。 第一个案例是当你说 SOL 的技术比 BNB 好的时候,就会引来争议了。有人拿 SOL 宕机过几次来证明 SOL 技术不好,还有人说炒币根本不是技术导向,而是资本导向,技术好不好不重要。这些观点反映了各自不同的立场。 从开发者的角度,没有人会把 BNB 看作一条区块链,因为它是平台币,节点中心化运维,运营模式也不是链的方式。技术上 fork Geth 的代码做了一些魔改,比 Geth 早一步用上了 PoS,以增加对整条链的控制。ETH 完成坎昆升级后,BNB 也模仿着搞上了自己的 BIP-4844,显然和 ETH 属于相同的技术体系。 另一个观察角度是,ETH 的 ETF 接近通过后,大家开始讨论下一个能上 ETF 的是 DOGE 还是 SOL,甚至是 DOT,但没有人会提到 BNB,充分说明了 BNB 和其他公链不一样,都不是一个赛道。判断两个产品是否属于同一个赛道的方法,可以看其是否存在竞争关系,你死我活那种。如果 BNB 用了 ETH 的技术还希望 ETH 死掉,在逻辑上是不通顺的。正常逻辑应该是希望 ETH 更好,BNB 也会跟着受益。 而从使用者(炒币)的角度,管你用的什么技术,管你技术好不好,一个表情包都能市值前十,一个戴帽子的狗都能市值上亿,投资人在哪儿我就去哪儿,资本炒什么我就买什么,FOMO 就完事了。 第二个案例是当你说 SOL 技术好的时候,别人会说 ETH 更好,所以 SOL 不好。 同样是立场问题,站在使用者的角度,有无数理由来对比两个项目的好坏。当你说 A 好的时候,总是有人可以找到一个比 A 好的 B,或者只有某个方面好于 A 的 C。使用这样的技巧,他们可以打败任何人、任何项目。而且这里面也存在非常大的争议空间、鄙视链,就好像到底 PHP 是不是世界上最好的语言? 但是站在开发者的角度,如果我要开发一条链,我的链要和 SOL 比技术、抢市场,我能不能做的比 SOL 更好?肯定不能。那么在这个立场下,我说一句 SOL 技术挺好,不过分吧? 这里的 SOL 只是举例,其实只要是 CMC 前 100 的老牌公链,都有一些闪光点,尤其是开创和尝试了不一样共识算法的,它们也许不如 ETH 和 SOL 备受关注,但是也做出了不错的成绩。

2024/5/30
articleCard.readMore

发币的核心要点

首先关于《炒币投资的小 tips》再补充两点: 比特币本位还是法币本位或者其他币本位,主要还看手里有什么、什么币来的容易,没有银弹的逻辑适用于任何场合. 随着 ETH 的 ETF 通过了一半,意味着 SEC 也许会接受 PoS,只是要求 ETF 的资金不能用于质押(我还没理解为什么)。之前提到要在 CMC 前 100 里挑,可能需要再加一个条件,就是没有曾经被 SEC 称作证券的。也许在换人后,或者新法案通过后,加密货币将不归 SEC 管,但至少说明在同等的判断标准下,很多币明显存在问题,ETH 一直属于模棱两可的状态,所以目前在 ETF 的状况比较复杂。 其实比较安全可靠的币屈指可数。例如 DOT 的出镜率不高,SEC 没有对 DOT 有过定义,但是 DOT 的质押率已经达到流通量的 57%,而且还保持着 17% 的年利率,我还无法理解这是一种什么样的状况,质押率高的后果是什么,为什么 DOT 收益率可以这么高,假如所有持有者都平等获得年利率,不就相当于通胀吗,币价得跌。对于网络来说,质押率越高倒是越安全。但是流通中的币越少,说明这个币没啥用,甚至不能用于合约的手续费来消耗。这种理解不了的币也是不能买的。 然后是关于发币。最近无意间听到李笑来以前在网上流传的录音,还是有不少启发。 所有币的核心要点就一个,就是把币通过有价值的形式分发出去。持有者认为它值什么价格,它就是什么价格,只要持有者不卖,价格就不会跌。包括 BTC,是通过挖矿的形式分发,10 分钟发一次,无论多少人参与,10 分钟、固定数量,总会有一个胜出者,而且胜出者有随机性,增加了活跃参与的程度。 ETH 也是通过挖矿,每 12 秒钟一次,把块奖励发给矿工,奖励分发上和 BTC 差不太多,ETH 最大的两个黑点是: 开发团队有预挖,挖了多少不清楚,ICO 低价给了多少人是黑箱 无限增发的代币模型,总供应量没有上限,是否会带来通胀,是否意味着开发团队可以不断印钱 李笑来发过一个叫 Caddy 的币,当时给他一个什么社区合作,几百万真实用户,以十分之一的价格给那个群体,让他们在网上点赞转发炒新闻。李笑来自己说,要策划好一个币的前提是自己是网红,有流量、有影响力,是非常重要的条件。因为李笑来自己就算个网红,经常出书、到处演讲、是新东方有名的老师,他的背景是非常深厚的。 他说的这个关于网红的要点,其实最终作用也就是招来大量粉丝,让这些粉丝认可这个币,币会通过某个价格到达这些粉丝的手里。所以无论是挖矿,还是早期的空投、交易所的 LaunchPool、激励形式的发放,目的都是以有价值的方式发给持有者,持有者必须要付出一点代价,币才会有价值。广撒网白给,币肯定不值钱,但如果和美元一对一锚定,那又是稳定币。所以这个价值还得控制好,不能太具体,也不能没有。 另外十倍百倍的币是没有逻辑的,一般认为 ETH 的火爆有两个主要原因,一是当时国家出了个政策,交易所里面的 BTC 不能提出去。二是当时有个传销盘,用的 ETH,日利率 1%,大量用户在交易所里花钱买完 ETH 就提出去,放到传销盘里了,币价就涨了上来。好笑的是期间 ETH 的价格涨了上千倍,传销盘甚至可以坚持三年不崩盘…… FIL 曾经也在国内大量挖矿,价格一度涨到 200,为什么价格那么高,也是传销盘……后来国内不让搞,现在 FIL 就一点活力都没有了,技术是真的好啊。另外一个技术不好的,NEO 也涨了百倍起来的,大家都疯了不知道为什么这么值钱,只有十来个中心化的节点,技术也不行用 C# 写的,号称国产版本的以太坊,主要还是资金盘拉起来的,创始人手里也没多少币,直到现在也能看出来,币价隔一段时间就会拉起一波,只有资金盘能操纵起这样的场面。 所以技术也不是绝对优先地位,技术属于基础,用来给大家做横向比较的,比如同时有几个项目叫 ETA、ETB、ETC、……、ETH,即使是传销盘也会从中选一个技术相对可靠、有东西可炒作的币来用。然后就是天意了。 还有是八卦相关的,很多新闻报道李笑来曾经是币圈首富,用四分之一的身价低价买了六位数的比特币,拿了好几年然后经历过好几波牛熊,最后高价卖掉了。但是事情没有那么简单,网上可以查到很多李笑来关于比特币的演讲视频,他绝不是单纯买完币啥也不干,躺着等币价涨,再卖掉。他从复杂的知识层面对比特币有理解、做演讲,肯定是逻辑上相信比特币,才会进入币圈的。这件事情告诉我们,别瞎买,看准了再买,别在交易所上看见个喜欢的代号就买了,或者看见个推广文章就买了,真正能赚钱的人背后都有深厚的逻辑支撑,做了大量功课的。 另一个版本的八卦是,李笑来曾经组织过一个比特币基金,私募,几万个比特币的规模,用来投资挖矿、交易所、买股票,结果亏的很惨,很多人跟着亏钱还拉了维权群。后来投 EOS 什么的又赚回来了,打折把欠的钱还上。李笑来吹牛自己有六位数的比特币,结果没有啊,警察去搜都搜不着,到现在李笑来都被边控着出不了国。谁知道他到底有没有比特币,他要真有呢,不能放他出去。这件事情告诉我们的道理是,没有人靠炒币发财的,李笑来本来就有钱,有钱了都还在进行各种操作,做投资、搞基金,赚钱总是得做事情,别想着炒币挣钱,买完币放在那儿就等着涨。即使当年属于币圈早期,参与了李笑来比特币基金的人,对比特币的未来也是持怀疑态度,拿不准比特币以后的价值,过程中也洗掉了一大批人。何况现在呢。

2024/5/26
articleCard.readMore

炒币投资的小 tips

1. BTC 是行业底线 有 Bitcoin,才有加密货币的整个行业。如果哪一天 Bitcoin 被否认了,说 BTC 不安全了、不去中心化了、没有价值了,那么整个行业就没了,BTC 从来没有被超越。而且事实上,除了比特币,其他的全是山寨币,尤其是在老矿工眼里,经历过只有比特币的时代。山寨币都是从作为比特币的模仿者开始,试图做出某些改变,包括以太坊。Vitalik 也搞过染色币、从 PoW 开始发链、来中国募资。一切都从比特币开始。 2. PoW 抗跌 2024年4月12日前后,由于多方面因素,普遍认为是因为两个国家擦枪走火、有可能挑起战争的缘故,所有的币平均下跌 20% 左右,币安的 Gainner 排行榜上也全是红色,可以简单将其称为 412 事件。 观察一下这一波价格变动的情况,可以明显看出,PoW 系列的币,跌幅普遍比 PoS 系列的币小。以 BTC 为首,Doge、LTC、BCH、ZEC、ETC、ETHW。毕竟 PoW 的链是矿工实实在在投入算力在上面,硬件成本在那儿,他们不愿意低价格卖掉。PoS 不一样,把一大堆钱质押进服务器,钱生钱,来的相对容易。ETh、DOT、ADA、COSMOS 等,很多很多,CMC 前 100 估计有 50 个 PoS 的、有质押功能的。 3. BTC 本位 炒币的基本逻辑是 BTC 本位的,也就是炒山寨币对比特币的汇率。假如本来有 1 个 BTC,觉得 Doge 最近的上涨幅度会超过 BTC,就把 BTC 换成 Doge,等 Doge 涨完涨了 20%,再把所有的 Doge 换成 BTC,这个时候,BTC 就从 1 个变成了 1.2 个,这就是 BTC 本位的含义。 从此以后,你将无视市场行情的波动,无惧牛熊,你眼里只有比特币的数量,是 1 个,还是 1.2 个,还是 0.8 个。至于 BTC 对于法币的价格,随便怎么变化,总之每隔 4 年翻一倍,跌了会涨,涨了还会涨,All Time High 反反复复。 要是用法币本位去炒币,十有九伤,大多会死得很惨。特指现货,合约千万别碰。 4. 挑 CMC 前 100 的买 尽可能把眼光聚焦在 CMC(Coin Market Cap)排行前 100 名的币上,挑这些里面的买。一方面是因为暴跌的概率小,100 名开外的没底。另一方面是生态支持差,没准什么时候钱包不支持了,转不了账带来很多麻烦。或者某个 X-20 币的合约地址变了,就很离谱,前段时间有一个 AI 板块下的,合约地址说变就变,而且原合约的 Dex pool 还在以 20% 的价格运行,合约地址切换后,价格变为了原来的 80% 左右。 5. 创始人的离开是去中心化的开始 BTC 比较特殊,创始人从一开始就隐瞒身份,这是非常聪明的做法。也正因为如此,BTC 才可以被大肆炒作。为什么 BTC 上的资产类型很多,染色币、RGB++、Taproot Assets、铭文、符文,L2 资产还在打架。因为没有人能站出来给 Bitcoin 画一个 Roadmap,谁也不知道接下来应该怎么发展。 与之相比,ETH 的创始人还在全世界开会、演讲,给 Ethereum 画出了路线图,让 L2 有了整齐划一的技术方向。当 ETH 遭遇黑客攻击,创始人能够带领整个社区把攻击历史分叉掉,还能引领社区把共识机制从 PoW 转换为 PoS。如此大的影响力,说明 ETH 是中心化的。ETH 没有理由能通过 Howey 测试,在 SEC 的标准下,ETH 八九不离十被认定为证券。 如果一个币是你发的,你在那儿喊单,是不是很不合适?可如果一个币是去中心化的,你随便喊,就喜欢 Doge,就喜欢,怎么喊都行。 BSV 的 CW 也在今年打输了官司。社区里有人用这一点来安慰自己,说 CW 的离开是好事,BSV 会变成真正社区驱动的币。不管 BSV 前景如何,至少创始人的离开是一个重要的指标。 还有一个例子是 ETC 和 ETHW。这两个币都是宝二爷分叉出来的,很多人对他有成见,但是这两个币的市值、价格、TVL 摆在那儿,412 这一波,这两个币的抗跌能力有体现。虽然确实不敢买,从 ETH 分叉出来以后,技术上就没有进一步的更新了,GitHub 上活跃度很低,感觉很难长久下去,但价格上又很是让人感到疑惑。ETC 和 ETHW 其实是真正没有创始人而且是 PoW 的币。 另外一个现象是,自从以太坊将共识机制从 PoW 转为 PoS,ETH 对 BTC 的汇率就一直在下跌,从接近 0.1 到现在 0.05 左右。 6. 其他 拿出 CMC 前 10 来简单对比一下: BTC:行业底线,市值占比 50% ETH:开创了 EVM 和加密货币版 Defi 市场,有庞大的生态,市值占比 16% USDT:使用范围最广的稳定币,美国国债储备 BNB:世界上最大的交易所平台币 SOL:PoH+DPoS 共识机制,中心化,依赖于少数排序器对交易排序后,才会交给验证者打包 USDC:最安全的稳定币,黄金储备 XRP:RPCA 共识机制 DOGE:PoW 共识,最大的 Meme 币,马斯克喜欢 TON:Telegram 发行的链 ADA:创始人有名气,使用 Haskell 开发,UTXO + PoS

2024/5/4
articleCard.readMore

为什么说 PoW 比 PoS 更加去中心化

想从准入门槛的角度对比一下 PoW 和 PoS。 我们经常描述一条链是 permissionless 还是 permissioned 的。其含义是成为出块节点,需要被授权还是不需要被授权。 PoW 是公认不需要被授权就可以挖矿的共识机制,只要你有一台计算机,就可以加入网络开始挖矿,只要你能计算出符合要求的哈希值,哪怕手算心算都可以,只要是正确的,你就拥有出块的权利,这个块中包含哪些交易由你来决定。当然能不能算的出来是另一回事。 BFT 是典型的需要被授权的共识机制。很多使用 BFT 类共识的项目,直接需要 CA 中心来签发证书,只有拥有证书的节点才可以在网络冲承担起挖矿的职责。而 CA 中心为什么要给你签发证书呢,完全是线下行为。成为出块节点的权限牢牢控制在 CA 中心手里。 PoS 存在一些争议。 SEC 曾经把 66 种加密货币定义为证券,包括 SOL、ADA 等市值比较高的币种。但其中没有包含 ETH,这一点给了很多人期望,也让 ETH 的 PoS 存在模棱两可的空间。 一个显著的事实是,PoS 是属于 permissioned 的共识机制。 在 PoS 中,节点往往会需要质押一定数量的 token 才能成为出块节点,而质押的那么多 token,就是 PoS 的门槛。 一个节点想成为出块节点,需要获得很多 token,而这些 token 从哪里来?要么来自项目的发行方,在创世阶段就分配出来到钱包里,要么后续从矿工手里买,因为矿工会增加 token 的供应量。 问题就在这里,设想一种极端的情况,如果所有持有 token 的人不愿意出售持有的 token,整个网络的控制权不就在他们手里了吗?不管他们是十个人还是十万个人,总之是一个圈子。而且在 PoS 的机制中,拥有越多 toekn 权力就越大,更是加剧 PoS 的中心化程度。 这个时候可能有疑问,以太坊如此成功,有上千万的持有者,成千的机构组织管理着上万个出块节点,难道还不够去中心化吗? 所以要区分两个概念,共识机制是去中心化的,和一条链是去中心化的,是两码事。 只要愿意,即使 BFT 类共识只能支持几十个节点的规模,如果这几十个节点分散在世界各地且有利益冲突的大财团手里,这条链仍然可以认为是去中心化的,因为几十个个利益相关方不可能有超过 2/3 比例的数量合作。 同样的道理,以太坊只是做的去中心化了,它成为了一条去中心化的链,不代表 PoS 是去中心化的技术。 为什么很多项目方愿意选择 PoS 而不是 PoW 来发币?因为 PoW 太不可控了,被攻击的风险非常高。可以观察一下,目前存在的 PoW 链,几乎都是从比特币分叉而来,而且很少有新增。新出现的山寨币,多数源自以太坊的技术栈。 PoS 为什么可控?只要你不把创始阶段的钱分散出去,你就拥有整条链的控制权,PoS 让人更有安全感。分散出去也没事,只要整体比例可控,链就仍然在你的控制下。 那按照这么说,比特币的矿机资源不也被控制在少数生产商手里吗?只要他们不愿意出售矿机,比特币就始终被控制在一定范围内。也不全是,即使没有先进的矿机,也是能够参与挖矿的。落后的矿机堆积的多了,算力也可以很高。至少没有人有权力,剥夺你成为出块节点的资格。 与之相比,PoS 是不一样的,只要没有足够的钱,就没有成为出块节点的资格。如果 token 持有者不愿意出售,这种资格是你无论花费多少努力都无法获得的。PoS 本质上还是关起门来大家协商的共识机制,在有资格的节点中随机选一个出块,而且还得投票。 对了,如果存在一种 PoS,没有准入门槛呢?所有节点都是出块节点,每一轮随机选择一个节点负责出块。这样的机制免不了会受到女巫攻击,最终变为比拼节点数量的游戏。其实比拼节点数量也不是不行,节点数量最终也是硬件资源的体现。拥有更多硬件资源的节点有更大几率被选中。我倒是觉得这样的共识机制有进一步思考的空间,不过面临的现实问题也非常多,比如怎么通过 VRF 来选择下一个出块节点,以及如此庞大的出块节点数量,需要的网络带宽也许是不是现有技术能够承载的。另外,其实没有准入门槛就不叫 PoS 了。 15 年过去了,比特币从未被超越……

2024/4/14
articleCard.readMore

牛市对普通人来说意味着什么

如果你曾经在市场低迷的时候,持有一些加密货币,那么牛市最大的意义,就是让你有限的资产获得了价值上的增长。 如果你是一位比特币信仰者,牛市最大的意义还在于,能够证明你的信仰是对的、几年前的眼光具有前瞻性,但也仅此而已,如果当时没有投入很多钱的话。 很多人看到现在 7W 的比特币眼红,后悔当年 3.8W 的时候没有多买点。但是放到 1 年前,3.8W 的比特币你敢买吗?是真不敢,谁知道当时还能跌到什么程度。如果我告诉你,一年后比特币会涨到 20W,你现在敢买吗?也不敢,是真的不敢。很简单的道理,没有人可以预测未来,只有在回顾历史的时候,人人都是股神。 最近比特币已经上了两次微博热搜,价格一涨人们就开始关注了。我也是眼睁睁看着 ETH 从 3400 掉到 1800,现在又涨到 4000。可是话说回来,这样的波动,能带来多大的改变呢,1W 块钱变 2W 块钱?好像也没太大意思。 牛市往往伴随着很多暴富故事。按照我的理解,有这么几种赚钱的方式: 炒币,拿现货,价值投资。这样相对稳健,风险可控,但是收益也有限。敢投入多少资金,取决于你自己有多少可用于投资的资金。别看鲸鱼们动不动挣百万千万,他们真实拥有的资产,一定至少在用于投资的 5 倍以上。同样的,收益虽然绝对值多,但其实相对于他们的资产总量,也就那么回事。 按照类似的比例,对普通人而言,收益也是有限的。敢把房子卖掉梭哈比特币的,那叫赌狗,那种做法,一般来说,挣多少钱就会赔多少钱,怎么挣来的就会怎么赔进去。除非能赚一波及时收手,可以能收住的也不是一般人,自制力已经超越人性。 开合约,加杠杆。这种类型的交易,拿个 10 块 20 块玩一玩就行了。如果不是玩,想赚钱,就不要碰,多少钱都得进去出不来,一般人玩不了这个。庄家做庄可以,如果你有能力控盘,但那个不是一般人。 发行 Meme 币。蹭当下的热度,什么火热蹭什么,AI 出新技术了就发个 AI 币,马斯克发推特有火箭的照片,就发个 ROCKET 币。总会有不长眼的玩家,为了热度瞎买,干这种事情,指不定一不小心就能赚很多很多钱。运气成分占很大一部分。另外是要有一定的运营和技术基础,得及时开交易的池子,懂得在社交媒体上推广,还得有一些美术能力,画个像样的 LOGO。 哪怕不是 Meme 币,发个土狗币,只要有盘子,有人进来玩,有人愿意接盘,就能赚钱。但是技术、美术、运营各方面条件加起来,也需要点小小的成本。很多暴富的例子其实都是从土狗币来的。我亲眼见过有人干这个事情挣了钱的,但是我不太眼红这个,这种钱咱挣不了。 钓鱼网站。这个属于明确的违法行为了,需要黑客基础,以及相关经验,这个是不能考虑的。 撸空投。现在的空投猎人已经是一个小产业,批量参与早期项目,广撒网。这个也是随机性很强,需要运气,前段时间疯狂打铭文的,也有很多很多人花了手续费进去,最后啥也没捞到的。另外这里也有一个收益比例的问题,别看有些空投猎人一个项目上拿百万,他能够控制的用于投入的资产一定也非常多,这种机会给你,你也拿不出那么多钱啊,一般人也是没啥机会。我平时接触的空投性质的项目不多,也许是我消息闭塞,不懂这些人是怎么玩的。 DeFi。就是赚个收益,把钱压进质押平台,赚几十个点的利息。这种需要承担的是项目方 Rug 的风险,得看准项目方靠不靠谱。 MEV。每年几亿的市场,已经被很多有经验的厂商盯着了,想从里面挣钱可能得拼网速。 实打实地开发一个有技术含量的项目,作为核心开发成员,拿到初期分配的 token,然后等着 token 翻上几百倍。这种方式显然更安全,但是机会也非常少,尤其是现在的市场环境,几乎没有敢从头搞开发的,大多数项目是拼凑型的,类似于技术方案整合商。有能力在某一个技术方向有突出贡献的项目不但少,而且技术门槛也非常高,这个也不是普通人搞得了的。 正经工作,安心打工。像交易所的技术栈和互联网公司其实差别不是很大,办公地点不传统以及工资发 token 属于比较显著的特色。 挖矿。现在的挖矿应该不挣钱了。 牛市有两种,一种是政策利好带来的价格增长,另一种是新技术引爆市场,吸引更多玩家进入圈子。目前的牛市属于前者,除了价格很疯狂,似乎没有太显著的技术出现,也没有现象级别的新玩意出现。除了价格在变,似乎什么都没变。或者说,可以认为目前还处于牛市早期,敢加仓的赶紧追高 :P 还有一点是,牛市会吸引更多资本的眼光,开始投入更多新的项目、新的公司,打工人的工作机会应该会变多一点。最近发现偶尔会出现一些名字小众的小公司,可能用人市场稍微好转了一点点?但是不出意外的话,猜测这些小公司,做的项目,也无非就是想象中的那几样……在哪儿打工不是打呢,身处裁员名单的人可能会觉得轻松点吧。 对于普通人而言,目前的牛市似乎真的没有意味着什么。今晚以太坊完成了坎昆升级,不知道能不能引起又一波 Layer 2 的风潮。我自己不太是以太坊信仰者,Layer 2 给人一种残缺的感觉,而现在的以太坊致力于推进 Layer 2 的发展。同样的,近期有一些公司在做比特币的 Layer 2 项目,同样不太看好,Bitcoin core 本身没兴趣搞扩容,甚至有能力直接让 BRC-20 一类的项目直接消失。 那么该信仰比特币吗,比特币好像跟普通人也没啥关系。期待有能代表信仰的项目出现。

2024/3/13
articleCard.readMore

如何健康地远程工作

最近两个月在以远程工作的形式上班,当然远程工作并不是像很多人憧憬的那样,穿着泳衣在沙滩上晒太阳,谈笑之间上百万的交易就成交了。远程工作仅仅只是办公场地的不同,至于工作制度和工作流程,属于整个公司和团队的氛围和文化选择,暂时不讨论这方面的问题。 远程工作和现场办公相比,有好有坏。好的地方是能比较方便打造适合自己的工作环境,比如电脑桌的高度不太对,可以换一张让自己舒服的桌子,在公司的办公桌就不那么方便换掉了。 也有一些不好的地方,比如生活和工组场所相同导致生活和工作没有明确的边界感,像我之前有过比较离谱的情况,找 bug 没有头绪,心烦意乱,晚上已经躺下准备睡觉了,睡不着,半夜 1 点多起床 debug 了一小时代码,稍微有点进展后继续睡觉,第二天 8 点钟起床继续 debug,然后继续上一天班。一天下来状态会很差,这是非常非常不健康的行为,一定要想办法杜绝。 为了针对和解决不好的习惯,需要一些明确可行的规则来规范个人行为,主要是作为参与者,应该如何在远程工作的状态下,保持自己的身心健康。 工作地点 因为需要合适的工作环境,所以并不能太自由地选择工作地点,比如背上电脑到商场,在咖啡店呆一天,或者找个图书馆、自习室,都是不合适的,不但要花钱买咖啡,而且那种消费类型的座位都不适合久坐,工作中还有需要视频会议的时候,在公共场合的话也不方便。尤其是对于程序员来说,工作需要 27 寸的外接显示器,需要人体工学椅,也需要能让人专注的环境,还涉及到工作时长的问题,如果下班晚,咖啡店不会营业到那个时候,就有麻烦了。 WeWork 之类的共享办公室本身选择比较少,而且价格很高,一天要 100~300 块钱,长期用的话非常贵了,很多共享办公室也是不支持保存个人物品的,产业整体并不成熟,不是好的选择。 综合来看,工作地点只能是在自己的房间里。如果有属于自己的房子,有一间闲置的书房,或者空闲的房间,那么多余的房间就可以作为工作地点。 如果是租房子住,情况会稍微恶劣一点,一般租房子只会租一间自己的卧室,卧室的书桌就变成了工作的地方。如果专门为了远程工作租另外一间屋子,成本是比较高的。而且提供办公场所原本是公司的责任,在远程工作的形式下这个步骤就省略了,如果让这个成本转嫁到员工身上,也挺违反直觉的,所以自己另外租一间房子实在是没有道理。 还有一种方案是移居到低消费的城市,由于远程工作不受地点约束,降低消费的同时还能提高生活质量是可行的,但是这种方案仅适用于在当前城市没有任何朋友的情况,否则即使只为了某一个人,也没办法换城市。 生活和工作要要有明确界限 在现场工作的时候,生活和工作会有明显的区分,至少身处的环境是完全不一样的两个地方,一个是家,一个是公司,比较容易做出生活和工作的区分。可能会有一种感觉,即使工作再累,回到家也是该休息的时候。也许电脑放在公司就不会带回家,也许带回家不打开,也许工作环境在公司内网,你想在家工作都不行。总之种种原因,会给你一个明确的、生活时间和工作时间的区分。 但是如果生活和工作的场所相同,都在同一间屋子里,而且还是远程工作,就出问题了。 想象一下,公司在你的办公桌旁边放了一张床,还提供了所有生活所需的物品,然后你今后要住在公司的办公桌旁边,起床就能看见办公桌,是不是一件很可怕的事情。 再想象一下,你一整天的生活,起床之后一个小时就开始工作,工作结束之后两个小时就准备睡觉,在工作之外的时间你都仍然呆在房间里,是不是一种会让人发疯的场景。 远程工作最重要的一点,就是要保证生活和工作有明确的界限,为了达到这样的效果,简单列出几点行为: 按时起床,预留出“通勤”时间 在原本应该通勤的时间里,出门散步,假装在通勤,绝对不可以睡懒觉 上班时间要穿好衣服和鞋子,不能穿居家的衣服或睡衣,必须是随时能够出门的状态 摆正外接摄像头的位置,表示进入上班时间 打开桌面上的时钟摆件,表示进入上班时间 打开窗户,表示进入上班时间 下班时间后,外出散步相当于通勤,结束工作 下班时间后,回到房间,拖一次地,让地板是湿的 下班时间后,不在电脑上进行工作相关的操作 …… 这些行为并不是具体的,也不一定是好的,只是举一些例子,重点是通过一系列物理上的行动,来给自己明确的信号,经过四五种操作以后,就进入上班时间了,经过另外四五种操作以后,就进入下班时间了。单纯光靠时间概念,到点了就上下班,很难让自己有明确的感受,也许本就非常自律的人可以做到,如果还做不到,就需要先依赖一些繁琐的行为,来给自己制造分界线。 户外活动 上面有提到,在本应该通勤的时间,要出门散步,假装在通勤。不只是为了保持和现场工作一样的生活节奏,更重要的是为了保持身体健康,确保有足够的时间在户外进行活动,哪怕是简单的散步,也一定好过整天待在家里。 有人认为远程工作的好处之一是可以省去通勤的时间,用来睡懒觉。这是非常错误的想法,多出来的时间不但不可以用来休息,还要刻意用来锻炼身体才对,天天在家里呆着估计也没人受得了。 参加社交活动 远程工作以后,我甚至开始怀疑和人类说话是一种生理需求。也许是认真工作的缘故,也许是工作日从早到晚都在同一个场所中,而且见不到活人、不能和人说话,感觉周末异常想出去玩,然后连续玩两天。 不在乎游戏好不好玩,只是想换个地方呆着,换换脑筋放松一下,见到不同的人,玩不同的游戏,干点和工作无关的事。 番茄工作法 现场办公的时候,可能时不时有人和你说句话、从你工位旁边走过、制造出什么声响,或者自己站起来去饮水机接一杯水、下楼买瓶饮料、去一趟洗手间等,各种各样的小事件,都会分散你的注意力,打断你的思考。这些事情也许会让你觉得麻烦,但是这些事情也能让你不要长时间专注于某件事情。 过于长时间的专注不是一件好事,会让头脑变得麻木迟钝,对于身体也是一种压力,会让人感觉腰酸背痛。人需要隔一段时间就站起来活动活动、舒展筋骨。 番茄工作法的大致含义是,每工作 25 分钟,休息 5 分钟,算作一个番茄时间。番茄工作法能有比较高的知名度,还是有一定道理的,这样的时间分配是一种不错的实践。 不要用 Stretchly 之类会在屏幕上弹窗的软件,使用体验并不好,它会直接打断你正在进行的工作,这很让人生气,你大概率会手动直接关掉它的弹窗。即使有些软件可以提前 30 秒通知你要弹窗了,体验也不会变好,手头上的事情如果在 30 秒内无法放下呢,本次 break time 就直接失效了。而且弹窗直接覆盖掉整个屏幕的方式,会让人有轻微的不安,担心是否有人给自己发消息,担心错过屏幕上的什么动态。 所以最好用会响起闹铃的倒计时。开始工作后计时,25 分钟后响起闹铃,如果不手动关闭闹铃,闹铃就会一直响下去。这种闹铃其实是一个很好的特性,如果你不放下手上的工作,闹铃就在不断提示你,是时候站起来放松一下了。当然自己也要自律,直到自己真的站起身,再动手把闹铃关掉。 但有时候开始工作会忘记启动倒计时,而且频繁手动开启倒计时也很麻烦,可以直接设定多个闹钟,在固定的时间响起,比如每个小时的 25 分和 55 分都响起一次。你不一样严格按照闹钟的时间进行工作,但至少闹钟会按时提醒你,该休息了。 实践证明,适度的休息放松不但不会耽误工作,而且会让工作效率变得更高。有些难题会一时想不到解决办法,往往在休息放松的时候,就有灵感了。 仪式感 作为内容的补充,偶然发现上面描述的种种行为,其实是在创造一种仪式感,上班需要仪式感,下班需要仪式感,很多时候仪式感是周围环境创造出来的,有时也可以自己主动去创造。生活需要仪式感。

2023/10/10
articleCard.readMore

为什么比特币不用概率加密函数

概率加密函数 在常用的对称加密中,一个明文对应一个密文。有一种叫概率加密的方式,会在加密的过程中加入随机数,达到一个明文对应多个密文的效果, 概率加密函数为什么很少用 需要区分三个概念:加密、签名、哈希。概率加密函数属于对称加密的一种,而实际上区块链系统中很少用到加密,无论是对称加密还是非对称加密。比特币的白皮书中只提到过签名和哈希,签名是在发起交易的时候用来确认资产所有权的,哈希是在挖矿的时候确认出块权的,并没有提到关于加密的使用。 加密的应用场景比较少是主要原因,存储类项目可能会用到对文件的加密,但也属于用户行为,区块链系统本身不参与数据的加密保护。 概率加密函数想要替代签名和哈希更是不可能的事情,它本身就不具备那样的能力。 概率加密函数的概率分布 有概率就会有概率分布,比如正态分布。对于概率加密函数来说,均匀的概率分布是比较理想的,否则在概率分布已知的情况下,根据加密出的密文内容,攻击者有可能还原出明文内容,是很危险的。只有随机强度一致,攻击者才无从下手。 所以概率加密函数几乎没有选择,尽可能分布均匀是常见的做法。 新式落地窗 在一个优美和睦的动物森林里,不知怎的流行起了落地窗风格的房子,不仅透光良好,而且设计又美观又高级。小狐狸是有名的建筑师,能够建造出这种流行的带有很大落地窗的房子,森林里的小动物们纷纷找小狐狸盖房子。 小鹦鹉是森林里的百事通,消息灵通能说会道,同时也是河水霸主河马身边的军师,常常给河马出谋划策。河马听说了最近流行落地窗的房子,也想要盖一间。但是河马碍于比较高的地位和身份,不想直接套用大家都在用的设计,于是让小鹦鹉想想办法。 小鹦鹉到处打听,无意间听说了“玻璃纤维”这个词,据说是一种新出现的合成材料,不易碎、韧性特别好。小鹦鹉高兴极了,赶紧告诉河马,自己发现了一种新的建筑材料,这是史无前例的伟大创新,能够颠覆传统的建筑方式,用更高级的玻璃纤维来代替玻璃,简直能引领下一代房屋建筑的新潮流! 河马听了挺满意,出钱出力让小鹦鹉找人把房子盖出来,而且建造方案要保密!可不能让人知道了这么重要的创意。 小狐狸听说了这件事情,也挺好奇,自己盖房子这么多年,并不知道有什么新材料,小鹦鹉竟然能做出如此重大的创新。正好小鹦鹉的施工队招人,小狐狸欣然报名,参与到河马房子的施工大队中。 但是小狐狸一进去傻眼了,玻璃纤维不是玻璃,是不透光的呀!怎么能代替玻璃呢?如果坚持用玻璃纤维,这房子就没法住了。如果不用玻璃纤维,就只能用玻璃,那就没有创新了! 后来小狐狸离开了小鹦鹉的施工队…… 达克效应 达克效应是认知偏差的一种,尤其用于描述低估或高估自己能力的现象。对于在某一个领域的专家,试图对领域之外的事情进行革命性创新的行为,可以归因于达克效应上,但是也存在细节上的差异,更贴切一点的描述是“超越领域的自信”,或者用更专业的心理学概念描述为“专业人士错觉”。 从心理学角度解释这种现象是有一定道理的,不过实际上造成这种现象的,还有一个重要的因素,就是存在一种无形的力量……

2023/10/4
articleCard.readMore

程序员的 “服从权威” 心理

最近经历了一件事情,就是入职一家初创公司,工作两周后主动离职。虽然只有短短两周但感觉过了很长时间,从中也学到或者意识到很多东西。离开的原因有很多,也涉及到比较具体的问题,我会继续反思和总结。今天想到一个有意思的话题,可以作为简单的切入点。当然有一些不方便说的内容我是不会说的 :P 我发现程序员群体普遍存在一种 “兽性”:谁技术好谁就厉害,谁技术好我就听谁的、我就服谁,我甚至会叫他大神、大佬,崇拜他,相信他,反之,要是技术不好的人当我的小组长、技术负责人、CTO,支配我的工作,我就不服他、不愿意听他的话。对于管理者来说,如果自己的能力不服众,事情推进起来也会格外艰难。 这个问题罗永浩在某个节目里也提到过,但是我不记得是在他的创业课播客还是什么访谈节目中了,特意找了一下没有找到,他的语录内容实在太多了。同时我也意识到,当有过加入初创公司的经历,观察到一些现象后,再回头去看他的 创业课播客,觉得他说的那些东西对创业公司还真挺有价值。 这种现象不止存在于程序员群体,在很多影视剧中都有类似的情节,比如在森林中,战斗力最强的狮子就是王,猴群中最能打的当猴王,或者在骁勇善战的原始人部落,以武力争高低,谁赢了听谁的。再比如西部牛仔比试枪法,胜利者获得某种地位和象征之类,等等。 这样的现象叫 “服从权威” 心理。分析一下权威这个词。权威其实不是一个负面的词语,虽然我们会看到一些言论说要打倒权威、不要盲目崇信权威,但其实那个语境中的权威是指非法获得的、德不配位的、迷惑群众的权威。“服从权威” 这个词中的权威,指群体中真正的强者。相信大多数人都不会反感真正的强者。 回到职场关系的问题,按照我粗浅的理解,可以简单把职场关系分为 “合作关系” 和 “从属关系”。 典型的合作关系比如公司与公司合作、公司的合伙人之间合作、两个部门负责人之间合作、两个小组长之间,等等,地位是平等的,在这种合作的情况下,能力可以是互补的,一方拥有 A 能力,另一方拥有 B 能力,合作起来才能发挥更大作用。很多公司都有 CEO、CTO、COO 的角色,角色背后是极其专业的能力。 从属关系也好理解,老板和员工之间,领导和下属之间,只要存在上下级关系都属于从属关系。一般来说,从属关系的能力是垂直的,比如 CTO 负责制定技术路线和战略方向,一线的技术人员根据规划完成具体的工作。这个属于角色分工的不同,但 CTO 和下属都是技术背景的人员。如果让公司的 COO 来领导技术团队,事情不就乱套了吗。 程序员的 “服从权威” 心理就存在于从属关系中,需要有纵向的比较,才能够产生所谓的 “权威”。合作关系中的能力都不同在一个标准下,就很难进行比较。 既然这种心理现象是普遍存在而且是客观存在的,那对于我们来说有什么启发呢? 对于非权威一方:明白自己要追求的权威是什么。比如工作中感受到了来自上级的压力,要清楚是有意义的压力还是无意义的压力,如果对方是自己领域的权威,就争取提高自己的能力,打败他,自己成为新的权威。如果对方不是自己领域的权威,只是凭借着某些原因在支配你,你应该奋起反抗,或者及时脱离苦海。 对于权威一方:明白自己权威的来源是什么。比如技术能力也是分层次的,假如自己有着高瞻远瞩的技术视野,而有些非权威的人拿着一分钟能打多少个字的数据来挑战你,你完全可以置之不理,不需要担心什么。 如果不具备权威的能力,又身处权威的位置,该怎么办呢?将从属关系变为合作关系,就不用担心有人来挑战权威、自己不能服众、有人不服气的问题了。因为 “服从权威” 的心理只存在于从属关系中而很少出现在合作关系中。简单来说,就是外行的领导要懂得放权。

2023/7/30
articleCard.readMore

区块链技术面试题

最近找工作也经历过很多次面试,这几次面试比较大的感受到一个共同的现象,就是和面试官聊不到一块儿,因为一般面试官也只是问他自己会的、关心的技术,很多东西我平时不怎么关心,而我简历上写的东西,对方也不怎么问。举例来说,我简历上有提到 State channels 相关的工作内容,我想但凡面试官知道 State channels 是什么,看到这部分简历,一定会多少问一句 HTLC 的原理是什么这种问题。然而我经历过的面试中,只有一家公司的面试官问到了这个,他们也确实是专业做区块链项目的团队。然后人家没要我。 虽然我面试了很多次,也不通过面试很多次,但是大多数情况是问我一些 web2 风格很重的技术细节,很少有人在区块链方面把我问住,尤其是我简历上提到的工作相关的内容,所以并没有太多挫败的感觉,更多时候是在抱怨,为什么那些人都在关心那些,我觉得不重要的东西,我能把某种需要两三个月才能搞明白的区块链技术理解清楚,半个小时就能看懂的某个关于编程语言的问题还能学不会吗? 所以就想到,如果我是面试官,面对在应聘区块链开发职位的求职者,我可能会提问哪些问题,然后根据这些问题来判断候选人的水平。这些问题是基本上通用的,其中一些问题能深入和扩展: 比特币的共识是什么,UTXO 是怎么回事 以太坊的共识是什么,如何选定出块节点的 你还知道哪些链,这些链有什么技术特点 PBFT 的流程是什么,国内有哪些联盟链 PBFT 有哪些变体,哪些链在用 Layer 2 有哪几种技术类型 State channels 的原理是什么,Rollups 的原理是什么 账户钱包是怎么生成的,keystore 文件是什么 存储类区块链有哪些,其中的难点是什么 Solidity 语言有哪些限制,和 Go 语言的不同 面向对象了解吗,写过 Java 吗 区块链里有随机数吗,怎么使用 合约交易是怎么经过虚拟机处理的,怎么增加对另一种语言的支持 日蚀攻击和女巫攻击分别是什么 了解过资产跨链吗,大概是什么流程 这些问题没有明确的先后顺序,也比较浅,抛砖引玉吧。突然想起来我面试还被问到过,区块链里面的区块是怎么连接起来的?答案是下一个区块会存上一个区块的块哈希,这种问题就属于没入门系列,还是问点门里面的问题吧。

2023/7/12
articleCard.readMore

如何区分公有链和联盟链

简短版 发币的就是公链,不发币的就是联盟链。 详细版 因为我经常否定联盟链,就自然而然产生一个问题:到底什么是联盟链?公链和联盟链的根本区别是什么,分界线在哪儿?本以为这是一个简单的问题,但是仔细想想又没那么简单,不是特别容易去下一个明确的定义, 简单来说,公开给所有人访问使用的区块链就是公链,在一个小范围内部使用的区块链就是联盟链。这种区别就像是互联网和局域网的区别。但是如果把一条联盟链开放出来,给所有人使用了,它就变成公链了吗?如果这条链使用原始版本的 PBFT 作为共识机制,那它仍然还是联盟链,始终都是联盟链。公链和联盟链的区分,不能仅仅根据使用人群范围的大小。 再比如存在一个主观的问题,什么是所有人?多大范围算是所有人?如果一共有 100 个人,100 个人就是所有人,那么 99 个人算不算?在现实生活中,似乎也算,99 个人在使用的链,不可能说它是一条联盟链。那 80 个人算不算?好像也算,已经是大多数人了,比起 20 个人的小部分,80 个人不至于认为是一个联盟。60 个人呢?已经超过半数了,能代表所有人吗? 所以如何判断一条链是公链还是联盟链?经过一段时间的思考,从技术特点的角度去考量,我认为要同时满足这三个条件,才算是公链: 节点之间网络互联互通 每个节点平等地拥有成为出块节点的机会 成为出块节点的门槛是合理的 你可能注意到,在简短版中,把有没有加密货币作为了区分公链和联盟链的唯一指标,而在详细版里却没有提加密货币。主要是判断的维度不一样,有没有发币是整个系统体现出来的使用方式,上面列举的三个条件要更通用一点,是技术方面的特性。满足三个条件而没有发币仍然能认为是公有链,只不过加密货币对于矿工是很重要的激励手段,是整个系统的一部分,一般是不可或缺的,项目方通常会把加密货币设计进去。 节点之间网络互联互通 这一点要求节点不能从网络层面设置访问权限,任何人都能通过互联网访问节点。如果节点没有部署在公网上,而是运行在局域网中,这个节点就不属于公链的节点。如果节点部署在公网上,但是仅限指定的 IP 访问和使用,那这个节点就属于设置了访问权限,是不够开放的行为,如果所有节点都存在类似的设置,整条链就不算是公链。 每个节点平等地拥有成为出块节点的机会 比如 PoW,只要算力足够,就会被认可成为出块节点。比如 PoS,只要质押 32 ETH,就有机会成为出块节点。这种属于典型的公链的例子。像是原始版本的 PBFT,出块节点是固定的,还不能更换,就属于联盟链。有一些链在 PBFT 的基础上加入了随机更换共识组的功能,每隔一段时间就会随机选择一些节点作为共识节点负责出块,包含这种设计的区块链是属于公链的,不过项目方需要考量这样的做法是否安全,容错能力怎么样,是否面临女巫攻击的风险。 成为出块节点的门槛是合理的 合理是一种主观的感受,因为不好量化,需要项目在设计上去权衡。比如 PoW,获得足够算力去出块是有很高成本的,这个成本就是门槛,如果门槛太低,所有节点都轻易出块,网络会乱,如果门槛太高,没有人能达到,出不了块,都是不合适的。这个门槛需要一个折中的、合适的位置。再比如有些 PBFT 联盟链中,成为出块节点需要 CA 发行的证书,而这个 CA 是由项目方控制的,需要线下通过一系列认证,才能够加入联盟获得证书,这种属于典型的联盟链。 为什么喜欢区块链 技术属性上,区块链有一种反叛的精神在里面,区块链主张去中心化的技术理念,自成一套体系,既然你中心化的机构靠不住,那我们就各自为营,我们首先相信自己,其次才相信别人,在这样的模式下,历史记录的准确性非常高。目前能在全世界范围达成数据一致的系统,也就只有区块链才能做到。 资产属性上,加密货币抵抗地缘政治变化的能力非常强,你可以轻易地持有和世界货币锚定的货币资产,不会因为世界局部的货币价值波动而受到影响。 为什么不喜欢联盟链 国内的联盟链项目,很多是政府机构搞信息化建设,最后都是政府出钱。假如雄安新区一个信息平台的项目 2000 W,其中会包含各种技术领域像人工智能、大数据、物联网,区块链是其中一项,具体到这一个区块链类目上,可能 300 W,这 300 W 也不是一般小公司能拿下的,至少得有点强硬的关系,比如外包大厂。然后这个区块链类目又包含很多小的部分,比如某个部门的数据管理平台,要接入区块链进行数据上链,如果这家外包大厂不具备区块链的研发能力,就会从 300 W 中分出 30 W,找一家专门开发区块链的公司,把这部分功能完成了。最终开发联盟链的公司,挣个辛苦钱,同时指望一年能多做几个项目,多挣点钱。 这种项目大都会走招投标的流程,可以想象一下,光是能不能把项目揽下来就会牵扯到多方的利益纠缠,投标的时候人家会给你公司的整体实力打分,包括申请过多少专利、企业过往的营收能力之类。而且这种项目和区块链的理念没有半点关系,换一个名头比如 5G 技术,还是一模一样的项目流程,招标投标做项目交付,只是其中用到的具体技术不一样而已。所以我说联盟链不是真正的区块链,联盟链是在用金锄头锄地。

2023/7/12
articleCard.readMore

对 Layer 2 项目创业想法的回复

说明:没有任何不尊重来信的意思,邮件中对于 L2 的观点带给我很多启发,因为其中有一些内容和我认知不完全一致,我认真理解和思考了来信的含义,所以想借此表达一下我对 L2 的看法。博客中 L2 相关的内容比较少,就放到博客上来了,这里只是单纯作为技术问题讨论一下 来信 你好,还在找工作吗?Jul 6, 2023, 5:39 PM (2 days ago)to mewangyu 你好!无意间看到了你的博客,也不知道这个邮箱能不能收到。今年开始 L2 开始火起来了。各种大公司,只要有点钱的,都开始怼 L2从 L2beat 来看,还有不少 L2 即将上线 https://l2beat.com/scaling/tvl#upcoming也不清楚是不是天下 L2 一大抄。但总感觉目前的 L2 技术都差不多来源于几家头部公司的开源代码。如果真的是这样,是不是就可以意味着阿猫阿狗都可以自己 Fork 出一个版本来了呢?如果是的话,那么我们就是那个"阿猫阿狗" :)我们创业团队挺有兴趣蹭蹭 L2 公链"热潮"的,感觉您在这块应该是一把好手。如果你也有兴趣,我们可以进一步聊聊合作方式?我大概的想法是远程、由你自由主导的链演化路线PS: 我的 telegram 是 @****** 欢迎在线联系我。 回复 您好! 非常高兴收到您的邮件,也很抱歉没有太及时回复。 我尝试认真理解您来信的内容,对于其中提到一些关于 L2 的观点,我想描述一下我的理解。 1. “技术类型” 和 “项目” 的区别 Layer 2 有 4 种技术类型,State channels、Sidechains、Optimistic rollups、ZK rollups,所有的项目都属于这 4 种类型内,在即将上线的项目中,大多数是 rollups 类型,但是没有信息他们的代码是不是 fork 已有项目的。 具体项目的话是有这 4 个: 但是从已上线的项目列表看,即使是 fork 相同的项目,他们的应用场景也是有很大差异、高度定制化的: 2. 现有的项目不是简单的 fork 以 zkSync 为例,在列表上能看到 3 个项目有这个标识: 分别是 zkSync Era、zkSync Lite 和 ZKSpace,其中 zkSync Era 和 zkSync Lite 是同一家。 再具体看 ZKSpace,他确实是用了 zkSync 的合约,但并不是单纯的 fork 整个项目,而是使用了 zkSync 的合约代码,在其基础上做一些修改适配自己的业务。同时,ZKSpace 也使用了其他项目的代码。 从 ZKSpace(以前是 ZKSwap)的白皮书能看到,ZKSpace 想做的事情是 Layer 2 上的 Uniswap,包含了 AMM 的功能,也就是交易所市商的那些东西。 zkSync 是一个通用的 Layer 2 项目,本身不提供太具体的功能,ZKSpace 使用了一些 zkSync 的合约代码,在那个基础上做一些事情,而且是业务强相关、有具体目标的事情。他并不和 zkSync 本身抢市场。 再看比如有 OP 标的项目,也就是基于 OP 项目 fork 的。 Arbitrum 一开始是 fork OP 的,然后改了一些经济模型方面的东西,后来做大了又重构项目。而且 arb 的运营能力很强,举办过几次奥德赛。 Boba 的特点是 withdraw 余额不需要等 7 天,马上提取,另外通过他自己叫做混合计算的技术,把 web2 的网页能力带到区块链上。 Zora 是一个专做 NFT 领域的 layer2. Mantle 提供了去中心化的序列器之类。 总的来说,这些项目都是有具体的目的,想要实现某一种功能,然后把现成的代码拿过来,复用一下,重点可能比较在于这些项目的目标,也就是想干什么样的事情。他们都是结合了自己的业务场景,而不是简单的 fork 下来就能运行。 3. L2 不是链 目前的 rollups 项目都是中心化的,本身不是一条链,没有共识之类的东西,主要是项目方在运营。 小结 所以关于您提到的想做 L2 方向的项目,我的意思是,从您的描述来看,可能没有您想的那么简单,其中会很涉及到一些具体问题。也许需要应用场景上的创新,和 defi 或者 gamefi 场景结合之类,或者技术特点上的创新,能够改善某种痛点。 我也很想蹭热点,也希望能有合适的创业机会,我现在也是在找工作,不过从我的角度看还是挺迷茫的,我本身没有太直接的想法,一个项目该往什么样的方向做之类,我觉得那些都涉及到挺市场化的东西,需要很大的资本力量。 不知道您具体是什么样的想法。由于内容比较多,就以邮件的方式回复了。有问题您随时联系我。 补充 有一些在回复中没有提到的话题,顺便展开聊一聊。 首先是 Layer 2 为什么火?因为 Vitalik 在最近的博客中频繁提到 zkEVM,比如在文章 Endgame 中,把支持 ZK-rollup 认为是以太坊未来的重要工作之一。有些问题没办法在 Layer 1 直接解决,就希望通过 Layer 2 解决。有 Vitalik 这样的备受关注的开发者引领生态上的技术方向,Layer 2 就成为了以太坊的发展热点。 Vitalik 比较看好 ZK-SNARKs 类的 zkEVM,从技术角度,ZK-rollups 将是所谓的 Endgame。但是 ZK 的开发成本非常高,目前还处于研究开发,小体量的资本玩不起,太贵了。 Dapp-learning 社区在 Youtube 上有一个 Scroll 的技术分享视频,内容挺好的。Scroll 的整个技术团队都在国内,从视频的讲解中感觉似乎 Scroll 的技术方案是有一些暴力成分在里面的,就是既然写 circuits 不容易,那就把某一类型的 circuits 结果作为一张表,用的时候查表,然后去不断扩充出各种表以支持整个 EVM。这是 Scroll 对比了各 ZK 项目实现 EVM 完备的方式: Vitalik 的博客文章 The different types of ZK-EVMs 也提到了对 zkEVM 的分类。不管怎么说,Layer 2 的火热应该是指基于 ZK-rollup 的项目打得火热。 Optimistic rollups 更像是 L2 的一种过渡方案,相信 ZK 出来后 OP 类的项目都会受到打击,因为 OP 并不依赖于加密技术的可靠性,从命名上就能看出来他是乐观主义,先认为一笔交易没有问题,然后给 7 天的挑战期,如果交易有问题,需要用户自主发起挑战,或者由第三方验证者来完整挑战,有很大程度的人为干预在里面。 再一个是 L2 项目中心化的问题。除了 Sidechains 的项目,L2 一定是中心化的,因为共识交给 L1 去做,L2 本身不管这个,从动机上,L2 起到的作用是快速地把交易收集起来打包发到 L1 上,一笔交易最终的信任是落到 L1 上的,用户相信的不是 L2 而是 L1 上的交易记录,所以 L2 不需要去中心化,用户不需要,项目方也没有必要。L2 一般是有项目方在发行和运营,用户信任项目的技术方案,才愿意到项目上玩,把资产质押进去。 还有 L2 使用体验的问题,目前很少有用户会把 OP 或者 ARB 作为低成本资产转移的方案,宁愿用 BNB 或者 TRX 来实现匿名场景的支付,使用比较多的还是治理 token 的投资或者 DApp 项目形式的交互。OP 赎回资产需要 7 天的等待期这一点就挺难用的,L2 链之间的资产转移也是个问题,比如在 OP 上的 USDT 能不能转移到 ARB 上,不然我用 OP,另一个人用 ARB,我们就没法交易了。在这个方面还有很大的提升空间。 L2 是很有前景的技术方向,期待它能带给我们更好的使用体验,至于其中有没有低成本的获利空间,就不太清楚了。

2023/7/8
articleCard.readMore

对区块链共识机制的理解

共识机制的作用,是让大多数节点的数据能够保持一致。共识机制有两种大的类型,一种是 PoW 风格的,一种是 PBFT 风格的。几乎所有的共识都在这两种风格之下。 可以从头思考一下,如果是一个人类的群体,怎么样才能让大多数人保持一致呢?一种方法是选出一个领袖,大家都听领袖的意见,再一种方法是大家一起商量进行决策,达成统一的意见。 对于第一种方法,问题在于如何选出领袖,依据什么样的标准选出,或者怎么样的人才能够成为领袖。对于第二种方法,问题在于哪些人可以参与商量,如何做出决策。 先看第一种方法,流程上主要有 3 个步骤: 通过某种方式选出领袖 所有人在时间单位内都听领袖的 所有人在时间单位内将会达成一致 PoW 的过程就是这样: 解出哈希难题的矿工成为出块节点 所有节点接收并验证矿工的块 所有节点的块数据达成一致 在这种 PoW 风格的共识过程中,最大的变数是第 1 步,也就是如何选出出块的节点,也因此有了很多 PoW 共识的变种。 Proof of Stake (PoS):以太坊在使用的共识机制,币种名称为 ETH。质押一定量资产的节点会随机成为出块节点,随机的过程由在信标链上运行 VRF 函数完成,并且质押的资产越多,成为出块节点的可能性越大。 Proof of Authority (PoA):以太坊测试网支持的共识机制。在网络的初始化阶段就已经确定哪些节点可以出块,之后按照顺序依次出块。确认验证者的过程是线下完成的,网络本身不具备对验证节点的纠正能力,比较中心化的一种共识。 Proof of Importance (PoI):Nem 在使用的共识机制,币种名称为 XEM。制定了一些给节点打分的机制,在多少天内交易多少次、交易额度是多少之类,以根据分数对节点进行排名,分数高于指定标准的节点,随机成为出块节点。 Proof os Elapsed Time (PoET):Hyperledger Sawtooth 项目使用的共识机制,是英特尔开发的一个项目。每个节点随机生成一个等待时间,等待时间最短的节点成为出块节点。出块节点需要提供一个最短时间的证明,这个证明和硬件设备相结合,达到无法伪造的效果。 Proof of Burn (PoB):Slimcoin 在使用的共识机制,币种名称为 SLM。节点通过销毁资产的交易获得销毁哈希,销毁哈希的计算包含了销毁的金额以及节点在一段时间内销毁的频率等信息,然后系统对每个节点提交的哈希值进行比较,哈希值最小的节点将进行本轮的出块。 Proof of Capacity (PoC):Burst 在使用的共识机制,币种名称为 Burst。节点使用 hard-to-pebble graph 的数据结构,在磁盘上进行文件的写入,这个步骤需要足够多的磁盘空间,写入完成后节点随机打开文件的某个位置,计算对应的哈希值,直到找到满足要求的哈希值,节点就可以进行出块。 Proof of History (PoH):Solana 在使用的共识机制,币种名称为 SOL。这种共识机制的创新之处在于,每一笔交易或者其他链上的操作,都会对应一个哈希值,然后 PoH 生成器生成块,这个块由一系列连续的哈希值,也就是一系列链上操作组成,从而保证链上数据的一致。这里的 PoH 生成器就是出块节点,PoH 生成器的选择标准则是质押金额最多的节点。 这些是 PoW 风格共识的例子,可以看出它们在想方设法改变选择出块节点的方式,但不管具体使用怎么样的机制,这些区块链都符合 3 个步骤的流程。 再看第二种方法,流程上也是 3 个步骤: 通过某种方式选出参与决策的人 参与决策者进行交流、达成一致 其余所有人都依照决策结果,达成一致 PBFT 的过程类似这样: 从所有节点中选出共识节点,然后共识节点依次作为提案节点 参与共识的节点经过两轮投票,对提案内容进行决策 共识节点达成一致,其余节点跟风达成一致 可以看出和第一种方法相比,由单一的领袖变为了多个决策者。在这种 PBFT 风格的共识过程中,最大的变数也是第 1 步,如何从所有节点中选出共识节点,以及共识节点成为提案节点的顺序。这是一些变种: Delegated Byzantine Fault Talerance (DBFT):Neo 在使用的共识机制,币种名称为 NEO。每个持有资产的节点都可以进行共识节点的选举,将自己的资产委托给共识节点。拥有委托资产的共识节点进行排名,前几名将依次作为提案节点。 Federated Byzantine Agreement (FBA):Stellar 在使用的共识机制,币种名称为 Stellar。网络中的所有节点都是共识节点,都可以参与两轮投票。为了减少网络的拥堵,节点也可以委托自己的投票权给另一个节点,使用切片或者子网络的方式提高共识效率。 HoneyBadgerBFT:一种支持完全异步网络的 BFT 类共识,不依赖于同步的时间顺序,这是 PBFT 不具备的能力。不过异步网络的共识效率相对低一点。 HotStuff:一个允许部分网络异步的 BFT 类共识。它的特点是,网络中可以同时存在多个提案,提案节点将选择最好的一个提案,进行后续的流程。这样的方式相当于产生提案的过程是并行进行的,提高了整个共识的效率。 VBFT:Ontology 在使用的共识,币种是 ONT。使用 VRF 随机函数进行提案节点的选择,每一轮的提案节点都是随机、不可预测的。 总体而言,PoW 风格和 PBFT 风格都是通过某种方式,筛选出最终能达成一致的内容,但是在选择内容的方式上存在根本区别。PoW 中其余节点将无条件接受来源的块,只要来源的块是符合某种条件的。而 PBFT 中,其余节点先收到块的内容,然后对这个块的去留进行决策,在接受块之前有一个投票的过程。 现在也有一些创新类型的共识出现,或者是结合多种共识的混合共识等形式,不管是对节点进行信誉评估、根据历史交易质量之类打分,还是根据手机、物联网等硬件设备进行数据的验证,还是对 PBFT 做某个阶段上并行、容错能力和网络效率之类的优化,共识机制终究还是在 PoW 风格和 PBFT 风格的体系之下。 因此你可以大胆的说,共识机制也就这么回事,只是在具体的设计和实现上有差异而已。

2023/7/1
articleCard.readMore

Pebbling Game 鹅卵石游戏

这是一个在线的网页游戏:Pebbling Game。可以看这个嵌入进来的网页: 为了直观展示 Pebbling Game 的游戏规则,经过几十次调整,GPT-4 完成了这个在线的游戏页面。 游戏的规则是: 点击节点的圆圈,可以在节点中放入鹅卵石 只有指向当前节点的所有节点,都已经放置了鹅卵石,当前节点才能够放置鹅卵石 游戏目的是在节点 0 放置鹅卵石 任何时间都能够从任意节点取走鹅卵石 如果直接点击节点 0,可以看到两个红色闪烁圆圈的提醒,意思是节点 1 和 2 都还没有放入鹅卵石,所以节点 0 不能放入鹅卵石。 节点 7 没有来源节点,所以可以直接放入鹅卵石。点击节点 7,能看到节点内出现了黑色的实心圆。此时如果想把鹅卵石放入节点 3,会提示因为节点 6 还空着,放入失败。节点 3 的来源节点是 6 和 7. 那么在这样的游戏规则下,问:最少需要多少颗鹅卵石? 如果鹅卵石足够多,这个图中一共有 10 个节点,手里有 10 个鹅卵石,就不需要取走鹅卵石的操作,直接按照顺序把节点填满就行。 如果鹅卵石有限,寻求鹅卵石数量最少的解法,这个图应该至少需要 5 个鹅卵石。 鹅卵石游戏的特点就是,总会存在一个最小值,如果鹅卵石的数量少于这个值,游戏将不能完成,因为最终的节点依赖于下层节点,而下层节点依次依赖于更下层的节点。如果中间节点的鹅卵石被取走,还需要从最下层开始重新放置。 鹅卵石游戏对于 Hard-to-pebble graphs 的数据结构具有启发意义,理解了游戏的规则,就理解了区块链如何证明磁盘空间的大小。 空间证明 Hard-to-pebble graphs 是一种结合了 Merkle 树的 DAG,特点就是需要一定数量的储存空间才能够完成最顶点的计算。就像是鹅卵石不够就无法完成游戏,储存空间不够就无法完成挑战。 由于图的多种多样,需要鹅卵石的数量没有通用的最优解,只能是针对某一种类型的图,去计算空间复杂度。 区块链场景的需求是既要占用空间大,又要验证速度快。Stack expender graph 的图结构在 Proof of Space 中使用比较广泛。在验证阶段,只需要按照 Merkle 树的特点,验证图中的某些节点,就可以确认图的完整性了,同时也能根据图的深度,推算出占用了多大的磁盘空间。 如果既要验证空间大小,又要验证空间占用的持续性,就在空间证明的基础上加上对时间的证明,比如 Chia 就用了 Delay Verifiable Function 的方式,先验一遍空间证明,等一段时间后,用 VDF 验证确实经过了足够多的时间,然后再验一遍空间证明,就达到了 Proof of Space-Time 的效果。

2023/5/18
articleCard.readMore

PDP 文件证明的局限性

存储证明 对于一个文件来说,PDP 最基础的用法,就是根据文件内容生成对应的 TAG,然后把文件发送到另一个环境里。接着用保存下来的 TAG 验证对方是否真的储存了文件。如果对方没有储存文件,是不可能通过第一次验证的。在接下来的验证中。因为挑战是随机生成的,会选择文件不同位置的片段,能在概率上提高验证的可靠性。 所以这样的用法,至少能够证明文件储存在对方环境中过。但是这样的证明方式存在疏漏,就是文件不需要完整,或者说缺少对文件完整性的证明。如果文件非常大,而挑战的数量一般是固定的,文件越大,挑战越不全面。虽然挑战的文件片段是随机的,仍然存在可能性,对方不需要完整的文件,而只需要文件的一部分,2/3、1/2、1/3,就足以完成挑战了。 文件完整性证明 要让 PDP 能够证明文件的完整性,可以把 PDP 证明和 Merkle 树的数据结构结合起来。PDP 证明一般需要把文件拆分成数据片段,根据数据片段生成 TAG,并不强调数据片段的序列化方式。 和 Merkle 树结合的地方,就是在把文件拆分为数据片段之后,把数据片段序列化到 Merkle 树中。由于 Merkle 树的特性,父节点的值需要依赖子节点的值才能够推算出来,如果验证了父节点是正确的,就足以说明子节点是全部存在的,也就间接证明了文件的完整性。 在实际的使用中,往往抽取 Merkle 树的一些节点,对 Merkle 节点的路径进行验证。也是验证 Merkle 树完整性的一般方法。在使用了 Merkle 树作为文件的数据结构,并且在验证 PDP 证明的同时验证 Merkle 树的完整性,就能确保文件是完整存在了。 Merkle 树的验证覆盖率也不可能 100%,但通常认为,伪造 Merkle 树节点的成本比真正保留了完整数据的成本还要高。 文件大小证明 PDP 证明本身不包含文件的元信息,它只能证明文件是存在的、文件是完整的,至于是什么样的文件,文件名是什么、文件类型是什么、文件有多大,一概不知。PDP 的证明信息里不包含文件的元信息,而元信息中比较有用的是文件的大小信息。PDP 不是区块链时代的技术,也就没怎么关心这个问题。 有的区块链用磁盘空间的大小来描述节点的算力,或者根据文件大小来让使用者付费,或者根据储存的文件大小来给予存储节点奖励,这些都意味着文件大小的信息至关重要。 那么,对于文件的元信息缺失的问题,一种做法是在 PDP 证明系统之外,将元信息与文件数据关联起来。另一种做法是,对文件进行修改,将元信息附加到文件数据里,只要证明文件数据的完整,就能够确认文件元信息的准确。 然而,无论哪种方式,都不能保证文件元信息的可靠。在区块链的场景下,信任模型发生了一些变化,和 PDP 要解决的问题存在差异。以前的 PDP 是让用户相信存储节点,而现在,是让区块链来相信存储节点。 第一种方式需要额外的机制保证元信息和文件数据之间的映射关系,这会带来很大的开销。第二种方式在实现上是简便的,问题在于将元信息附加到文件数据的步骤,由谁来完成?是可信的吗?如果恶意附加了错误的信息呢?另外,怎么读取这些元信息?要把文件数据或者一部分文件数据下载下来?下载的信息还不能保证是正确的? 总之,在加密学的范畴里,PDP 并不能解决对文件大小的证明。 文件持续性证明 PDP 证明需要不断地发起挑战请求,对方能够完成挑战则说明文件安好。比如现在发起一次挑战,一个小时之后又发起一次挑战,至少能够说明对方在完成挑战的时刻,文件是完整存在的。那在没有进行挑战的时间呢?没有限制。 如果在第一次完成挑战后,就把文件转移到了另外一个地方,等第二次挑战的时候,再把文件拿过来完成挑战,这完全是可以的。PDP 并不也没有能力阻止这样的情况。PDP 无法对文件的持续性提供证明。 这样的能力限制会对区块链造成什么样的影响呢,首先能想到的就是算力换空间。因为在有些区块链的规则里,文件越大,矿工的收益越高。矿工完全可以利用 PDP 的证明间隔,把空间腾给其他文件用,用磁盘的 IO 换取磁盘空间大小。 再就是证明成本的提高。挑战越频繁,文件越安全,这对寻求证明的一方是一种压力。 文件多副本证明 PDP 自然也不具备证明文件有多个副本的能力。如果需要对方存 2 个副本,而对方实际上只存了 1 份,对于 PDP 是没有任何感知的,这很容易理解。 如果想分散文件损失的风险,把文件存到了不同地方,在使用 PDP 作为验证手段的情况下,是无法达到目的的。

2023/5/17
articleCard.readMore

不要小瞧 ChatGPT

之前我不了解 Filecoin 是如何管理磁盘空间的,既然它使用了 Proof of Space,那么 Sector 在创建的时候就一定会占用掉磁盘空间,当有用户的文件存入 Sector,Filecoin 是如何及时把占用的空间释放出来的?每写入一次文件,就释放一次占用的空间,这种做法太生硬了。 我看了 Filecoin 的 文档 和 Spec,都没能很直接地找到答案。运行 Filecoin 节点的成本又比较高,不但对硬件需求高,而且还得在实际的操作过程中去观察磁盘容量的变化。 后来无意间问了 ChatGPT 一句,没想到它给出了很详细的解释,让我对 ChatGPT 刮目相看。Filecoin 不会实时释放磁盘空间,只在 Sector 存满文件之后有一个 Sealing 的过程,这个过程会把 Sector 封存起来,然后用真实的数据替换到之前随机生成的用来占用空间的文件,用真实数据进行挖矿并获得收益。 以前在一个视频上听到过 Filecoin 的磁盘利用率只有 50% 的说法,但没有很理解为什么那么说。现在才算是明白,Sealing 之前 Sector 会占用两倍磁盘空间的含义。 在这件事情上,最令人惊讶的是 ChatGPT 的能力,其实回头想想,如果有一个经验丰富的 Filecoin 研究者,他很轻易就能够解答我的疑惑。但我经常面临的问题是,找不到那样资深的研究者,我向他咨询问题,他还乐意给我解答。现在我找到了。 2020年12月21日,我在微博上写下这样一句话: 单纯的记忆是没有意义的,把书本和网络上的内容背下来,只能“显得”厉害,欺骗愚昧的人。如果把计算机和互联网理解成另一种形式的生物,它将比任何人类都博学。 没想到,事到如今,这样的 “生物” 真的出现了,ChatGPT 集成了各个领域的专业知识,并且能够以人类的语言进行对话。我以前就知道,单纯提高自己 “死知识” 的储备没有用,总有你无法超越的人,甚至对方不是人。 最近有一个 ChatGPT 超越人类案例:如何看待一男子宠物狗患病兽医难断病因,询问 GPT-4 后获救?内容大意是,由于兽医经验有限,对于狗狗的病因,兽医只冲着 A 方向去诊断。后来主人把狗狗的各项身体指标让 ChatGPT 分析,ChatGPT 给出了 A、B 两种可能,去医院一查,果然是 B。由于 ChatGPT 不像兽医一样受到过往阅历的限制,ChatGPT 显得比兽医更加博学。 随着年龄的增长,我们必须学会抛弃极端的思维方式。我们不能相信 AI 会替代人类,因为现在的 AI 没有智能。我们也不能否认 AI 在某一些方面强过人类,可以作为人类很好的助手。 现在最先进的大语言模型是 GPT-4,需要在 ChatGPT 的网站上花 20 美元一个月开订阅才能用到。免费使用的预览版本是 GPT-3.5,不过 GPT-3.5 也很厉害。当人们以为 AI 聊天只停留在微软小冰和苹果 Siri 那样水平的时候,GPT-3.5 横空出世,广为流传。我以前用 GPT-3.5 也做过一些事情,帮朋友生成播客的好评评论、帮朋友完成专科学校的编程习题作业,等等。 ChatGPT 的翻译能力也是很强的,个别词汇的翻译比谷歌翻译要厉害。比如 “兜底机制” 这个词,谷歌翻译会翻译成 “Pocket Mechanism”,而 ChatGPT 会翻译成 “fallback mechanism”,显然谷歌翻译是直译,ChatGPT 是在理解词汇含义之后再翻译的。再比如 “布偶猫”,谷歌翻译为 “cat plush”,ChatGPT 翻译为 “Ragdoll cat”,高下立判。 不过 GPT-3.5 自诞生就存在一个广为诟病的问题,就是 “一本正经地胡说八道”。作为一个语言模型,程序员应该能比较容易理解,为什么会出现这样的现象,像是套模板,从一堆现成的语言模板里挑出比较好的回答。GPT-3.5 似乎就是这样。也就是说,想靠 GPT-3.5 来获取知识,是有很大风险的,它能告诉你正确的内容,也能把编造的内容当作知识讲出来。 谷歌的大语言模型 Bard 不但综合能力比 ChatGPT 差,对代码的处理能力很弱,而且同样存在 “一本正经地胡说八道” 的情况,还经常 “答非所问”。中国的产品就更不用比了。 与 GPT-3.5 相比,GPT-4 在各方面应该都有所改善,不是很清楚在信息的准确性上具体有多大程度的改善,但对语言的理解能力确实超过 GPT-3.5,比如这里的讨论。GPT-4 用了和 GPT-3.5 不一样的训练模型架构,微软发布过关于 GPT-4 的技术论文。从论文看, GPT-4 是支持多模态的,也就是支持根据输入数据生成统计图之类,可能现在还没有开放出来给公众使用。 GPT-4 的能力过分强大也引来很多人的担忧,甚至马斯克都签署了暂停训练比 GPT-4 更强大 AI 的公开信。没有人知道 GPT-4 的出现,会给人类社会带来有益的还是有害的影响。 目前已经有很多产品接入了 GPT 的能力,微软的 Office 办公套件是首先支持的,毕竟是自家产品。不知道实际使用效果怎么样,也许会带来翻天覆地的变化,很多重复性的工作,GPT 完全具备更出色的处理能力。你不需要知道 Excel 里的某个函数怎么写,那些函数也许专业的程序员都用不来,何况几乎不懂计算机的文员,有了 GPT,用自然语言描述就可以了。以后的计算机教育也许会增加一个门类:如何与 ChatGPTG 高效沟通。 微软的 Bing 搜索引擎也引入了 GPT 的能力,没有 GPT 的 Bing 在搜索结果上是无法和 Google 相比的,有了 GPT,Bing 就有了使用自然语言搜索的能力。New Bing 的方式是,根据用户要搜索的内容,GPT 去搜索一遍,然后归纳总结,再用自然语言描述出来。这样做的好处是,GPT 说的都是正确的内容,缺点是处理过程比较慢。尝鲜可以,对于程序员来说,快速解决问题还得是 Google。 更加值得期待的是 GPT-4 的插件,让 GPT 融入到更多的场景中,比如取代 GitHub Copilot,在生成代码的能力上绰绰有余,只是使用形式上的问题。Copilot 这个东西,一旦用过就回不去了,本以为能够这样自动补全代码已经很先进,没到 Copilot 发布没多久,GPT 就出来了。Copilot 是面临危险的,也推出了 Copilot X 方案,不过我更看好 GPT 的未来。我已经取消了 Copilot 每月 10 美元的订阅,买了每个月 20 美元的 ChatGPT Plus,相信以后 GPT 能大放异彩。 现在 ChatGPT 的使用对于美国之外的用户,还有一些门槛。首先需要能够正常访问 ChatGPT 的网站,然后注册账号需要美国的手机号码,最后订阅 Plus 需要美国的银行卡。经过一系列的折腾,我的建议是直接买现成的账号最划算,150 人民币就可以买到一个独立使用的、开通了 Plus 的账号。否则即使付出很高的成本,也未必能把事办成。 外国的手机号码可以花 300 块钱买 Ultra 实体卡,不需要 KYC,号码是 +1 开头,长期保号,每月 3 美元月租,开了 Wi-Fi Calling 可以免费使用 100 条短信。美国手机号还是比较有用的,可以开美区的 PayPal 账户,美区的 Apple ID 可以用美区的 PayPal 支付。美区的 PayPal 可以绑国内开的 Visa 信用卡。很多 IM 软件也会需要国外的手机号码作为验证。 国外的银行卡可以试试 Depay 虚拟卡,最近因为 ChatGPT 会员的需要发展速度很快,充值加密货币,然后以美元消费。但是 ChatGPT 的订单有一系列的风控规则,IP 地址、订单地址不一致都有可能触发风控,导致订阅失败。只是开通 ChatGPT Plus 的话,苹果手机访问 ChatGPT 能看到 Apple Pay 的选项,有美区 PayPal 也可以试试。总的来说,直接买账号是最快最省力的办法。 另外,最近 AI 领域的文字生成图片也比较火。我试用了比较热门的几个产品。 DALL-E 和 GPT 是同一家,都是 OpenAI 的产品,但是 DALL-E 绘制出来的人物经常是畸形的 Replicate 提供了多种模型的试用,比如 stable-diffusion,同样的问题,生成的人物总是怪怪的 Midjourney 是在 Discord 里面交互的一个产品,生成的图片质量不差,但是图片里的场景都比较小 从我的感受上,目前文字生成图片的效果并不好,而且生成图片的效果很大程度取决于,输入的描述语是不是精确符合模型的规则,希望以后借助 GPT 的自然语言能力,文字生成图片会更加好用。 拥有 ChatGPT 就相当于有了一个空前强大的知识库,它拥有丰富的文字能力,一心一意为你服务,你难道不应该拥有一个吗?

2023/3/30
articleCard.readMore

为什么炒币不是一个好主意

最近很长时间我都被贫穷的痛苦困扰,但我不认为炒币是一个获得财富的好办法。尤其是短线操作,以及各种加杠杆的合约交易。 币圈就像一个充满金钱诱惑的赌场,各大交易所、各种 DeFi Dapp 都是赌桌上的游戏。赌徒也许会赚钱也许会赔钱,但最终赚钱的一定是赌场。很少有赌徒可以从赌桌上全身而退。 比较聪明的人都只是把加密货币作为理财手段,按照投资的性质分配一部分可支配财产进去。长线操作才算是投资,短线就是赌。 造成这样的现象,最根本的原因是,没有人可以预测市场的走向。 很多人喜欢在行情发生变化后,有理有据地分析行情为什么会发生,消息面有哪些利好、技术面有哪些趋势、周期上有什么规律。这些分析最大的共同点,就是全都在事后分析,事情发生前,没有人知道会发生什么事情。看看近一年的几次黑天鹅事件,有哪一次是被经济学家、区块链科学家预测到的吗? 究其原因,没有人可以预测未来。 再就是最简单的道理,如果那些号称量化大师、预测行情的专业人士,真的掌握着财富密码,为什么要说出来呢?为什么唯恐大家不知道呢?说明他们都只是瞎猜而已,扩大知名度能够带来的收益,比财富密码的收益要高。 从投资的角度,炒币能带来的收益是比较有限的,却需要承担非常高的风险。当然和传统的基金和股票比起来,变化幅度要大很多,但是加密货币的行情波动还远达不到,让人暴富的程度。10 年前也许可以,回到 10 年前,你真的敢买吗? 站在历史的高点,看任何事情都轻而易举,可做为历史的亲历者,想超前于时代是很困难的事情,需要过人的眼光和魄力。 另外,加密货币野蛮发展到现在,已经有一点脱离价值投资的范畴,大盘的涨跌、牛熊市的转换,是因为人们对加密货币本身的认知变化吗?至少最近几年不是,大都因为美联储的货币政策。也就是说,现在加密货币的市场就这么大,除非扩大加密市场的容量,不然新出的链只是在和已有的链争市场份额。 所以扩大加密市场的机会,在 Web 3.0 和元宇宙上。要让更多的非加密行业内的人,参与到加密市场中去。 炒币能够稳定获利的方式是老鼠仓,前提要有那种层面的信息源。KOL 在一定程度上是能够影响市场的,如果粉丝数量非常庞大,在 KOL 的言论引导下,粉丝跟风集火到某一个点上,也算是操纵市场的一种方式。这种跟风活动最终获利的肯定也不是后来参与进去的人,自然不靠谱。 至于说为什么需要关心炒币的问题,因为工作本身并不能让你变得富有。打工可以是一个积累财富的过程,但不会出现让你财富突然增长的情况。思考和寻找能带来更多收益的机会是很有必要的事情。 关于企业运行的逻辑,一般开始的时候花投资人的钱,有盈利后再把收益还给股东。企业往往会有多个产品线,只要整体上营收大于成本,企业就能够生存下去。 其实企业在裁员的时候也是充满纠结的,假如因为盈利问题,需要裁掉一个产品线,那么纠结之处在于,如果扛一扛过去这段困难时间,也许未来这个产品能带来更多收益,也许因为没有及时裁掉而带来更多亏损。如果直接裁掉,那么以后再想打造这么一个产品线,又要付出的更多的成本。 还有一个裁员中让人纠结的问题是,一个人在 A 领域打 60 分,另一个人在 B 领域打 80 分,现在因为 B 领域所在的产品线整个裁掉了,B 领域的所有人都要裁掉,也就是说企业裁掉了更加优秀的人。所以裁员这事不好说,和企业的运行情况和战略方向关系比较大。 总的来说,炒币不可取,盲目依赖于上班所在的企业也不可取。

2023/3/15
articleCard.readMore

一种在区块链上生成随机数的机制

区块链本身不允许存在随机数,因为全部节点都需要同步计算结果,如果每次运行的结果不一样,整个网络就会混乱。也就是说,在区块链上,如果有一个函数的作用是生成随机数,这个函数需要符合两点要求:1. 返回值是不可预测的;2. 返回值是确定的。 这两点要求似乎相悖,但目前已经有解决方案了,Chainlink 的预言机提供了使用 VRF 来生成随机数的方法。由于 VRF 的特性,正好适合在区块链的场景中生成随机数。 不过现在要讨论的是另一个问题:矿工作恶。可以参考一下这篇文章的内容:How Not To Run A Blockchain Lottery 假如在区块链上运行一个彩票游戏,而矿工也参与了彩票下注,由于彩票中奖后会获得巨额奖励,只要巨额奖励超过矿工的挖矿所得,矿工就有足够的动机作恶。作恶的方式是,因为一笔交易的执行结果是确定的,而矿工会先知道结果,如果交易结果对自己不利,矿工可以拒绝出块。对于矿工来说,在经济上不一定划算,但是这种情况的存在,不但给彩票游戏增加了不公平的性质,还给了矿工作恶的权利。矿工也有被贿赂的可能性,如果矿工集体作恶,网络就乱套了。 面对这样的情况,我们需要一种方式来生成随机数,要求矿工不能知道这个数字是什么。等区块确认上链后,随机的数字才被公开。 有什么是真正的随机、难以预测呢?未来,未来无法预测。 我们可以尝试使用这样的方式:随机数用本次交易块高度 +2 个块的块哈希作为随机数种子。 比如发起一笔交易,要生成一个随机数,现在的块高度是 1,这笔交易提交后,得到的随机数是 null,真正的随机数将会在块高度达到 3 时才真正显示。因为这个随机数使用 3 的块哈希作为随机数的种子,在块高度达到 3 之前是不可能有人知道,这个数字将会是什么。 在彩票的场景中,抽奖结果在块高度为 1 是已经确定了,用户在块高度为 1 的交易中已经参与了抽奖,只是块高度为 3 时才公布抽奖结果。这样几乎能避免矿工作恶的问题,因为在块高度为 1 时,矿工也不知道结果是什么,在块高度为 3 时,参与抽奖的顺序和结果已经确定了。 那矿工在块高度 3 的时候,不还是可以拒绝出块吗?直接拒绝 1 块或者拒绝 3 块没有差别啊? 这里就需要区分两个情况: 在块高度 1 发起的请求,在块高度 3、4、5 去查,得到的都是以块高度 3 的块哈希为种子,计算出的随机数 在块高度 1 发起的请求,以块高度 3 的块哈希作为种子;在块高度 2 发起的请求,以块高度 4 的块哈希作为种子 要想达到完全随机的效果,应该使用第二种方式。 那问题又来了,当前块高度是不断变化的,这个随机值不就成变量了吗?用户在块高度是 3 的时候生成随机数,然后想知道这个随机数具体是多少,结果永远没办法得到这个值,因为等块高度是 5 的时候才真正产生这个数,而块高度是 5 的时候去查询,要等到块高度 7…… 这里还需要区分两个概念:生成随机数和查询随机数。上面两种情况都是按照查询随机数的机制来描述的。 按照生成随机数的机制来设定,使用第二种方式是必须的,不然随机数就成常量了。那查询呢?不能否认的事实是,只要用户能查到的结果,矿工一定能查到,而且会提前知道。问题回到了一开始的困局,似乎无解了。 不对不对。 为什么要设定为使用块高度 +2,而不是 +1 块高度的块哈希呢?是为了避免矿工提前知道结果,如果是 +1,矿工挖出 1 个块就能知道结果,如果是 +2,矿工一般很难领先网络 2 个块。 所以更好的做法,是在块高度 1 发出生成随机数的请求,然后将块高度 2 和块高度 3 的块哈希,作为随机数种子。在这种情况下,矿工手里的第 3 个块会对随机数产生一定影响,但又不是决定性的影响,有可能第 2 个块就已经确定矿工与中奖无缘了,矿工将没有必要在第 3 个块上进行违规操作。 那如果第 3 个块也能很大程度决定随机数的内容呢?矿工仍然可以拒绝出块。 从这个角度来看,延后的块数越多,矿工的影响力越小。假如随机数由 +10 个块的块哈希决定,前 9 个块已经让所有矿工都出局了,矿工就不作恶了。 还有其他更好的方案吗?好像没有。假如将 +2 个块的块哈希作为对称加密的私钥,在 +2 块产生前随机值已经产生但是无法被解码,问题在于,合约也无法提前把 +2 块作为密钥对随机值进行加密。 只能降低矿工的影响力了。 未来无法预测,但当未来来临的时候,总有人能先知先觉。

2023/2/22
articleCard.readMore

为什么以太坊的私钥计算不可逆

你有没有过好奇,为什么以太坊的私钥无法从账户地址逆推算出来?当你拥有一个私钥,想要得到这个私钥对应的账户地址,你可以在 MetaMask 里导入这个账户,或者使用 ether.js 这样的 SDK 在代码层面导入一个账户到钱包,然后打印出账户的地址。这个导入账户的过程有没有黑箱操作? 我前几天无意中在 Medium 上看到一片文章,作者用很简洁的代码写出了从私钥到地址的计算过程,作者的代码在这里 RareSkills/generate-ethereum-address-lower-level.py。 我把代码复制过来: from ecpy.curves import Curvefrom sha3 import keccak_256private_key = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80cv = Curve.get_curve('secp256k1')pu_key = private_key * cv.generator # just multiplying the private key by generator point (EC multiplication)concat_x_y = pu_key.x.to_bytes(32, byteorder='big') + pu_key.y.to_bytes(32, byteorder='big')eth_addr = '0x' + keccak_256(concat_x_y).digest()[-20:].hex()print('private key: ', hex(private_key))print('eth_address: ', eth_addr) 这段代码只有四五行,计算的关键有两个地方,private_key * cv.generator 和 keccak_256(concat_x_y)。除了这两个地方,其他的部分都是常量计算,乍一看挺复杂,仔细看看拆解一下,都是很简单的字符串拼接。 其中,private_key * cv.generator 是椭圆曲线的计算,上看定义的私钥 private_key 是一个 16 进制的数字,注意是 int 类型,然后用椭圆曲线的生成器去计算出一个值。这个计算过程是不可逆的,就是在椭圆曲线上绕圈那个过程,可以类比理解为,一个数字取余之后,你无法还原出取余之前的数字。椭圆曲线只是用更复杂的方式,提供了比 RSA 更加安全的计算结果。 第二个不可逆的计算是 keccak_256(concat_x_y),这是一个计算哈希值的过程,keccak 是 sha3 的一种。摘要算法不可逆,这也是毫无疑问的事情。 也就是说,从私钥到地址的计算过程,有两处是不可逆的,所以整体上,无法从以太坊的账户地址,逆推计算出私钥。

2023/2/20
articleCard.readMore

关于以太坊的私钥碰撞

如果你有 1/100000000 的机会暴富,你愿意尝试一下吗? 以太坊私钥 以太坊的账户完全由私钥控制,根据私钥可以推算出账户的地址。私钥是一个 64 位长度的 16 进制字符串,比如: 0xd110227375ab838e8743192d278c105e30f253c966987c50b754412c9b986fe3 你可以在任何支持以太坊账户的钱包,用这个私钥导入对应的账户。这个私钥对应的账户地址是: 0x00000006A3D4DA3A559829B1730603CAeE97cC3D 你也可以根据这个账户地址,在以太坊浏览器上查询到账户相关的交易记录等信息。 私钥碰撞 既然私钥只是一个字符串,那么有没有可能,随机生成一个字符转,这个私钥对应的账户地址,正好有钱?因为有了私钥就能够控制账户上的所有资产。以太坊上有钱的账户那么多,有的余额还特别高,万一正好生成了一个私钥,那岂不是直接暴富。 当然随机生成的字符串,必须是 64 位长度 16 进制。为了避免以太坊的 SDK 存在某些防碰撞的规则,我们可以直接粗暴一点生成,比如这样: let s = "0123456789abcdef";let hex = "0x";for (let i = 0; i < 64; i++) { hex += s[Math.floor(Math.random() * 16)];} 这样做足够直观,在字符串层面随机拼接出 64 位长度的。LeetCode 的简单题就是天天搞这种。虽然实际上以太坊并没有防碰撞的机制。 碰撞概率 我们来计算一下,随机生成私钥,碰撞到有效账户的概率。假如存在一个地址,我们想要正要随机生成这个地址的私钥,可能性是多大? 16 进制就代表每一位字符,和目标私钥一致的可能性是 1/16,一共 64 位长度,概率就是 (1/16)64。 p = (1/16)^64 = 1 / 16^64 = 1 / 115792089237316195423570985008687907853269984665640564039457584007913129639936 把零头抹掉,只计算数量级的话: p = 1 / 115792089237316195423570985008687907853269984665640564039457584007913129639936 = 1 / 100000000000000000000000000000000000000000000000000000000000000000000000000000 = 1 / 10^77 这是一个非常低的概率,中国中奖率最低的大乐透彩票,中奖概率是千万分之一,也就是 1/10^8。以太坊的私钥碰撞,想正好是某一个账户的可能性,相当于连续中 10 次彩票。这是几乎不可能发生的事情。 不过彩票的中奖是有速度限制的,比如需要 1 天才开奖。加密货币领域的私钥可不是,如果提高随机生成私钥的速度,能不能增加碰撞成功的概率呢? 目前比特币全网的哈希率是 270M TH/s。比特币挖矿的过程本身就是不断地做哈希计算,直到找到拥有 n 和前导 0 哈希值的字符串,所以比特币的哈希率也可以用来描述私钥的生成速率。做一下单位换算: r = 270M TH/s = 270000000 TH/s = 270000000000 GH/s = 270000000000000 MH/s = 270000000000000000 kH/s = 270000000000000000000 /s = 100000000000000000000 /s = 10^20 这个结果只保留了数量级。假如你拥有全球比特币矿机的算力,去做以太坊的私钥碰撞,每秒钟的概率是: ps = p * r = 1 / 10^57 = 1 / 1000000000000000000000000000000000000000000000000000000000 这仍然是一个低到遥不可及的可能性。如果碰撞持续尝试 1 年呢? py = ps * 60 * 60 * 24 * 365 = ps * 31536000 = ps * 10000000 = ps * 10^7 = 1 / 10^50 = 1 / 100000000000000000000000000000000000000000000000000 持续运行 1 亿年呢? pb = py * 100000000 = py * 10^8 = 1 / 10^42 = 1 / 1000000000000000000000000000000000000000000 可以看到,时间对于概率的增加幅度,是微乎其微的。计算能力也许有可能成倍增长,比如苹果 M 系列芯片,比 Intel 芯片高几倍速度的提升。但是在 70 个 0 的数量级面前,这样的提升仍然微不足道。 即使拥有 1 亿个地球的比特币算力,运行 1 亿年,也只是能再减少几个 1 后面的 0,远远达不到碰撞成功的目的。 尝试一下 虽然可能性非常小,但我还是想尝试一下。 我写了一个脚本 eth-collision/eth-collision-random,随机生成私钥后,根据私钥的账户地址,自动去 etherscan.io 上查询账户余额,如果余额大于 0 会把私钥输出到日志文件里。由于 Etherscan 对 API 的调用次数有限制,脚本会以每秒 20 个地址的速度去查询,也就是一天能尝试 172 万个地址。 这个速度不够快,有更好的办法吗?凡是第三方服务,大多数会有 API 速率的限制,想不受限制进行查询,只能自己运行一个以太坊节点了。以太坊节点最低的硬件要求,是 4 核 16G 内存 1T 硬盘。这样配置的服务器,在 Vultr 上的价格至少要 300 美元一个月。有点贵。而且运行起节点后,还需要自己写一写程序,根据每个块的交易信息统计出有余额的地址。整个周期会很长。 我注意到 这个页面 提供了以太坊上余额最多的地址列表,1 页 100 个,总共 100 页。我写了一个爬虫 eth-collision/eth-address-top-list 到这个页面上抓取数据,这样就能得到以太坊余额最多的 10000 个账户地址的列表。 根据抓到的 10000 个地址,程序就可以比较快地运行了,而不需要受到 API 速率的限制,也不需要自己跑全节点。由于不看好 JavaScript 的运行速度,我用 Golang 写了一个程序 eth-collision/eth-collision-match-address,启动 100 个协程来加快生成私钥的速度。程序会把 10000 个地址读到一个 map 里,然后判断随机生成的账户是否在目标列表内。JavaScript 的一个 for 循环也足够把 CPU 跑满,但是直觉上总感觉 Golang 会更快一点。 对了,Golang 中的 map 记得加互斥锁。因为 map 不是并发安全的。不过加锁会降低速率。为了解决需要加锁的问题,因为只有 10000 个地址,占不了多少内存,所以可以实例化多个 map,有多少个协程就有多少个 map,每个协程在自己的 map 上做验证。这样就能充分保证私钥的生成和验证效率。 10000 个地址是不是太少了?以太坊一共有那么多个地址。一共有多少个呢?从 Etherscan 统计目前有 230M 左右,大概 2 亿多个。 后来我发现了一个仓库 eth-collision/Wallet-private-key-collision-brute-force-tool,这个仓库里提供了一个 OneDrive 文件的下载链接,是一个包含 1.8 亿条账户地址的文件,这些账户都在链上有过交易记录。文件的压缩包有 4.4G 大,解压后 16G 左右,是 pkl 格式的文件,然后我用 Python 解析成了 txt 文件。 在得倒这么多账户地址的列表后,现在的问题就变成了,该怎么利用这些数据。 首先是像原先一样,把所有数据读到 map 里。这么多的数据读到内存里,占用的内存可能至少 16G,也就是需要 24G 内存的服务器。Vultr 上最便宜的符合要求的服务器,接近 150 美元一个月。况且那么大数据量,Golang 的查询效率也是个疑问,还得加锁,限制比较多。 有没有好的方案?用数据库的话,MySQL 是支撑不住亿级别的数据查询的,记得之前参与的一个项目,在上亿数据里做最简单的查询要 10 秒以上,还是单次查询。现在的需求是每秒钟查询上千次。Redis 呢?Redis 的查询效率应该是有保证的,可 Redis 还是会把所有数据读到内存里。在 Redis 的介绍里,1M 个 key 需要 85M 的内存,换算一下: 1M -> 85M180M -> 180 * 85M = 15300M = 15G Vultr 上,16G 的 Redis 价格很高,比服务器贵多了,需要 480 美元一个月。至于更沉重一点的数据库,像 Elasticsearch 或者更专业一点的 HBase 之类,从数据库搭建到导入数据,然后处理数据,一整套下来确实麻烦了一点。服务器开销也不见得更低。 再后来,看到有个东西叫布隆过滤器,正好符合这样的使用场景。我试了一下,容量 2 亿条数据的布隆过滤器,容错率 10 亿分之一,在加载完 1.8 亿条数据后,导出的二进制文件,只占用 1G 的磁盘空间。加载到内存中,也只需要占用 1G。 这样的方案其实挺好了。我在服务器上运行碰撞程序,每个月需要 30 美元的 2核 4G 服务器,每个小时可以尝试 60M 个地址。每个月需要 600 人民币的 8核 16G 服务器,每个小时可以尝试 160M 个地址。 当然,用再好的服务器都没有用,用整个地球上的算力都没用。 靓号地址 以太坊的账号地址也有靓号一说,比如地址 0x00000006A3D4DA3A559829B1730603CAeE97cC3D 包含有 6 个前导 0。关于靓号地址可以参考这篇文章。 我写了这个程序 eth-collision/eth-collision-find-address 来寻找靓号地址,一般来说,找到 6 个前导 0 的地址是非常容易的事情,在 Macbook Air M1 上几分钟就可以。包含 7 个前导 0,可能需要半个小时。包含 8 个前导 0,就需要一两天。 包含有 10 个前导 0 的地址,已经算是非常非常稀有了。不过当然靓号地址没什么用,你一旦知道私钥,这个账号就是你的了,给别人别人也不要。 在线手动碰撞 这是一个在线手动进行私钥碰撞的网页,可以根据私钥计算出公钥,也可以生成随机的公私钥对,并且点击链接能够直接跳转到 Etherscan 检查地址对应的余额。 地址:http://eag.smallyu.net 源码:smallyunet/eth-address-generator 页面是纯前端实现的,可以离线使用,不会有任何安全问题。

2023/1/18
articleCard.readMore

“猜均值的2/3” dApp 游戏设计

猜均值的2/3 是一个经典的博弈游戏,现在把它改造成运行在区块链上的 dApp 游戏,这是游戏的基本规则: 玩家支付 任意金额 参与游戏 计算所有用户支付的 金额总和 计算 金额总和 平均值的 2/3 作为 胜利数字 玩家支付的 任意金额 最接近 胜利数字 则获胜 游戏的奖励规则是: 获胜者获得本轮游戏所有用户支付的 金额总和 合约在每轮游戏抽取 金额总和 的 1% 作为服务费 一个重要的问题是,每轮游戏多少玩家参与?或者说每轮游戏什么时候结束?关于游戏结束的规则是这样: 每一轮游戏,当玩家参与的时候,可以知道玩家是第 n 个参与的 生成一个随机数 x(10<=x<=100) 根据 n 和 x,生成一个随机数的边界 l, r = n-x, n+x 在 [l, r] 的区间内生成一个随机数 y 如果 y == n,本轮游戏结束 还有一点补充规则: 如果一个用户多次参与游戏,以第一次参与结果为准 如果多个用户支付的 任意金额 相同,第一个参与的用户获胜 上面是这个游戏的全部规则。 结束游戏的规则有点复杂,举一个具体的数值,比如有一个玩家,是第 n=50 个参与进游戏的,然后生成了一个随机数 x=10,那么生成的随机数边界就是 l, r = 45, 55。在范围 [45, 55] 中再生成一个随机数,如果正好是 50,本轮游戏结束。 为什么要这样做?因为区块链上的数据是公开的。在原版的“猜均值的2/3”游戏中,玩家选择的数字,相互是保密的。但在区块链上很难做到这一点。所以这里提出的 dApp 版本游戏规则,至少有两点和原版规则不同: 玩家参与游戏支付的金额是无限制的,可以是 0,也可以无限大,而原版规则有上限 玩家数量是不确定的,原版规则是知道有几名玩家参与 这样差异的设计都是因为,区块链上的数据是公开的,所以要引入一些随机的机制。如果没有随机结束的机制,在玩家数量确定的情况下,越是后面参与的玩家,越有优势,因为可以根据前面参与玩家的记录,推算出自己用哪个数字参与更有可能获胜。 当然,最后参与的玩家并不一定有能力扭转局势,尤其是在参与数值有上限的情况下。但如果玩家数量是随机的,就不存在“后面参与的玩家”一说,因为谁都不知道游戏什么时候结束,谁是最后一个玩家。所以即使玩家能够支付出扭转局势的金额,也会被后面的玩家拉下来。 至于原版游戏要求参与数值为整数,dApp 版本的参与数值可以没有限制,可以是小数。是因为原版游戏是人为给出参与数值,而 dApp 版本可以在计算机上自由输入,并且结果是智能合约计算的,不限制精度也没有影响。 然后解释一下游戏结束的规则。这个规则的效果是,能在大多数情况下,让游戏在参与人数为 [10, 100] 的区间内结束。这个区间对应 规则 9 中 x 的范围。 这个规则可以这样理解,每个用户都拥有能力结束游戏,但是能力是随机的,最高 1/10,最低 1/100。如果拥有 1/10 的能力,就是有 1/10 的可能性让游戏结束。 假设所有用户都拥有最高的能力 1/10,那么游戏大概率会在 10 个人参与后结束。每个人触发“游戏结束”这个事件的概率是独立的,不管多少人参与都是 1/10。参与的人数越多,结束的可能性越大,因为不结束的可能性是 9/10 的人数次方。 假设所有用户都拥有最低的能力 1/100,那么游戏大概率会在 100 个人参与后结束。 所以 规则 9 中 x 的范围大概就限定了参与人数的范围。当然,这里的人数范围 [10, 100] 不是严格的概率计算。 按照上面定义的游戏结束规则,这里模拟了 10 万次运行的结果,记录下游戏会在“有多少个玩家参与”的时候结束,代码在这里:guessavg/emulate_tool 这个图的横坐标是参与游戏的玩家数量,纵坐标是在对应的数量上,游戏结束的次数。比如在接近 1 的位置上,有 2500 次,说明在 10 万次游戏中,有 2500 次 1 个玩家参与就结束了。 如果次数不够直观的话,可以看这个比例的图,每个数字上结束的比例不超过 2.5%。越靠前的位置,游戏结束的可能性越大,因为每次都是从 0 开始,前面的位置结束游戏的机会更多一点。 这个比例统计图更能够说明结果,游戏有接近 20% 的概率在参与人数小于 10 的情况下结束,有接近 70% 的概率在参与人数为 [10, 100] 的范围内结束。参与人数大于 300 才结束游戏的概率,只有 0.07%。 虽然这个规则形成的结果,概率上不是完整的正态分布,但是基本上能够满足一开始的需求,也就是让游戏在随机的时间点结束,并且结束的时间不会很离谱,不会导致参与人数太少或者太多,而且还提供了一定的小概率,允许参与人数达到 500 左右。 相信这会是一个合理的设计。 更新(2025.04.22) 一年多前,我写过一个 《“猜均值的2/3” dApp 游戏设计》的设想。 偶然想起来,现在有了功能强大的 ChatGPT 的帮助,让这个游戏在实现上变得简单,合约和前端页面都可以轻松完成。今天只用了 2 个小时,就完成了这个网页的 Demo。 可以通过这个页面访问游戏:https://guessavg.oiia.network/ 有几点需要说明: 目前这个 dApp 小游戏运行在 Oiia Network 上,合约地址 0x6eb07...BA8F1 参加游戏实际上就是调用合约的函数,同时给合约转账一笔钱,例如交易 0x1bfb2...10936 合约代码在仓库 guessavg/contract,理论上可以部署到任意网络,因为 OIIA 不要钱,我就拿来用了。 前端代码仓库在 guessavg/game,和合约代码配合使用。 OIIA 可以在 faucet 领到一些 https://faucet.oiia.network/ 这个私钥有 10 个 OIIA,也能拿来玩一下。但是从游戏机制上,是不允许相同地址重复参与的,需要先把 OIIA 转到陌生地址才行。 fdf0aec857f3ac4fe146e0d00fb3a7a729646a081719df3f4e168a541a21893b 前端网页在添加 Oiia 网络到 Metamask 的时候,会报错,但实际上成功了,我没有深入排查原因。一个月前相同的代码还没报错来着。 Oiia Network 随时会消失。 只是玩玩…… 更新(2025.05.06) Oiia Network 今天起就完全停止运行了,因为我不想为没有人用的网络付服务器费用了。现在这个猜均值的游戏 Demo 已经转移部署到 Base 网络上。 游戏 Demo 地址:https://guessavg.github.io/game/ Base 上的合约地址:0x4BbeE...868D2 需要 Base 网络上的 ETH 参与游戏。 游戏降低了难度,只需要两三个玩家就会结束一轮游戏。

2022/12/27
articleCard.readMore

Proof of Storage/Space/Replication 的区别

时间线 table { display: inline;} 简称全称年份 PDPProvable Data Possession2007 PORsProofs of Retrievability2007 PoSProofs of Storage2009 PoSProofs of Space2013 PoSTProofs of Space-Time2016 PoRepProof of Replication2017 PoCProof of Capacity2017 Proofs of Storage PDP 和 PORs 在 2007 年各自独立地发表,在文件证明的方式上各有优劣,是对同一类问题不同分支的解决方案。 PoS(Proofs of Storage)的概念至少在 2009 年就已经出现,是一种统称,指客户端对服务端上文件进行验证的交互式协议,同时包含了 PDP 和 PORs 的范畴。比如论文《Proofs of Storage from HomomorphicIdentification Protocols》,第一作者是 Giuseppe Ateniese。 由于 2009 年区块链还没什么发展,所以 PoS(Proofs of Storage)和共识机制没有关系,和 PoS(Proof of Stack)也仅仅只是简称撞了,没什么联系。 PDP 和 PORs 属于 PoS 的前身,PoS 把它们用一个名字统一起来了。 Proofs of Space PoS(Proofs of Space)开始于 2013 年的论文《Proofs of Space》,第一作者是 Stefan Dziembowski。 也是巧合,PoS(Proofs of Space)和 PoS(Proofs of Storage)的简称一样,有时候可能会引起混淆。这个小节的 PoS 指 Proofs of Space。 PoS 的理念是,对标 PoW(Proof of Work)。所以从 PoS 开始,就是区块链中的概念了,它是一种共识机制。 PoW 是用 CPU 的算力进行挖矿,PoS 的想法是用磁盘的容量进行挖矿,想办法证明服务器上有某个数值的磁盘空间。 最基础的办法,是客户端生成一个文件,比如 1G,然后发送到服务器上,接着只要验证服务器上保存了那个文件,就能证明服务器确实有 1G 的磁盘空间了。不过这个办法太笨了,不但消耗客户端的磁盘空间,还会给网络传输带来非常大的压力。 PoS 提供的办法是,使用一种 hard to pebble graphs 的数据结构,比如 Merkle hash tree。这种数据结构的特点是,生成上层的数据必须要依赖于下一层的数据。 比如在使用了 Merkle hash tree 的系统里,客户端可以要求服务端,返回某一个上层节点的整条链路,然后自行验算路径是否正确。可以交叉验证多条链路,基本上就能保证服务端的可信了。 Proofs of Space-Time PoST(Proofs of Space-Time)出现于 2016 年,论文标题《Simple Proofs of Space-Time and Rational Proofs of Storage》,第一作者是 Tal Moran。 PoST 是基于 PoS(Proofs of Space)的方案,因为 PoS 可以证明服务器端拥有一定量的磁盘空间,但是不能证明,服务器端的空间容量一直保持在期望的水平。比如,在进行验证的时候,服务器的磁盘空间是 1G,一旦验证结束,服务器就把空间用到别的地方了。再验证的时候,就再生成一遍 1G 的文件,用于验证。 所以 PoS 提议每 1 分钟都进行一次验证,以保证服务器的诚实。这显然不是很聪明的做法。 PoST 期望解决这个问题。PoST 提供的方法是,加大初始化阶段的难度,也就是 PoS 生成文件的阶段,想办法让服务器,必须要足够多的时间,才能够生成文件。 怎么保证需要足够多的时间呢,PoW(Proof of Work)就能够做到,比如计算 2^30 次哈希值,就意味着花费了那么多的时间。 PoST 就把 PoW 和 PoS 结合了起来,在初始化的阶段,让服务器必须消耗足够多的时间,才能够生成文件,然后在证明的阶段,去验证初始化阶段生成的文件。 Proof of Replication PoRep(Proof of Replication)源自 2017 年的论文《Proof of Replication》,第一作者是 Juan Bene。 PoRep 属于 Proofs of Storage 的一种,是 Protocol Labs 的研究成果,Protocol Labs 还开发了 IPFS 和 Filecoin。PoRep 也是 Filecoin 在使用的共识机制。 PoRep 基于 Proofs of Space 和 Proofs of Retrievability,在其基础上增加了一种能力,就是可以区分出服务器端的副本数量。做法也相对简单,就是在生成 Tags 的阶段,给每个副本都带一个唯一标识,让每一份副本都变得独一无二。 因为是去中心化的网络,Filecoin 需要保证整个网络中存在多个副本,如果节点联合起来作恶,之前的证明方式是无法应对的,所以 Filecoin 使用了 PoRep 的共识机制。 2017 版的 PoRep 相对简单,在 2018 版的 PoRep 里,才开始提到使用 Depth Robust Graphs 的数据结构。论文标题是《PoReps: Proofs of Space on Useful Data》,第一作者是 Ben Fisch。 Proof of Capacity PoC(Proof of Capacity)是 2017 年 Burstcoin 区块链使用的一种共识机制, PoC 提供了一种挖矿的方式,产生新的区块需要一个 nonce 值: 1 nonce = 8192 hash value = 4095 scoops 其中 hash value 是使用 Shabal 作为哈希函数计算出的哈希值,每两个哈希值为一个 scoops。 在 0 到 4095 个 scoop nubmer 中随机选出一个,然后和相应的 nonce 结合,去计算出一个 deadline 值。和其他所有节点相比,谁的 deadline 值最小,谁就可以产生新的区块。 PoC 更倾向于一种纯粹的共识机制。

2022/12/20
articleCard.readMore

Proofs of Retrievability 文件证明的含义

PORs(Proofs of Retrievability)的论文和 PDP 在同一年 2007 年发表,论文题目是《PORs: Proofs of Retrievability for Large Files》,第一作者是 Ari Juels。 PORs 不但可以证明文件确实被保存在服务端,而且可以知道文件有没有被修改、删除,这是 PDP 不具备的能力。不过 PORs 的局限性在于,只能用于加密文件,或者说必须对文件进行加密。PORs 实现的关键在于复杂的 setup 阶段。 PORs 的思路是,客户端生成一些随机的编码记录在数组中,这些编码被称为哨兵。比如 [6, 4, 0] 是三个哨兵。 先对客户端的文件进行对称加密,而且必须使用 block cipher 的方式。在加密过程中,把哨兵加入到文件随机的位置。两个哨兵之间,可能隔着 1 个 block 或者无数个 block,哨兵的位置是随机的。 然后把加密后的文件发送到服务端保存。 客户端发起挑战的内容,就是要服务端返回,文件在随机 n 个位置上的编码。挑战中包含哨兵所在的位置。 由于文件是加密的,服务端并不能判断,挑战的位置是原始文件的数据,还是哨兵位置的数据,服务端是难以推测出哨兵位置的。 对于客户端来说,只需要验证哨兵所在位置对应的编码,就可以判断文件是否存在以及完好无损。如果文件被修改或者中间的某些数据丢失,哨兵所在位置的编码一定是大相径庭的,挑战无法完成。 Retrievability 是可检索的意思,在 PORs 中的含义是,对于客户端来说,我可以通过指定哨兵位置的方式,对文件的某个部分验证,比如想验证第 1 个哨兵到第 3 个哨兵之间的文件是否完好,或者去验证第 5 个哨兵到第 9 个哨兵之间的文件,指哪打哪。 至于说为什么一定要对文件进行加密,应该一方面 PORs 依赖于对称加密的 block cipher,另一方面,对于不加密的文件,服务端比较容易推测出哨兵的位置。所以 PORs 只能用于加密文件。 论文中还通过引入纠错码的方式,来增加文件的容错能力。在 setup 阶段,先对 block 加入纠错码,再进行对称加密。引入纠错码的好处,就是在两个哨兵之间,在一定的错误范围内,如果文件有损坏,可以去修复文件。 为什么在 PORs 的机制中,纠错码开始显得重要?因为要和 PORs 的特点 Retrievability 结合。如果文件被储存在多个服务器环境,遭到了不同程度的损坏,因为有哨兵的存在,比较容易从其他服务器中恢复文件的一部分。在这样的背景下,引入纠错码就是对文件容错能力的进一步加强了。

2022/12/16
articleCard.readMore

对 S-PDP 文件证明的示例和解释

我们现在说的 PDP,一般指发表于 2007 年论文《Provable Data Possession at Untrusted Stores》里的 PDP,第一作者是 Giuseppe Ateniese。这篇论文之前,已经有一些文件证明的概念,比如 B-PDP,但是都没有做到,能够保证服务器端保存了文件。 S-PDP 是论文中首次提出的一种 scheme,可以用于客户端确认,某个文件确实被保存到了服务器环境上,这个服务器环境是不受信任的。 PDP 在解决的问题,就是我把一个文件保存到服务器上,不是说服务器告诉我它保存了,我就相信它真的保存了,我需要一种机制,确认文件真的在服务器上了。 PDP 也有很多种类型,有公开的、私有的、静态的、动态的。S-PDP 是比较基础的一种公开验证的 PDP。 同态加密是实现 S-PDP 的关键。 示例 我想举一个简单的例子来说明 S-PDP 的过程。因为论文中不着边的东西比较多,概括性的定义比较多,而且语焉不详,没有提供太具体的实现方式。我是按照自己的理解来解释。 现在客户端有一个原始的文件,内容是: F = 12345 把这个文件分割成小的 block,比如分成 5 份: F = [1, 2, 3, 4, 5] 在客户端这边生成一个随机数组成的数组 W,数组的长度和文件的 block 数量一致。W 的内容一定要是不可预测的: W = [8, 1, 7, 3, 6] 生成同态加密标签分 2 步,首先,我们对 F 和 W 使用加法同态加密,接着,使用客户端公钥对同态加密后的数组,进行非对称加密: T = r[ h(9), h(3), h(10), h(7), h(11)] = [rh(9)) rh(3), rh(10), rh(7), rh(11)] 客户端将会把原始文件 F、同态加密标签 T 一起发送到服务端进行保存,客户端只保留本地生成的随机数组 W,W 是唯一私密不能泄漏的内容。客户端发送完毕后,就可以把本地的原始文件 F 和同态加密标签 T 都删掉了。 当客户端想要验证服务端的文件,由客户端生成一个挑战,比如随机验证第 1 个和第 3 个 block: chal = [1, 3] 服务端在收到挑战后,生成证明也分 2 步,首先,使用客户端的公钥对原始文件 F 的第 1 个和第 3 个 block 进行非对称加密,接着,使用同态加密标签 T 去做减法同态加密: V = [rh(9), rh(10)] - r[h(1), h(3)] = [rh(9), rh(10)] - [rh(1), rh(3)] = [rh(8), rh(7)] 客户端拿到证明 V 后,使用私钥对证明进行非对称解密: sW = r'[rh(8), rh(7)] = [ h(8), h(7)] 可以验证,证明经过解密后的 sW 正对应随机数组 W 第 1 个和第 3 个索引的值。由于同态加密的使用,整个过程中,W 的内容都没有泄漏。 用户只要手里有一开始生成的随机数组 W,在没有原始文件的情况下,就可以验证服务端的文件确实存在。这个随机数组 W 的数据占用是非常少的。而且过程中由于非对称加密的使用,服务端必须同时拥有同态加密标签 T 和原始文件 F,才能够完成挑战。因为 T 是公钥加密的,如果服务端作恶,客户端会解密不出来,服务端挑战失败。 局限性 在 PDP 的过程中,存在个问题:既然 V = T - F,如果服务端事先把整个 V 保存下来,即使删掉 T 和 F,也是可以通过挑战的,而且客户端并不会发现? 这是 PDP 的局限性,PDP 只能保证服务器至少保存了这个文件 1 次(如果一次都不保存,是不能生成 V 的),但是无法保证文件持续保存在服务器上,也无法反复验证证明的有效性。 假如第一次挑战是 [1, 3],服务器通过了挑战并且保存了挑战为 [1, 3] 的证明,那么之后只要是 [1, 3] 的挑战,服务器都可以直接返回已经通过挑战的证明,而不需要对文件进行计算。客户端无法知晓,证明是立即生成的,还是早已生成的。这是所有 PDP 证明的局限性。

2022/12/14
articleCard.readMore

我的加密货币交易机器人

当然不是什么机器人,只是一个简单的脚本。不过值得一说的是,它确实能带来收益。 目前运行了 10 天左右,收益约 5%,在我看来已经是很好的结果,比自己操作要靠谱很多,反正我手动炒币只有亏钱的份。 如果本金是 $100,那么按照 5% 的收益率,一个月可以赚 $15,至少能把开服务器的钱挣回来。能平衡开支也是一件值得开心的事情。 换个角度想,10 天 5%,一个月 15%,一年 150% ……这收益率简直逆天。这还只是用 spot 交易,如果加上杠杆,收益会高很多。 经过最近几天的运行调试,解决了一些常见的错误情况,程序已经基本上稳定了。 原理 https://www.bi123.co/ 这个网站提供了各种加密货币的信号,会在整点的时候,通过邮件发送过去,某一种加密货币是看涨还是看跌。经过很长时间的人为观察,这个信号挺准的,至少在大方向上没有错。 那么脚本在收到邮件后,就根据信号,自动在 Binance 上进行交易。Binance 提供了完善的 API。 脚本会实时推动消息到 Telegram 的机器人,方便查看和关注交易结果。 收益分析 在一个价格波动的周期中,如果能做到低点买、高点卖,那么每一个上升的波段都是收益。无所谓价格最终有没有变高,只要有波动,就能有收益。 bi123 的信号是整点发的,也许会感觉不及时,会错过收益的机会或者带来更大的损失,不过以至少一个小时为周期,也可以防止太小幅度的波动,带来不必要的买卖。 由于各种加密货币的价格涨跌不一致,脚本支持配置多种不同的加密货币,随个人喜好选择。 代码 也没什么技术含量,代码在这里:smallyunet/txbot 2023.01.12 现在已经过去了一个多月的时间,来总结一下我实际的使用情况和结论。 首先是由于犯了很多错误,导致我最近一个月的余额是亏损的,大概亏损 3% 左右。一开始我没有留意,信号是分级别的,有 1 小时级别、4 小时级别、1 天的级别。不同的级别对市场的响应速度不一样。bi123.co 的 “信号趋势” 这个种类的信号,原理是对比收盘价,比如现在的价格是 1500 美元, 4 个小时之前是 1300 美元,那么就发出看涨的信号,就这么简单,就这么暴力。所以不同级别强度的信号,差异很大。一般来说,不推荐使用 1 小时的信号,我也确实在使用 1 小时信号的情况下,亏损率比较高。后来换成 4小时级别的信号,效果好多了。我推荐用 4 小时的信号。 再一个是信号种类。bi123.co 提供了 “信号趋势”、“RSI 背离”、“MD5 短线”、“多空头排列” 等多种信号,我之前只用了 “信号趋势” 一种,比较容易导致对市场的反应不及时。我推荐所有种类的信号一起用。 现在交易机器人脚本是支持配置使用,不同级别的信号强度以及信号种类的,默认配置是我感觉比较好的一种配置。 再看机器人的效果,目前机器人是无法逆市场而为的,也就是说很难在熊市的情况下获利,因为目前机器人只支持 spot 交易,没有添加做空或者倍率的交易方式,后期可以考虑加入。而且机器人会在市场短期震荡的情况下,发生亏损,也就是横盘的情况,机器人不但无法获利,而且会有少量亏损。从这个角度看,机器人似乎很肋助。 但机器人也不是毫无用处,机器人可以帮助你顺应市场,在跌的时候及时卖出,涨的时候及时买入,而不至于发生,跌的时候继续持有、涨的时候没有买入等情况。而这些操作都不需要手动完成,你甚至不需要关心这笔资产的情况,一切都会自动发生,它可以帮助你,不错过任何时候市场的机会。市场下跌及时止损,市场上涨及时跟进,就是这样。 机器人的另一个用处是,支持多币种的配置,可以让你不错过某个币种疯狂上涨的获利机会。市场上经常出现,某一个币种逆势上涨的情况,其他币都没怎么动,某个币拉疯了。如果是手动操作资产,很难不错过这样的机会,但如果是机器人自动批量操作,似乎很容易,也很自然而然可以抓住这样的机会。 2023.01.15 短短两天,市场疯涨,机器人的表现也符合预期,市场拉升,机器人会自动买入,及时跟上收益。这是最近一整个月,在机器人的控制下,我的资产变动情况(纵坐标的单位不重要): 在一段时间的实践之后,我仍然推荐使用这样的机器人。 2023.03.16 经过最近一段时间对机器人的试用,我决定放弃并停止使用这样的机器人。我一共标的了 56 种加密货币,接受它们的信号,并且根据信号自动进行交易,这是最近一个月的资产变动情况: 也许是标的货币的种类太多,导致单种货币投入的金额太小,又或者是其他什么原因,总之由于机器人实际效果的不理想,以及现在我对炒币这件事情的不认可,不再推荐使用这样的机器人。

2022/12/3
articleCard.readMore

对区块链行业的见闻

最近有点迷茫,随便写写,可能比较乱。之前只写过工作方面的回顾,没有概念方面的内容。这一年过的很快,发生了很多事情,又没什么大的变化。 最近一年都是熊市,尤其是五月份之后,区块链行业进入严重的寒冬,没有像之前各种概念层出不穷,似乎没有大事发生,大家都还在想,下一轮加密货币行业的叙事会是什么? ETH 以太坊是加密行业的半壁江山,很多项目都是基于以太坊构建的,很多 token 都在以太坊的合约里,很多概念也都是以太坊提出来的,比如 DAO,这样的体量是不会轻易倒下的,如果倒下那些项目要跟着死。比特币的价值在于开创了整个行业,但未来区块链拓展应用场景和生态,还得是以太坊,以太坊能玩得起来。 再一个是公链的开发周期长,当年以太坊也是苦苦拉投资,“2017 年 Vitalik 来中国学中文”,后来回西方国家开发了两年,EVM 才成型,并且现在几乎已经成为智能合约的标准,各种链都开始试图兼容 EVM,因为智能合约的虚拟机和生态,没有完成度比 EVM 高的。以太坊一开始发布的白皮书,也是以智能合约为卖点,PoS 是后来版本的白皮书才有的。 2017 年到 2019 年是公链爆发的时间段,在共识机制上创新,或者应用场景上创新,现在的很多链都是那个时候留下的,投资人也愿意往底层链上投资。现在发链的比较少,因为竞争力不大,回报周期也比较长。今年的 Aptos 和 Sui 主打 Move 编程语言,共识机制上没有太大创新,我觉得 Move 编程语言也不会很流行。 号称历史上最大升级的以太坊 merge,其实啥事都没有,对于普通用户来说,发生了和没发生一样,一点感觉都没有。合并之后,币价从 1700 跌到了 1400。说好的发行量减少、会通缩、价格会涨,都没有。 不过以太坊合并之后价格没崩,说明以太坊的价值不来源于挖矿。有的人认为挖矿是币价的保障,因为有一个成本在里面。以太坊的事情说明,不用算力挖矿、没有硬件设备做抵押,也能行。 CEX 加密货币的价格和美股关联性很强,涨跌全看美联储的加息幅度和 CPI 之类的指标。不过也有不那么相关的时候,如果用户对美股失去信心,资金就会进入加密货币,如果对加密货币失去信心,资金就会进入美股。 加密货币行业的盘子比较小,好像全部只有几千亿美元,和美股几十万亿的盘子比起来,影响不会很大。有的阴谋论会说,加密货币是美国货币的蓄水池,为了缓解通胀。 这两天 FTX 暴雷,挪用用户的加密货币资产,导致用户对加密货币尤其是 CEX 失去信心,加密货币进入新一轮熊市,以太坊价格好不容易回到 1700 左右,一下打回 1300。各大交易所开始公布资产证明。 交易所里面的交易真的是赌博,我玩不来。我曾经也有一个暴富的梦,拿着 100 美元,开了 125 倍的杠杆,做空 Mask,几秒钟被拉爆仓。后来又拿着 100 美元,做多 Sol,只剩下一半左右。不敢玩了,币圈都是赌狗,涨跌全靠消息,doge coin 全靠 Musk 喊单。FTX 拿的 SHIB 都比 ETH 多。 顺便说一句,凉兮素质很低,马一龙没啥文化,KOL 都是跳梁小丑。孙宇晨其实是看不起这些人的,只是借助他们的流量和噱头。 Finance NFT 的出圈挺突然的,但是现在热度也下来了。看到一篇文章里说,区块链的场景有两种,一种是 web 2 转移到 web 3,那不叫创新,叫链改,另一种是 web 3 原生。本来想着用 NFT 作为艺术品的链上存证,没起来,结果蓝筹 NFT 在链上搞原创,链下搞周边,火了。现在的 NFT 还是投资的属性多一点,靠品牌大和稀有度。 各种 Fi 其实最终都落在 Finance 上,SoicalFi、GameFi、DeFi,都在金融的圈子里。目前还没有靠谱的场景能行得通,最大的 web 3.0 和元宇宙都是概念阶段。国外都没玩通,国内就更不行了,天然抵制 Fi。在这样的背景下,你说绝望吧。 有聊天室和邮箱基于 ENS 的,问题是谁用啊。有小游戏结合链上资产的,玩家挣不到钱,项目方也挣不到钱,那怎么办。 在一些一线机场买 VPN 的时候,发现支持 TRC-20,还挺意外的,原来 Tron 还活着,还有商家在用,倒是给了我下载 Trx 钱包的理由,它真的能用来买东西,不用担心国内的支付方式有问题了。不过遗憾的是,依然是 Finance 场景。 DSN 分布式储存网络有两个头部,Filecoin 和 Arweave。 Filecoin 是链下储存的典型,或者说基于合约的 DSN,链下用 IPFS 存数据,链上做存证、结算、挑战。Filecoin 的学术能力很强,先是做出了 IPFS,在 PoC 方面也基本上是老大,在文件证明的方向有很多创新,超越了基础的 PDP、Proof of Retrivailiabily。在链式的文件证明上都是发顶会的论文。 链下储存的优点是文件可以很大,不会影响到链的效率,但是 Filecoin 广为人知的问题是,数据有丢失的风险,数据没了就真的没了。而且 Filecoin 的节点和 SDK 真的不怎么好用,至少不亲民,据说因为要根据磁盘空间计算算力,故意控制文件的编码速度到一个频率,导致用户的体验非常差,可能要一两个小时才能知道,文件有没有成功储存。现在也少有项目是用 Filecoin 构建项目。 Arweave 是链上储存的典型,或者说基于链的 DSN。Arweave 的创新能力比起 Filecoin 差很多,块结构和 Filecoin 类似,会在块中记录一个 calldata,随机验证上一个块 calldata 前 10% 的内容。Filecoin 的 PoC 共识比较纯粹一点,Arweave 还是要依赖 PoW 挖矿,先做文件的校验,然后 randomX 挖矿。 Arweave 利用经济模型,文件越稀有,挖矿的几率越高,用这样的方式达到文件永久储存的效果,以区别于 Filecoin 的临时储存。Arweave 和 Meta 合作也让 Ar 的价格涨了很多。 链上储存的好处就是,文件永远不会丢。问题是数据膨胀量也比较大,对于大文件的储存可能不是很友好,比较适合存 NFT 之类的数据。以太坊也早就有结合 IPFS 做链下储存 NFT 的方案。 我理解链上储存和链下储存,或者基于链的储存和基于合约的储存,简单的区分方式是,看生成新的块要不要依赖上一个块的文件数据。 有人认为目前的区块链,基本方向还在于分布式计算,至于分布式储存,可能路径不是特别明确。Filecoin 也是在搞一种 actor 的节点,支持 EVM 的执行环境。其实早在多年前《硅谷》的电视剧里,就已经畅想过分布式储存的前景了。 参考之前的思维方式,分布式储存是要做链改,替换掉 web 2 的云服务器市场呢,还是在 web 3 上搞创新呢。 Layer 2 首先 layer 2 还在 Finance 的范畴里。以太坊计划把 layer 2 发扬光大,提高主网的性能,分散主网的计算压力。 目前 layer 2 还没有真正的头部,各个 layer 2 项目是割裂的,相互之间不能交互,对用户的体验是很大的问题。用户需要选一个 layer 2 站队,那么只有一些小项目会放到 layer 2 上。layer 1 的大合约不太可能会放到 layer 2 上。 layer 2 的技术设施也不完善,zk 就不用想了,op 和 arb 相对能用,但是比较中心化,和 layer 1 的交互也比较慢。arb 今年在奥德赛的时候,由于用户的突然增加,gas 费接近主网的 3 倍,属于世界名画了,问题也一时难以修复。 op 的挑战周期比较长,据说是一到两周,对于普通用户有点不能接受,而且问题在于,谁来验证主网上的交易。用户自己肯定不干,交给第三方的话,又要把信任放到第三方上。 state channels 和侧链的局限性都比 rollups 要大,更加不那么安全,op 似乎是从 plamsa 演进来的。 以太坊对 layer 和 sharding 都有很多蓝图,但是就像不知道谁说的,“以太坊啥都好,就是啥都慢”,我们对以太坊的期待要等很久。 其他 DID 几年前就有了,没有项目真正做起来。现在阶段,还是认为小狐狸或者钱包是 web 3 的入口,如果要搞 DID,可能还需要一大波市场的清洗。 Solana 的价格在 FTX 的事件中跌的厉害,Solana 的特点是 PoH 共识,因为经历过之前的几次宕机,大家不是特别看好。 Polkadot 主要是它不好用啊。 DeFinity 看起来挺好用的,理念上也结合了各种链的优点,号称无限扩容,全部数据上链。问题是他没有太大创新,更多的是把各种东西整合到一起。 Conflux 是国内的链,从大学实验室里开始的,在国内根本做不起来,啥场景都没有。 比特币由于耗费大量算力的问题,长期看会被淘汰,也会被历史记住。它最稳,也最不稳。 其他的我也不知道了。

2022/11/15
articleCard.readMore

随机确认块的共识机制

步骤 在一个时间窗口内,一个节点会收到多个通过验证的块,在这些块中随机选取一个作为自己的下一个块。 在下一个时间窗口,节点如果没有收到块,同时发现网络整体的块高度已经高于自己,将丢弃掉上一个确认的块。 如果块高度相同,少数服从多数。 为什么要这样 收到多个通过验证的块 Proof of work 的思路是,在一个时间窗口内,只有一个正确答案,这样就保证了整个网络的一致。换个思路的话,为什么不能在同一个时间窗口内,产生多个正确答案呢? 随机选取一个块 由于网络中同时存在多个正确的块,那么就需要一种机制来从中选取一个,公平起见,就用完全随机的方式。 丢弃上一个块 丢弃块的机制是为了给系统提供自我纠错的能力。Ethereum 的 DAO 攻击事件,需要开发者带领社区对区块链进行硬分叉,说明系统本身是没有纠错能力的,需要人为干预,似乎目前所有区块链系统都是这样。 少数服从多数 在没有交易发生的情况下,有可能所有节点都处于等待的状态,此时因为块高度相同,没有节点愿意丢弃自己的块,整个网络是割裂的。这是一种博弈的状态,所以要按照少数服从多数的原则打破平衡。 一些问题 为什么要随机? 为了避免富有的人越富有,有算力的节点更富有,有财产的节点更富有,公平地给予每个参与到网络中的节点,获取块奖励的机会。 谁来随机? 每个节点各自随机,选取一个块。而不是使用类似 Varifiable random function 的方式,让每个节点都使用确认的结果。 完全随机还是有限制的随机? 完全随机。 完全随机是指,节点收到 100 个块,选中某个块的概率就是 1%。有限制的随机是指,例如,上一个块已经是某个节点提供的,下一个块就不再使用它的块。相应的,块奖励也不会给它。 如果是有限制的随机,已经产生过块的节点,和没有产生过块的节点,就有了不一样的权重,无论权重侧重于哪一边,都不是合理的方式。如果产生过块的节点更有权重,依然会存在富有的人越富有的问题。如果没有产生过块的节点更有权重,网络中的参与者就会疯狂创建新的账户,使用没有获得过块奖励的节点产出块。 随机会带来分叉? 一定会,但分叉是小范围的。如果网络效率很高,那么只有最新的一两个块会处于不确定的状态,等大多数节点确认下来,就成为主流的一条链了。 分叉会一直存在吗? 不会,因为分叉会被淘汰。新加入网络的节点,肯定要选择某一个节点同步数据。对于其他节点,随机选择是指从通过验证的块中选择,通过验证的含义是,历史数据和自己一样。那么当某一条分叉链没有交易产生,由于纠错机制的存在,分叉链会不断丢弃掉自己的块,直到和主网络一致。 如果新节点不同步数据呢? 如果新加入的节点不同步已有数据,而是另起炉灶,那么它需要拉拢超过整个网络大多数节点规模的追随者。比如有 1000 个节点,由于完全随机的机制,它需要另外 1000 个节点,自己的链才会变为网络中的主流。这种做法可以视为攻击网络的一种方式。难度似乎比 51% 攻击还要高。 存在 51% 攻击? 由于块高度优先的方式加上少数服从多数的原则,可能会认为,攻击者甚至不需要 51%,只需要掌控块高度最高节点数的 51%,就可以形成攻击。 这里也许存在概念上的误区,攻击是指颠覆原有的数据而不是产生新的数据。51% 是不存在的。 回滚会带来不确定性? 对于用户来说,确实是不好的体验,可能上一分钟交易已经成功了,下一分钟节点丢弃了块,交易撤销了。 不过好在这种不确定是短暂的,可以认为确认一个块需要两个时间窗口或者更多个。因为即时网络中存在小范围的分叉,最终也会趋于大多数一致。 所以问题变成了客户端确认一笔交易的时机。 未确认的块会造成资源浪费? 一个节点收到 10 个块,确认 1 个,其他的块就浪费掉了。包括反复的纠错,也属于资源的浪费。 PoW 浪费的是算力资源,这种随机确认块的方式,浪费的是网络传输的资源,因为如果一个节点想要自己的块被确认,它首先要让其他节点接收到它的块。那么假如网络中有 1000 个节点,就需要每一轮都把块广播给 1000 个节点。 好在网络传输资源的浪费是有上限的,区块链网络本身就需要把每一笔交易广播到网络中,与其相比,现在只是需要多广播一个块的内容,算不上太大的负担。而且由于是完全随机地选择块,节点试图通过更高的网络配置来多次广播块也是没有意义的。 如果网络异常,节点会丢弃所有块? 如果节点获取到网络中块高度非常低,按照规则 2,是不是就会逐渐丢弃掉所有块? 不会。要区分清楚,从网络中获取到异常信息,和连接不到网络、无法从网络总获取信息,是两回事。假如一个节点的路由表中有 1000 个节点的连接记录,由于网络状况异常,只能连接到 10 个,此时就属于异常情况,节点不应该按照正常的共识流程丢弃块。 有哪些已有的提案? 在这篇论文《Blockchain Consensus Algorithms: A Survey》的第 16-17 页,提到了随机选择出块节点的共识方式,以及基于 coin-age 的选择方式。这确实是一个随机的概念,但论文描述的是随机是指随机选择节点,而不是针对块,并且是在 PoS 的语境下讨论的,出块节点有被选定、被内定的意味。

2022/9/25
articleCard.readMore

VRF + BFT 共识引起交易失败的问题

昨天遇到一个问题,是 Ontology 的节点不出块了。节点使用的是 VBFT 共识,网络互通没有隔离,也就没有分叉,报的错是提案(proposal)过来的块,和预期的块哈希(MerkleRoot)不一样。由于种种原因,昨天的问题没有深入去查,用比较暴力的方法先让网络恢复正常。不过提到 VBFT,我想到了以前公司的一些事情。 我是在之前的公司开始接触区块链的,当时的项目号称自研区块链,也是用 VBFT(VRF + BFT)的共识,不过在共识方面不那么说,叫成 UBFT 还是什么。 我猜测 VRF + BFT 的主意是 Ontology 提出来的,我之前的公司把这种创意抄了过来,模仿着实现了一下。说来讽刺。 主要想说的是之前公司由于对 VRF + BFT 不靠谱的实现,引起的一个隐藏的 bug。那件事情距今快 2 年了,不记得当时为什么没有写博客记录一下,可能是在忙着做 PPT?昨天遇到共识相关的问题,我想起有那么一回事,正好现在有时间有心情写一下。由于过去时间太长了,细节上可能有出入。 背景 先介绍一下那个项目的情况,主打的特点有几个。 一个是异构多链,含义是可以在同一个节点上包含多条异构的链。异构是指一条链可以使用不同的共识机制、基于不同数据库运行起来。多链是指多条链可以在同一个节点上运行,因为觉得一条链不够用,一条链就相当于传统业务里的一张数据表,多条链可以方便地进行数据拆分,在联盟链的场景下更好地支持业务。异构多链,可以理解为把以太坊不同 chain id 的链,用同一个二进制包启动了。现在的某条开源联盟链,还在拿从异构多链演变来的灵活装配作为一大特点呢,猜猜为什么。 再一个是多数据库的支持,同时支持很多关系型数据库和非关系型数据库,做法是针对各种数据库,写数据操作的中间层做适配。 还有就是共识机制方面,基于开源的 Tendermint 项目。Tendermint core 是一个对 BFT 类共识的实现,在那个基础上,做的改动是把轮询选择提案节点,用 VRF 函数,替换为随机选择提案节点。另外还增加了对分层共识的支持,也就是共识组的概念,每隔多少个块换一次共识组,换共识组的方式借鉴 BFT 的流程,保证换共识组过程的安全性。分层共识这个理念也不知道起源于哪儿,可能同时期的项目流行这个? 项目的这些特点都是在我接触之前就已经开发完成的,我也只是有所了解。 我当时遇到的问题是,如果向区块链发送一笔失败交易,节点会立即返回交易失败的结果,然后如果再次发送一笔失败交易,第二笔交易的结果将迟迟不返回,节点不出块了。接下来如果仍然是失败交易,第三笔、第四笔,都会是同样的现象。这个时候,如果发送一笔正确的交易,节点会立即返回结果,之后一切恢复正常。而且这种现象是概率性出现的,并不是每一次失败交易都会引起问题。 前提 首先是失败交易指合约返回执行结果为失败的交易。区块链系统的交易失败有两种,一种是交易不能被执行,另一种是交易能被执行,但是合约中返回了合约层面的失败。那个项目并没有严格区分这两种失败的类型,合约有权限返回交易层面的失败,这其实是有问题的设计。 不正确的交易,将会在提案的时候,被忽略掉,因为不正确交易没有必要记录在区块链上。加上项目对失败交易错误的处理,造成的现象就是,合约执行失败的交易,会被忽略掉。这是前提。 BFT 共识的基础,是投两轮票,最终确定一个块。不管是什么 BFT,在前面加什么字母,不管通过多么复杂的流程决定出哪个节点提案、如何提案,不管对共识的效率做什么优化,是并行提案还是流水线共识什么的,只要是 BFT 类的共识,都是投票两轮。对两个阶段的命名可能不一样,不管是用 proposal 还是 prepare 来描述,都是那样一个过程。 BFT 的流程,是先有一个节点生成一个块,然后把这个块发送给其他节点,如果超过 2/3 节点同意,会进行下一轮投票。第二轮投票如果超过 2/3 节点同意,这个块就算确认下来了。两轮投票是理解 BFT 共识的关键。至于为什么投票两轮就可以达到 3f + 1 的容错效果、为什么至少要两轮,我也不知道。 忽略掉失败交易的操作,是在检查交易的过程中完成的。项目里有两次检查,共识前检查和共识后检查。有一些交易是没办法在共识前进行检查的,比如在合约里进行的写数据库操作,如果共识前就写库了,然后共识失败了,数据不就乱套了吗。所以只能共识后进行检查。这是第二个前提。 第一笔失败交易 我们根据 bug 的现象分析一下,第一笔失败交易的流程是正常的。一笔失败交易进来,在共识之前是不会检查出失败的,所以预提案的节点正常提出了一个块,分发给其他节点,进行第一轮投票。之后正常进行第二轮投票,在确认块的阶段,写入块之前,会进行共识之后的检查,检查过程中发现交易失败,并且这个块只包含这一笔交易,这个块就作废了,没出块。同时,其他节点也都会返回消息告诉提案节点,这个块没出来,这笔交易失败了。所以第一笔失败交易是正常返回结果的。 第二笔失败交易进来,按照同样的处理流程,一切都应该是正常的才对。因为即使是失败交易,即使是在确认块的阶段,如果检查失败了,也会广播处理结果给其他节点。整个协议中投票失败或者落块失败,都是用空消息表示。其他节点不会因为交易失败,就收不到消息苦苦等待超时。那么既然 BFT 协议的流程没有问题,为什么还是出现 bug? 这个时候要提到项目在 VRF 方面的改造。 在 BFT 的协议中,是需要一个节点去生成一个块,分发给其他节点开始进行第一轮投票的。那么由哪个节点来进行这个生成块的操作呢?总不能是同一个节点吧,那就太中心化了。Tendermint 的做法是依次进行,比如有 4 个节点,第一次节点 A,第二次节点 B,这样轮询。 VRF(Verfiable Random Function)做的事情,是改变依次选择节点的方式。因为如果按照顺序来,那很容易预测到下一轮要由哪个节点去生成块,顺序可以预测之后,就存在节点被贿赂、节点被攻击等安全隐患。VRF 的功能是参数相同结果一定相同,参数不同则结果随机。把块高度、投票的轮数作为参数,就可以很好地实现,每一个块都能由随机的节点来生成,无法预测。这个改动也是作为项目的一个亮点的。 不过 VRF 存在一个问题,既然是随机的,那就有一定可能,第一次随机到节点 A ,第二次也随机到节点 A,这样的概率还是不小的。如果节点 A 是恶意节点,然后由节点 A 连续两次生成块,会给网络带来一些负担,虽然不至于破坏网络,但也是一点小小的麻烦。所以项目为了解决这个问题,在 VRF 的基础上加了黑名单的机制。 如果上一轮是节点 A 生成块,就把节点 A 放到黑名单里。如果 VRF 的结果在黑名单里,就再 VRF 一次,避免重复选择相同的节点。 第二笔失败交易 不返回结果 结合 VRF 和黑名单,再来看看第一笔失败交易发生了什么。节点 A 收到交易,会先把这笔交易广播给其他节点,然后打包成块进行投票的流程。此时节点 A 在黑名单里。投票失败后,节点 A 返回失败,并且这笔交易已经不在节点 A 的交易池里了,因为已经处理过了。 那么节点 B 呢?块里面的交易验证失败了,但是交易池里收到的交易还在,因为这笔交易还没有处理啊,处理的只是广播过来的块里面的交易。这个时候是不是应该把交易池里面的交易删掉?对,但是没删。所以造成一个问题,节点 B 被选作生成块的节点,把这笔交易打包了一下,广播了出去。这个块当然也是提案失败的。此时节点 A、节点 B 都在黑名单里。 以此类推,就这一笔交易,一轮下来,4 个节点全在 VRF 的黑名单里。但是对这笔交易结果的返回是没有影响的,因为交易结果在节点 A 的时候就已经返回了。 第二笔失败交易过来了,所有节点全在黑名单里,会发生什么?当然不能选不出节点,节点全在黑名单里,黑名单就失效了。VRF 的结果是哪个节点,就是哪个节点。 分析一下第二笔失败交易。同样是节点 A 收到交易,假如这一次是节点 B 负责生成块,然后这个块验证失败了,节点 B 就会删掉这笔交易,对吧,这个没问题。 注意,删掉交易的同时,通知客户端,交易失败了,返回交易结果。节点 B 被选中,节点 B 生成块,节点 B 返回通知。但提交这笔交易的客户端,连的是节点 A 啊! 第一笔失败交易为什么会收到响应?因为黑名单还没有失效,所有节点都处理了一遍交易,所有节点都返回了一遍交易结果。现在黑名单失效了,只有节点 B 会返回结果,所以节点 A 的客户端收不到交易结果。 那为什么黑名单失效,就不能像第一笔交易一样,所有节点都处理一遍? 阻塞后续交易 接着分析一下第二笔失败交易,节点 C 还有交易啊,节点 C 上面的这笔失败交易还没处理呢,节点 C 就开始用 VRF 选节点了。 刚才提到,此时黑名单失效,VRF 选出哪个就是哪个。刚才选出了节点 B,这一轮有没有可能再选一次节点 B?黑名单失效,就变得可能了。这个时候如果又是节点 B 负责生成块,会发生什么? 节点 B 生成不了块,因为节点 B 已经没有交易了,它已经把唯一的失败交易,在上一轮就删掉了。也就是说,在新一轮的共识过程中,4 个节点全部在等节点 B 生成块,节点 B 自己也知道该自己了,但是节点 B 拿不出块,节点 B 直接放弃这一轮共识,进入下一轮,并且节点 B 没有发出任何消息。 在分布式系统中,没有消息是一件可怕的事情。其他节点都在等节点 B 呢,节点 B 自己玩了。这个时候,节点 B 的轮数要比其他节点快一轮。 在 BFT 共识中,有两个索引值,一个是块高度,一个是共识的轮数。同一个块高度,有可能因为块没确认,就经过很多轮共识。由于节点 B 自己没生成块,轮数增加了,其他节点还不知道。 现在,所有节点都在 VRF 的黑名单里,节点 B 的共识轮数高于其他节点, 如果节点 A 再收到失败交易,有两种情况。一种情况是 VRF 又选中节点 B 了,节点 B 提出的块会被拒绝,因为其他节点还在等节点 B 上上轮的块,它拿出了高轮数的块,是对不上的。另一种情况是,VRF 选中了其他节点,那其他节点首先要等节点 B 上上轮的出块超时。超时之后,其他节点把轮数最高的数值同步一下,共识就算恢复正常了,然后 VRF 再选。 但是注意,这可是一笔失败交易,此时黑名单仍然失效,即使共识恢复正常,也还是有概率重蹈整个覆辙,节点 A 仍然收不到交易结果。至于具体的概率是多少,就懒得算了。 总结 可以看到,这个 bug 是由很多系统性的不合理设计共同造成的,直接原因在于 VRF 黑名单的失效,因为所有节点都在黑名单里了。或者说,黑名单没有及时清空,原先的错误之处在于,只有在块高度变化的时候才清空黑名单,可能是认为每个块的产生都应该由不同的节点来处理。这种想法的失误在于忽略了相同块高度的时候,共识的轮数也会发生变化,每一轮都会产生一个新的块。所以只要在共识轮数发生变化的时候,也清一下黑名单就好了。实际的代码改动只有两行。 上面写的东西,可能我自己也不想仔细去看,不好理解、抽象,而且文字的表达能力也弱,看起来费劲。这种类似状态机状态转换的文字描述,看起来是很痛苦的事情。尤其是内容和当时项目的耦合很深。总的来说,对于这个问题的分析和解决,我认为在逻辑上是自治的,能很好的解释成因和现象,以及用最简单的方式在表面上修复它。 时隔近 2 年的时间,我竟然还能记起来这些,感觉也是很奇怪。

2022/9/3
articleCard.readMore

为什么要重视编程思想

(1) 前两天遇到一个小问题,Solidity 写的智能合约超过 24 KB,不能部署到以太坊主网上,因为 EVM 对合约的代码小小有限制。于是考虑怎么减小合约的大小,当时对合约大小的概念都是模糊不清的。 其中注意到一个地方,合约是可以引入其他合约、调用其他合约方法的,只需要把部署后的合约地址作为参数传到合约里: contract Demo {}contract Main { Demo demo; constructor(Demo _demo) public { demo = _demo; }} 合约大小包括引入的合约吗?EVM 在执行合约的时候,会不会先把其他合约的代码也加载进来,然后一起运行?代码大小的计算要包括所有合约?那可就麻烦了。 后来注意到,可以使用接口替代合约: contract IDemo {}contract Demo is IDemo {}contract Main { IDemo demo; contract(IDemo _demo) public { demo = _demo; }} 接口的代码量一定是少于具体实现的,因为接口不包含方法体,把引入的合约全部替换成接口,合约不就小多了? 当然,在这里纠结的不是 Solidity 合约怎么写或者合约代码大小怎么计算的问题,后来搞清楚了。比较在意的是,那个时候突然有点恍惚,用接口和直接用合约,有什么区别? 之前给合约定义接口是为了提供一个对外方法的描述,这里才意识到接口可以替代合约本身,直接用来定义变量,并且使用接口定义的变量,去调用合约里面的方法。但为什么可以呢,它不就只是一个接口吗? (2) 如果你刚学习过 Java,或者使用 Java 作为工作语言,一定会有哑然失笑的感觉,这个问题太幼稚了,这不就是多态吗? 上第一节 Java 的课程,老师就告诉我们,面向对象有三大特性,封装、继承、多态,这句话时至今日我都能想起来,这是多么基础的概念,结果在工作多年后的今天,我竟然在实际工作上因为如此简单的问题犯了难,一时没反应过来,用接口作为类型的写法是什么意思。这太荒唐了。可能也是因为很久没写 Java,现在一直在用 Golang。 不得不说 Java 是面向对象编程语言的标杆,Solidity 虽然是一种看似新的用于智能合约的脚本语言,揉杂了多种语言的特性,但基本的编程思想还是基于面向对象的。合约就是类,部署一个合约就是实例化了一个对象,合约地址就是对象的内存地址,合约调用就是对象的方法调用…… 只要是支持面向对象的编程语言,就包含有面向对象的特性,就可以使用面向对象的写法,就离不开最基本的像多态一样的特性。从面向对象的角度去理解,Solidity 有什么难的呢?无非不就是换了一些表面上的形式,编程思路甚至可以一模一样,此外再添上一些区块链特有的概念,像转账、块高度之类,就没了。 从编程语言的角度看,Solidity 和 Java 那样成熟的语言自然没法比,面向对象的特性是残缺的,modifier、require 之类的写法看似好用却增加了很多理解成本,而且代码结构也变得不是太统一。EVM 怎么能和 JVM 相提并论呢?但作为一种轻量级的脚本语言,Solidity 又要使用静态类型那样冗余的写法。 当然要注意,编程思想是先于编程语言的,我仍然会认为形式上的编程语言不值得学习,但是不否认从学习编程语言的角度入手去学习编程思想。比如多态这个概念,含义是使用统一的符号去代表不同的类型,包括三种类型的解释,一是支持多种类型的参数,对应 Java 里方法的重载,二也是支持多种类型的参数,对应 Java 里的范型,三是子类型,也就是把接口作为类型,对应上面提到的场景中的多态的含义。 面向对象是一种编程思想,包含很多计算机科学的概念,而 Java 是一种完全的面向对象的编程语言,不但涵盖众多有用的特性,而且实现的完整漂亮,如果你学习了 Java,自然也就知道面向对象是怎么回事了,受用无尽。从这个角度看,和 Java 相比,Golang 有什么值得学习的地方吗?是 struct 的写法还是 * 号的用法?可能 Golang 更像是一种快餐式的语言吧,可以很方便地 go func()。不过要是为了学习,就不是太推荐了。 花了几分钟看 Java 文档的目录,倒是能很快想起来那些内容,毕竟实在是太基础了。也是要告诫自己,别忘了代码怎么写。

2022/7/24
articleCard.readMore

对 Web 3.0 的理解

Web 3.0 开发 Web 3.0 是一个几年前就存在的概念,可能随着区块链的推广越来越有名了。当人们还不知道 3.0 版本的 Web 会是什么样子的时候,区块链出现了,尤其是以太坊的 dApp 提供了一种很大的可能性,于是 Web 3.0 就和区块链、去中心化、自我主权这些概念绑定在一起。 前几天提到说。「Web 3.0 开发」是可以作为一种职业定位去描述的,而且这个词可以涵盖区块链开发的范畴,立意比「区块链开发」这个描述高一点。 Web 2.0 时代,我们说的 Web 开发指普通的前后端开发,前端用所谓的三大框架 React.js、Vue.js、Angular.js 结合组件库,后端用 Spring 全家桶,加上各种中间件 Zookeeper、Kafka、Elasticsearch 之类,还有常用的数据库 MySQL、Oracle,就是 Web 开发的常用技术栈。 Web 3.0 开发的技术栈,可能会演进为 Remix、Hardhat、Ruffle 这些智能合约的开发工具和框架,人们像关注 Java 的语言特性一样去关注 Solidity,以及各种区块链节点的搭建运行调用、二次开发,甚至及区块链节点本身的开发等等。当然,很难简单地把这些技术栈去和 Web 2.0 一一对应。一个简单的例子是,当你从事区块链开发的工作,你已经很难用前端开发或者后端开发来形容自己的工作内容了,就只能是区块链开发,或者智能合约开发,或者其他的描述方式。 现在已经有一些岗位在用「web3 开发」的形容了,不过我们要区分清楚 Web 3.0 和 web3 不是一回事,目前很多岗位说的 web3 指以太坊的那个 web3 框架。我们需要一个新的描述,同时我们需要有一个更好的、更有前景的职业定位,那就是 Web 3.0 开发。 我这里想说的是,要相信我们走在正确的道路上。 一级市场和二级市场 区块链的一级市场,指比特币、以太坊这种原生的链。二级市场更多是基于这些链,衍生出的一些项目,基于以太坊的项目尤其多,Layer 2、预言机、NFT、ENS,都属于二级市场。 有一些团队是做一级市场也就是区块链开发的,Solana、Filecoin、Neo 都是在以太坊之后出现的,Dfinity 的 IC 也是处于活跃的一种一级市场的例子,再比如像 Bitcoin SV 是在做 “支持智能合约的比特币” 这样的事情。 还有很多创业团队是在做二级市场,比如有从 360 出来的去做智能合约的安全,把合约扫描一遍报出安全漏洞给你;还有做 NFT 的交易协议,去定制一些类似 NFT 交易所的 API,想建立通用的交易网络;还有炒元宇宙概念的,给虚拟人物定制不同样式的衣服;也有基于 IC 做去中心化邮箱的等等。以太坊的各种扩容方案当然也算二级市场,OP 前段时间还发行 token 了。 从商业角度没有什么高下之分,从技术角度也不好说简单和难,不过我觉得还是一级市场更基础一些,但是技术上的发展相对缓慢,花样没那么多。具体倾向于哪一种看个人意愿了,这里想提醒的是,Web 3.0 开发是统称,要了解这些不同层级市场的区别。 去中心化是历史的倒退 刚才说希望 Web 3.0 是有前景的方向,这个部分想说的是 Web 3.0 的前景也没有那么好。 想到这个话题是在关心钱包安全的时候,意识到一个问题,就是账户的私钥一旦泄露,你就永远失去了对账户资产的控制权,或者说别人永远拥有了你账户资产的控制权。 因为我们知道,账户地址是可以从私钥解码出来的,私钥就是你的资产,在备份钱包的时候,备份的就是私钥。你的私钥泄露,就相当于把金钱摆到别人手里,至于别人会不会及时拿走,你能不能在对方动手之前抢回来,那就是另外的问题了。 这个和传统的账户模型是不一样的,你不可能说你的用户密码是你的财产,因为中心化账户是基于 KYC 的,你只要能证明自己的身份,身份证或者指纹或者长相,都可以找回你的财产,因为财产是和你绑定在一起,而不是你的账户,你的账户密码是可以修改的,即使泄露,别人也只能在短时间内拥有你账户的控制权,你把密码改掉,别人就没办法了。 私钥是不可能更改的,你能做的,就是及时把资产转移到另外的私钥。去中心化的世界有意区分了身份和数字身份的概念,增加了数字身份的主权,但同时也削弱了身份对数字身份的控制能力。 这里衍生出的问题就是,去中心化的资产安全吗?把钱拿在自己手里,比把钱存到银行,更加安全吗?考虑到比特币诞生的背景,是出于对中心化机构的不信任,才有了去中心化的理念。 想想吧!一开始就是没有中心化机构的,人们以物换物,打一开始,就是去中心化的世界。后来为了降低个人保护自己财产的成本,为了增加对坏人作恶更有力的惩罚机制,人们共同组建起中心化机构,保护大部分人的利益。 现在炒作去中心化的理念,不正是一种历史的倒退吗?去中心化并非新产生的事物,而是早就已经存在的、被人们选择性抛弃的东西。 不过现在的去中心化和以前的去中心化,最大的不同就是现在的技术手段更为先进,有可能做到之前做不到的事情,把世界推到一种新的愿景上。但是也要注意现在的技术不是那么先进,还远没有发展到那种程度,区块链的技术瓶颈非常多。 所以我的观点是,现在的去中心化理念不是中心化世界的演进,而是中心化世界的补充。在接下来的时间,中心化和去中心化会同时存在。 要注意的是,去中心化不等于 Web 3.0,Web 3.0 是 Web 2.0 的演进,因为版本号增加了。Web 3.0 将是中心化和去中心化同时存在的时代。 LUNA 归零 前段时间有一件搞笑的事情,有一天,LUNA 的价格早上还是 80 美元一个,晚上的时候就跌到 1 美元一个了。在接下来的三四天,LUNA 的价格从 1 美元,跌到了 0.00001 美元。几天之内,近万倍的跌幅。曾经号称前十的加密货币,突然归零了。 我粗浅的理解是,UST 有一个交易池,当短时间有大量卖出的时候,交易池会有小幅的倾斜。当时先是小幅的波动,然后随着社交媒体的传播,大量散户失去了对 UST 的信任,开始大幅卖出,越卖价格越低。Terra 团队是有 5 万个比特币作为储备的,当时也及时打进去想把平衡拉回来,结果比特币也在跌,质押进去的比特币在结算的时候已经不值预期那么多钱了,没能把价格拉回来,后来 Terra 团队也放弃了,任由价格下跌。 LUNA 的事情发生后不久,看到有的人讨论说,LUNA 还有机会吗?有一种机会是,Terra 团队还有 20 亿,等 UST 的价格跌到总市值小于 20 亿的时候,Terra 团队可以把市场上所有 UST 都买下来,销毁掉多余的 UST,只留 20 亿个,UST 的价格就可以回到 1 美元了。不过 Terra 团队可能没打算那么做,后来发行了新的 LUNA。 LUNA 的失败不意味着算法稳定币的失败,有的团队也在研发新的算法稳定币,据说是想把美联储的运行模式,用算法模拟出来,正在写白皮书。 在 Web 3.0 宏大的时代背景下,LUNA 的事情就算是先行的笑料吧。 Web5 最近新出一个 Web5 的概念,就是 Web 5.0 的意思。提出这个概念的人说,跳过 Web4 是因为 Web2 + Web3 = Web5。好家伙,不愧是 Web5,提出 Web5 的能是一般人吗?但凡对软件工程有了解的敢这么说? 简而言之,我的结论是,Web5 一定不会成功。不管它叫 Web5 还是 Web6、Web7,它的理念还是围绕去中心化、SSI 那一套,还在我理解的 Web 3.0 的范畴之内。如果认真了解过 DIDs 的理念,就知道现阶段所谓的 Web5 完全是噱头了。

2022/6/19
articleCard.readMore

GitBook 好用吗?

10 月 15 日,我写下一句话: 用 cloudflare cloud 的 DNS,把子域名 gub 从原来 CNAME 到 gitbook 改为指向到 github,到现在已经超过 72 小时,开启 DNS proxy 的情况下依然跳转到 gitbook,看样子是一个 302 forward。 不是 cacheing 的问题,已经很多次打开 dev mode 并且 purge 所有内容了。猜测 cloudflare cloud proxy 服务对于 forward 记录的更新非常慢,甚至有 bug。现在经过的时长一定超过 TTL 了。 一个月过去了,问题无意间得到了解决。想展开详细描述一下我遇到的问题。 背景 这里会涉及到两个概念,DNS(Domain Name System)和 CDN(Content Delivery Network)。如果你不太接触 Web service 领域,可以先了解一下它们的联系和区别。 问题 一开始的时候,我计划写一本开源书,选择 GitBook 作为写作平台。GitBook 名声在外,又有 GitbookIO/gitbook 那样广为人知的开源渲染工具,是开源书的不二选择。经过短暂的试用后,在平台的使用上没有感觉到异常。我创建了 Workspace,然后在 GitHub 上新建仓库,把仓库关联到 GitBook 上,一切都很顺利。我简单测试了一下 GitBook 和 GitHub 自动同步的能力,有可能会出现一点点冲突,但还是容易解决的。 我在 GitBook 上绑定了自定义的域名。smallyu.net 这个域名托管在 Cloudflare 上,子域名 gub.smallyu.net 也是在 Cloudflare 上设置 DNS 记录。全世界都知道,Cloudflare 会提供免费的 CDN 服务,只要在 DNS 记录上打开 Proxy 的橙色按钮开关就可以了: 当时在解析到 GitBook 的时候,开关是打开的。之后没几天,正好遇到了 GitBook 改版大升级,写作界面完全改变了。改版后一两天,我想要更新一些页面的内容,发现改版后的 GitBook 操作流程反直觉、bug 满天飞,每次修改都相当于 Git的 Pull Request,而且每次点编辑按钮,都会新增一个 Pull Request 的条目。当同时存在多个 Pull Request 记录时,页面状态会完全不可控,这个是 1、那个是 2、另一个是 3,还不能增量合并,因为你无法区分两个 PR 之间,一些内容是没修改过还是被删除了。重点在于,PR 没有删除选项,稍微有点强迫症都受不了。网速不好的时候,多刷新两下编辑页面,草稿箱就会多出好几个 PR 的条目,还不知道哪个是刚刚修改的。包括一些其他使用体验上的小问题,当时我还吐槽说: 现在 gitbook 的在线编辑难用过头了,不能删除 commit,不能新建文档,光标会自动跳转……他们是怎么对用户负责的。 后来决定放弃 GitBook,换成了 docsify,页面部署在 GitHub Pages 上,sub.smallyu.net 域名的解析也换到了 GitHub 上。更改 DNS 的解析记录后,发现解析没有生效(Cloudflare 上的 CDN Proxy 开着),访问 https://gub.smallyu.net 总是跳转到原来的 GitBook 页面上。 一开始怀疑是 DNS TTL 的问题,因为在 Proxied 的状态下,TTL 的值只能是 Auto。毕竟 Cloudflare 的 CDN 节点多,我的域名访问量又低,可能 DNS 记录更新比较慢。幸幸苦苦等了 3 天,这个时间足够长了,发现解析依然不生效,因为域名还是跳转到了旧的页面。 dig 域名的记录,是这样的结果: gub.smallyu.net.300INA104.21.81.212gub.smallyu.net.300INA172.67.146.253 此时域名是查不到 CNAME 记录的。对比之后,发现这就是 Cloudflare CDN 的 IP。域名已经解析到了 CDN 上,问题是 CDN 没有返回预期的新页面的内容。 然后偶然发现,把 CDN Proxy 关了,域名解析正常了,A 记录是 GitHub 的,CNAME 也是 GitHub 的,页面是新的。 是什么问题呢?Cloudflare 的 CDN 没有刷新内容。 Cloudflare 有一个 Caching 的配置,也提供了 Purge 的能力: 在点过很多次 Purge Everything 的按钮后,CDN 内容仍然没有刷新,即使打开其他人都说有效的开发者模式,也是徒劳: 包括自定义页面规则,不走任何缓存,也无济于事: 甚至为了让内容更新生效,我在 GitBook 上删除了原有的 Workspace,还注销了账号。仍然没有用。 之后就不了了之了,只要不开 Proxy,域名解析就是能用的。不过,我当时认为可能是 CDN 的 bug,也许 GitBook 用了 302 forward 之类的记录,CDN 不能正确刷新这种类型的记录。 原因 最近,在访问 https://gub.smallyu.net 的时候页面稍微卡顿了一下,想起这个页面是没有走 CDN 的,想到了 Cloudflare 上的这条不正常的 DNS 记录。顺手 Google 了一下相关问题,没想到这次找到了有用的信息。之前遇到问题的时候也在网上搜过,搜出来的全是更新缓存之类,这次却找到了不一样的内容。 Cloudflare 有一些 partners,这些 partners 有着控制 Cloudflare DNS 的权力,Cloudflare 的域名在解析到 GitBook 上后,CDN 的 DNS 就受 GitBook 控制了,在 Cloudflare 上的配置优先级低于 GitBook 上的配置。 相关问题的链接: DNS subdomain no longer works nor redirects to anything Subdomain CNAME does not update 我发邮件给 GitBook Support: 没想到 GitBook Supoort 一天之内就回复并解决了问题: 经过测试,现在一些正常,确实是那样的原因。 教训 谁能想到,Cloudflare 如此广泛使用的服务提供商,会把域名在 CDN 上的解析权限交给 partners。 谁能想到,GitBook 的产品即使用户删除了 Workspace 注销了账户,在系统内的域名解析记录都不会被删除。 这件事情可以带来的启发是,我们应该从普通的用户思维转变为开发者思维。也许在某种观念的影响下,因为所谓 “官网”、“权威” 的概念,当使用一些平台的时候,我们习惯于首先质疑自己的使用方法和操作错误,却很少质疑平台的问题。即使明确是平台的问题,也不会优先试图联系平台方解决问题。从这个点其实可以发散出很多内容,以后有机会再展开。 工具 最后现在用 Rust 团队开发的 mdbook 了。GitBook 稍微有点过时、处于不怎么维护的状态。docsify 也有问题,docsify 更适合项目文档,除了样式不那么凸显文字外,页面和页面之前是没有关联的,没有上一页下一页的跳转链接,不太像是一本书。虽然 mdbook 的样式没有很时尚,但是功能齐全完整、编译速度能感受到的快,好用就行。

2021/11/21
articleCard.readMore

一种区块链节点存储扩容的方式

区块链是天然支持水平扩容的系统,节点扩展能力首屈一指。 但区块链的垂直扩展能力还是一个经常被讨论的课题。单个节点的硬盘容量总是有限,如果节点拥有全部的数据,对单机性能要求会比较高;如果节点没有全量数据,就不能认为是 P2P 网络的节点之一。 最直接的办法就是用分布式数据库,数据库本身就支持扩容,区块链节点的存储模块就也算是支持扩容了。(如果区块链在立场上和数据库没有冲突的话。) 这里描述一种简单的实现思路的设想。 节点的垂直扩展,是想用多个节点合力代替原有的一个节点的位置,整体形式上一个集群提供了和单个节点一样的输入输出。 节点完全可以将块数据分散储存在不同的子节点上,比如按照数据库分库分表的经典思路,对块号取模,或者随机分发也行。 节点可以区分为索引节点和存储节点,索引节点只记录块号和子节点的对应关系,子节点集群就作为索引节点的储存模块。索引节点同时负责发送和接收块等操作。除了网络延迟带来的存取速度的降低,似乎没有大碍。 对于节点类型的问题,是必须要有不只一种类型的节点吗?有没有办法实现只要一份源代码、只有一种二进制程序、只用一种类型的节点,就能实现所有的功能?当然,不是说把三种类型的节点打包到一起就行了。由于功能侧重点的不同,尤其是节点 “身份” 的不同,可能节点不得不区分类型。一个节点对外提供能力和一个集群合作对外提供能力,集群内的节点和单个节点应该是一样的地位吗? 将块数据分散开后,对于 “世界状态” 一类的数据,可以全部储存在索引节点上。 如果状态数据也想扩容,同样可以只在索引节点上保留索引数据,然后将状态数据也分散到储存节点上。 这样的方案也许过于简单了,万一行之有效呢。

2021/11/10
articleCard.readMore

一种基于“自我中心主义”的共识机制

简介 “自我中心主义” 的含义是,对于每个人来说,世界的大小取决于他能够接触到多大的世界,世界很大,和我无关,世界很小,全和我有关。一个人认识的人、了解的事、接收到的信息,无论是否命中注定,一定是有限的,你不可能认识世界上所有的人,知道世界上所有的事。才学渊博、见多识广,又怎样呢。 在区块链中,共识机制是用来保证数据一致性的关键手段,也给区块链带来了最核心的去中心化的特点。共识机制是强一致性的,或者是在拥有一定容错能力的情况下,达到大多数一致的效果。有没有一种可能,存在一种共识机制,不以数据一致为首要目标呢? 世界本就是复杂的,试图将所有节点同步到仅仅一种数据状态,其实是违反直觉的。而且,无论是不需要授权的大范围共识,还是基于身份授权的小范围共识,最终实现数据一致的方法,都是 “多点变单点”,也就是同一时间只有一个节点在处理数据,其他节点可能是在共识后接受满足条件的数据,也可能在确认数据前投票是否同意对数据的操作,总之都需要有一个 “英雄” 一样的节点,在关键的变更数据的时刻,做一些事情。 有的英雄实力强大,先斩后奏,改过数据后过来跟你说,“我改了数据”,你一开始不满意,但是接触后发现英雄确实厉害,能做出你没有解决的难题,于是你就认可了英雄的行动。 还有的英雄被公众授予权力,行动之前作为代表被选举出来,行动的时候会万分小心,挨个问民众,“改动这里的数据,你同意吗?” 如果大多数人同意,英雄就会行动。 当然,每个人都有平等地享有做英雄的机会,虽然有的人天生神力,有的人八面玲珑,但机会总还是有的,题放在那里,你做不出来,怪谁?每个人就可以被选举,别人不选你,怪谁? 所以,为什么我们不能做自己的英雄?为什么我们要屈就于别人的光环之下?每个人都是自己的英雄,在我们的世界里,在大小受限于个人接触范围的世界里。一种共识机制,节点的边界受限于其触及的网络规模。 网络概况 在非结构化的点对点网络中,路由表是必不可少的组成部分,节点能够接触到的网络大小,就取决于路由表中存着多少 “联系方式”。共识对数据的处理,就以路由表中的节点为依据,路由表中有 10 个节点,就争取和这 10 个节点达成一致,路由表中有 10,000 个节点,就和 10,000 个节点达成一致。也没有必要使用分布式路由表,就普通的数组就可以。在这种情况下,网络中的节点会是这种样子: 以当前节点为中心,连接到的节点数量可多可少,有的很远,有的很近。弱水三千,只取一瓢。在路由发现的问题上,节点也是需要种子地址的,比如节点启动的时候先解析种子地址的记录: lookup("seek.domain") -> 127.0.0.1 -> 127.0.0.2 然后依次请求解析出来的节点地址,去得到他们路由表中的内容,将其添加到自己的路由表。这其实是常规做法,不过这样有可能引起的后果是,节点会瞬间获取到整个网络的路由信息。这并非不好,只是感觉有点快了,我们认识一个人是需要时间的,和人交谈也是需要时间的,你无法同时和三个人交谈,或者无法同时听三个人说话。即使拿到了很多人的联系方式,也没办法 “多线程” 联系每个人。我们处理信息的 “带宽” 有限,节点也一样。我们甚至可以对网络发现的速度稍微做一点限制,比如串行处理路由表新增记录的动作,先与节点建立连接然后再添加信息。 让路由发现慢一点,似乎听起来不太正常,难道是想让网络处于不同步的状态吗?很多共识的瓶颈就在网络带宽上,就在协议交互的复杂上。如果降低节点间的交流成本,共识的容错能力也会随之降低。网络可能会被划分为不同的区域,可能形成大大小小的圈子。 对于共识算法来说,脑裂是要尽可能避免的问题,但其实网络分割是再正常不过的事情,是自然存在的情况。我们人类的思想是分裂的,有可能是对立的,但是经过一些事件后又可能达成一致。所以在一个网络中出现割裂是完全允许的情形,形成的小规模网络可能是互联互通开放的,也可能是保守封闭与外界隔离的。重点是要有一种机制能够 “修正” 这种分裂,也就是在某种条件下,割裂的两个网络可以相互合并。 同步数据 主动 这里保留区块链创世块的概念,所有节点的第一个块内容是相同的。新加入网络的节点,会从创世块开始启动,此时其他节点的块高度已经有很多了。比如当前节点的块高度是 2,想要从网络中同步第 3 个块高度的内容,节点的路由表中有其他节点的地址,其他节点块高度均等于 3。当前节点会发起一个对块高度 3 的请求,依次到其他节点。 请求过后,发现有 2 个节点块高度为 3 的块一样,块的内容一样、块哈希一样、前块哈希也一样,那当前节点就把这个多数节点都存在的块作为第 3 个块。 如果请求的时候发现有的节点的块高度已经大于 3 了,那么是不是应该块高度最高的优先呢?如果考虑时间尺度的话,就会觉得事物发展是需要遵次序的,你不能跳过 3 岁直接过起 4 岁的人生,区块链的数据也应该有先后次序。况且,当前节点需要的块高度是 3,请求的块高度是 3,管你有没有其他高度的块?你的块高度再高,我就要 3 的,你说你多高有什么用? 那么如果请求过所有节点,发现每个节点的块都不一样呢?该信谁? 总得挑一个吧。如果不能确定哪个节点或者哪个内容可信的话,就随机选吧。最好是选择最后一个请求的节点,因为错过的节点就已经错过了,此时最后一个节点是距离你最近的节点,并且在此之前你并不能判断,是否存在块内容相同的节点。所以在放弃之前的节点后,最后一个节点就是你不可以放手的选择。 对于主动请求块数据的情况,很关键的地方是,请求一定是按照路由表顺序依次进行的,在得到第 1 个节点的响应之前,绝不向第 2 个节点发起请求,做人不能太三心二意了。如果有节点就刻意加速、同时请求多组数据呢?其实也无伤大雅,毕竟只是同步数据,有的人喜欢快点,有的人喜欢慢点,有的人喜欢快生活,有的人享受慢生活。 被动 除了主动请求某一高度的块数据,节点也会收到其他节点的广播消息,比如当前节点的块高度是 2,收到了来自其他节点的内容分别是 3、4、4 的块。 按大多数一致的原则,是不是应该选择内容是 4 的块放在自己的第 3 个块高度上?但是这样存在一个问题是,你无法预测自己会收到多少个块,没办法计算块内容的总量和占比。所以对于被动接收的块,可以以第一个收到的块内容为准。 人生的出场顺序很重要,如果正好需要的块高度是 3,接收到广播的块高度也是 3,那就它吧,遇到哪个算哪个。实在不行后面遇到更合适的再换。如果有节点很激进,为了自己的块能够被大范围接受,把同一个块标记为从 1 到很大块高度广播出去,就为了碰运气,让正好缺块的节点接收,那也就随他吧。因为节点在主动同步块的时候,是按照高度获取内容的,这种激进一点的做法并不能带来很好的收益。 新增数据 网络中的数据由谁产生?为了解决这个问题,可以先定义为,每个节点都可以产生数据。一种极端的情况是,每个人都只相信自己的数据,各玩各的,整个网络就变成单机版了。所以节点也需要将自己产生的数据散播出去,发送给其他节点。对于其他节点来说,就是 “被动同步数据” 的情况了。 有节点需要 主动广播块数据分两种情况,一种是有节点正好需要块,你正好发送给他了。 当前节点接收到内容的请求,新增了块高度为 4 的块,这时会直接把块持久化到主链上。接着开始对块高度为 4 的块进行广播,广播按照路由表依次进行,在广播结束之前,当前节点不会打包下一个块。广播开始后,有节点块高度为 3,说明你是第一个发送给他块高度 4 的节点,它一定会接收你的块,同时给你一个响应消息。在收到响应后你就可以知道,当前网络至少有一个节点接收了你的块,你可以继续处理下一个块了。当然,在路由表遍历结束之前,节点即使收到响应消息也不会停止这一轮广播,这是理所当然的,希望有更多节点可以接收块内容。 如果对方节点在收到块后,发现块内容是 5,前块哈希是 3,并不对应它自己的前块哈希 4,对方依然会接收这个块,并且依次替换自己之前的块,直到哈希一致。 这种机制有可能带来的风险是,接收到一个块,然后把整条链都替换掉了,这是非常严重的不能接受的开销。但确实存在这样的可能,你遇到了一个坏人,这个坏人乘虚而入,他的思想颠覆了你的人生观,让你误入歧途,六亲不认。倒是你需要反思一下,你的路由表里为什么会有这样的坏人。而且这样的坏人多吗?如果一个恶意节点用一个块替换了你的整条链,但是接下来会有很大概率有好人来把你的整条链置换到大多数一致的情况。 这里暴露出了一点问题,接收到第一个块就认可,是不是太草率了?如果是坏人怎么办?为了增加节点作恶的难度,在接收到块内容为 5 发现前块哈希对不上的时候,应该不止向第一条链请求块内容,而是走完整的 “主动同步数据” 的逻辑,根据块高度把路由表里的所有节点都请求一遍。如果块内容为 5 的块,前块哈希和大多数节点不一致,就直接把 5 抛弃掉。如果一致,就说明它不是有害内容。 没有节点需要 节点新增块后,可能遇到没有节点需要当前块高度的情况, 其他节点的块高度都大于等于广播出去的块。这种时候,当前节点就有必要做一点点妥协,为了让别人接收自己,为了让其他节点接收自己的块数据,只好先从其他节点同步数据,和其他节点保持一致。 在块高度为 4 的块上,当前节点广播了一圈发现没有节点愿意接收这个块,那当前节点就把最后一个访问的节点的,当前块高度的块,请求过来。为什么是最后一个请求的节点?这里也可以走一遍完整的 “主动同步数据” 的流程,但为了提高效率,减少网络交互,可以先随意接收一个块内容,再做后续的判断。选择最后一个节点,是因为离得近。在错过了万丈红尘纷纷扰扰之后,恍然回首,发现最后一个节点是你此时最亲近的伙伴。 收到最后一个节点的块内容是 7 的块后,当前节点继续广播块内容为 5 的块。 如果不幸遇到了其他节点的块高度都远高于自己的情况,那说明自己确实落后了,先把其他节点的内容都同步过来再说。想要创新,想要新增内容,至少要先到达某一种顶端, 不一定是整个网络的顶端,至少是某种圈子的顶端。 交换数据 类型 在目前的机制下,网络可能是混乱、不同步的。虽然对数据同步的速度预设是慢的,但如果有的节点就喜欢快呢,用快的计算、大的网络带宽,就是要达到整个网络的最前沿。 也就是大多数节点慢,少数节点快的情况。每个节点都是按照块高度平行更新内容的,也都是按照块高度广播内容的,在一定程度上会缓解这种问题。你想快就快,和我们没有关系,我们慢的自成一派,我们遵循大多数一致的原则,不是谁块高度高就听谁的。你想内卷就尽力去卷,我们不跟你玩。 另一种是一半节点慢,一半节点快的情况,也没有什么好担心的,最坏就是形成两个网络,无关痛痒。 至于少数节点慢,多数节点快,属于最正常的情况了。 融合 在一半节点慢,一个节点快的情况下,很容易造成这样数据对立的情况,即使块高度一致,也是两种数据。 这个时候就不得不有一方妥协了。如果两个网络想要融合,就必须有一方做出一些牺牲。在块高度一致的情况下,假设路由表互通,产生新的块数据后,其实就是 “主动广播数据” 的过程,当前节点先产生一个块: 例如最后一个节点在收到块后,发现前块哈希和自己的对不上: 就去其他节点请求上一个块高度的内容: 发现上上个块的哈希对不上: 继续请求其他节点对应块高度的内容; 依次类推,直到整条链完全相同。 被替换掉的块内容可以放到一个缓存队列,作为新块的内容,继续向外广播,减少节点内容的丢失。 总结 这是一种没有经过实践考验的、也无法简单用公式来建模的共识机制的设想,共识以自身立场为出发点,关心自己如何应对网络中其他节点的不同行为,而不是从整个网络的角度 “上帝式” 地设计交互协议。这样的机制会给网络带来不确定性,但也会带来很多可能性。我们只能考虑节点基本的行为规则,就像我们学习生活规则一样,我们很难预测整个网络的走向,就像我们无法预测世界会向什么趋势发展。这种共识机制并不是而且也许不能解决特定的问题,比如建立电子现金系统或者提供图灵完备的运行平台,它关注在更基础一点的层面,提供一种实现数据一致性的方法和思路。 计算机通过网络组成的虚拟世界,一定也很精彩。

2021/10/29
articleCard.readMore

链表常见算法题及解析

目录: 翻转链表 判断链表是否有环 链表如果有环,找到环的起点 判断两个链表是否相交 链表如果相交,找到交点 合并两个有序链表 翻转链表 问题 对于一个这样的链表: 希望经过函数处理后,变成这样: 链表结构定义 节点的定义为: type Node struct { Value int Next *Node} 构造链表方法为: func createLinkedList(n int) *Node { head := &Node{Value: 0} node := head for i := 0; i < n; i++ { if i < n { node.Next = &Node{Value: i + 1} } node = node.Next } return head} 函数会返回一个链表的指针。使用指针而不是结构体类型是因为,Go 语言的某些关于变量的设计,无法使用 Node{} == nil 的形式判断变量是否为空,因为理论上 Node{} 不是 nil。这就造成了如果使用Node{}作为链表头部的变量类型,在遍历的时候找不到一个合理的结束时机,只能使用类似 Node{}.Next == nil 这样的形式,还会遗漏掉最后一个节点。 迭代翻转链表 这里不能使用直接改变节点值的方式,比如遍历一次后把链表节点的值按照顺序储存到数组中,然后再遍历一次,一次修改链表节点的值。这个违背了数据结构的意义。可以使用递归完成翻转链表的操作。 比如第一个节点,使用 temp 变量储存翻转前的下一个节点的位置,然后把 head.Next 指向翻转后应该有的节点位置,第一个节点的下一个节点是空节点,第二个节点的下一个节点是节点 1。完成 head.Next 的指向后,head 要指向 temp 也就是原来的下一个节点用以完成遍历。这时还需要要用一个 curr 变量来储存head 跳转前的位置,方便下一次 head.Next 指向上一个节点的位置。这应该是一个简单的过程。 func reverseLinkedList(head *Node) *Node { curr := new(Node) for head != nil { temp := head.Next head.Next = curr curr = head head = temp } return curr} 时间复杂度:O(n) 空间复杂度:O(1) 执行 执行程序后结果和预期一致: func main() { head := createLinkedList(4) head = reverseLinkedList(head) for head != nil { fmt.Println(head.Value) head = head.Next }} 判断链表是否有环 问题 链表有环是指链表中“最后”的一个节点,它的下一个节点指向了链表中位于它之前的节点。 当程序遍历这个链表,会发现进入了死循环,永远找不到出口了。怎么判断一个链表,是否存在这样的环呢? 分析 常用的解决思路是双指针。设想一个在赛道上的场景,两个人 A 和 B 处于同样的起点,但是他们跑步的速度并不相同,A 的速度 v1=1,B 的速度 v2=2,也就是 B 比 A 快。在这样的情况下,只要他们不停,B 一定会超过 A 一圈然后再次追上 A,这是一种生活中的常识。 在一个圈里,一快一慢的两个点一定会再次相遇,而且他们经过的路程是可以计算的,路程 s1 和 s2 应该满足这样的关系: s2 - s1 = nR R 是圆圈的周长,n 是正整数,他们位于出发点时 n=0,第一次相遇的时候 B 比 A 多跑了一圈,多出了 1 倍周长的路程,n=1。 和链表的情景相比较,赛道的场景还少了开始的一段距离,在进入赛道之前,A 和 B 会先从赛道外的小路进入赛道,然后再开始绕圈跑步。他们的起点在赛道外,为了便于计算,他们的速度从始至终不发生变化,那么当他们进入赛道之后,就已经不是同样的起点了。 在这种情况下,他们经过的路程 s1 和 s2 还有规律可循吗?设圆形赛道外的直道距离为 d,相比上面的关系式,他们在圆圈内的路径依然满足 n 倍的周长 R,只不过现在的表达式不同了: (s2 - d) - (s1 - d) = nR s2 - d - s1 + d = nR s2 - s1 = nR 结果表达式在相互抵消路径 d 之后,和之前的一样。 A 的路程 s1=v1t,B的路程 s2=v2t,时间 t 是一样的,速度 v1 和 v2 是已知的 1 和 2,有: s2 - s1 = nR v2t - v1t = nR 2t - t = nR t = nR 取 n = 1,t = R。 解决 回到链表的问题,其实我们只要用快慢指针就可以判断链表是否有环了,并不需要知道他们具体相遇的点在哪儿,不过计算路径关系的公式可以辅助我们验证结果的正确性。 回到这个链表,用两个指针 A 和 B 从节点 1 分别以速度 1 和 2 出发: 他们的位置关系将会是: 时间 t01234 A 的位置节点 1节点 2节点 3节点 4节点 5 B 的位置节点 1节点 3节点 5节点 3节点 5 在第 4 个时间点的时候,A 和 B 相遇了,环的周长正好等于 4,满足 t = R 的关系。 链表如果有环,找到环的起点 问题 这个问题是上一个问题的延伸,在判断链表已经有环的基础上,找到环的起点。比如这样的一个链表,环的起点是节点 3。 分析 (1) 在判断链表是否有环的问题中,我们得到了一个至关重要的结论: t = R 两个快慢指针将会在等于环长度的时间点相遇。对于上图的链表,快慢指针的位置关系是这样: 时间 t0123456 A 的位置节点 1节点 2节点 3节点 4节点 5节点 6节点 7 B 的位置节点 1节点 3节点 5节点 7节点 3节点 5节点 7 我们可以观察到,环的长度是 6,快慢指针也会在第 6 秒相遇,他们交点位置是节点 7: (2) 根据上面提到的之前的结论,按照慢指针 v1 = 1 的速度,它经过的路程和时间是一样的,也就是说,从出发点到两指针相遇的路径长度,根据 t = R,此刻的时间是 t,正好是环的长度 R: (3) 做一个假设,慢指针保持着这个长度为 R 的走过的路径,向前移动一步,会变成这样: 再走一步,变成了这样: (4) 到这里似乎还不知道我们要干什么。现在对路径设一个变量,从 出发点 到 环的起点 之间的距离设为 l1,整个链表的长度设为 l,环的长度仍然为 R。 这 3 个变量将满足这样的关系: l - l1 = R 这是太显而易见的事情。 (5) 记得我们一开始的结论吗?从 出发点 到 快慢指针的交点 之间的距离,等于环的长度 R: 变量 l 和 l1 保持不变,图就成了这样: 此时的 l 仍然等于 l1 + R,不同的是,l1 和 R 重合了。 (6) l - l1 = R 重合之后,等式关系还成立吗?当然成立,因为整个链表没有变,变量的大小没有变。但好像又觉得哪里奇怪。 现在新设一个变量,设从 快慢指针的交点 到 环的起点 的距离为 l2: 此时: l - l2 = R (7) 经过这样一些比较,发现 l1 == l2,也就是从 出发点 到 环的起点 的距离,等于 快慢指针的交点 到 环的起点 的距离。 解决 出发点 -> 环的起点 == 快慢指针的交点 -> 环的起点 这是一个很重要的结论,因为我们此时的快慢指针就在 快慢指针的交点 上,在节点 7 的位置。 如果这个时候在新增一个指针 p3,在快慢指针相交的时刻,从整个链表的 出发点 1 出发(速度为 1),那么 p3 和慢指针一定会相交,因为 p3 到 环的起点 的距离等于慢指针到 环的起点 的距离。p3 遇到慢指针的位置,就是环的起点。 判断两个链表是否相交 问题 存在两个链表,分别在某一个节点指向了同一个节点作为下个节点: 这里有两个链表: 1 -> 2 -> 3 -> 4 5 -> 3 -> 4 怎么判断两个链表是否相交? 分析 一种简单的做法是,分别遍历每条链表到最后一个节点,判断最后一个节点是否相同。如果两个链表在中间节点相交,则最后一个节点一定相同。 链表如果相交,找到交点 问题 对于这样两个链表: 如何找到第一个交点 3 ? 分析 一种简单的解决思路是,把这个链表的尾节点和任意一个链表的头节点连起来: 可以是链表 1 的尾节点到链表 2 的头节点,或者链表 2 的尾节点到链表 2 的头节点,总之连起来以后,问题就转变成了,找到链表环的起点。 合并两个有序链表 问题 给出两个有序链表,将两个链表合并为一个有序链表。 分析 思路暴力简单,同时迭代两个链表,按照顺序依次合并就可以了。控制好边界条件。 代码 node 结构定义: type Node struct { Value int Next *Node} 构建两条链表: func main() { root1 := &Node{ Value: 1, } root1.Next = &Node{ Value: 1, } root1.Next.Next = &Node{ Value: 3, } root1.Next.Next.Next = &Node{ Value: 5, } root2 := &Node{ Value: 1, } root2.Next = &Node{ Value: 2, } root2.Next.Next = &Node{ Value: 4, } root := merge(root1, root2) for root != nil { fmt.Println(root.Value) root = root.Next }} 合并链表: func merge(root1 *Node, root2 *Node) *Node { var root *Node var temp *Node if root1.Value <= root2.Value { root = root1 temp = root2 } else { root = root2 temp = root1 } p1 := root p2 := p1.Next for { if p2 == nil || temp == nil { break } if p2.Value <= temp.Value { p1.Next = p2 p1 = p1.Next p2 = p2.Next } else { p1.Next = temp p1 = p1.Next temp = temp.Next } } return root}

2021/10/27
articleCard.readMore

基于 Multi-Linked List 的区块链设想

区块链有没有可能不再是线性的结构,而是有多条链路的、图式的数据结构呢,比如从 singly-linked list 进化到 multi-linked list。假设是 multi-linekd list 的形式,会给区块链带来什么样的变化,或者说,可行吗? 首先是正向的 multi-linked list,像是这样: 然后是适用于区块链的、反向的 multi-linked list,像这样: 对于 multi-lineked list,一种是固定数量的情况,比如每个节点的后续节点一定是 2 个,或者是不固定数量的 multi-linked list,每个节点可以指向任意多个子节点,它们的区别不大。 还有一个分歧是要不要保留块高度的概念,块高度是用来表示块顺序的索引,在多个节点之间同步数据的时候起到很重要的作用,在 multi-linked list 中,如果按照树结构层数来定义块高度,会是这样: 或者可以按照块写入的顺序定义,也就是初始的标号意味着块高度: 对于区块链来说,很重要的是确认数据的完整性,当只有一个子节点的时候,其他节点很容易同步一个块的数据,并且根据 hash values 校验块的有效性。对于 multi-linked list 的情形,这个校验块数据的步骤会复杂一些,但也并非无法处理,只要把原先校验一个块的操作,多几次循环,多校验几次就可以了。 正向和反向 multi-linked list 最大的区别是,一种是向外发散的,会随着层级的增大,每一层容纳的节点数不断增大,指数级增长。另一种是收敛的,每一层的节点数会不断减少,最终只剩下一个。也就是正着的树和倒着的树的区别。 区块链中有一个创始块的概念,如果是正向的 multi-linked list,创世块还是一个保持不变,但是子节点逐渐膨胀以后,会越来越不可控。对于保留块高度的情况,其实除了程序处理多几个步骤,并没有无法实现的理由。对于不保留块高度或者说用索引值代替块高度的情况,块仍然可以一个一个地出,也没有在技术上实现不了的理由,但其实还是链式结构的处理方式。单纯数据结构从 linked list 转为 multi-linked list,似乎没有明确的理由,因为无法想象到,这样做可以带来什么样的好处。 如果是反向的 multi-linked list,一个很大的问题是创世块怎么办,反向的树结构子节点会越来越少,最后只剩一个,难不成创世块规定足够大数量的节点数,然后后期逐步趋于1?这非常不合理,相当于给程序设置了一个停机条件,到达一定块高度后就无法在增加内容了,显然是不可取的做法。 由于树结构的发散或者收敛,multi-linked list 的设想是有问题的。那么这样呢,使用平行的 multi-linked list,可行吗? 因为必须有多个输入和输出,并且输入和输出的数量必须一样,所以使用这样带有重复指向的方式。 同样地,在技术上可行,问题在于,有什么好处呢?这样的数据结构带来了什么?除了程序复杂程度的增加,还有什么? 好像还真有,这样的数据结构,允许同时出两个块。两个节点只要使用相同的父节点,即使没有先后,同一时刻产生了内容,也可以将块累加到链后面,作为子节点。 不过这样又回带来很多问题,比如怎么防止 double-spending ?同一个账户交易分别出现在了两个块里,以哪个为准?分布式系统的数据同步,一定是多点变单点的过程,并行处理的程序也会有一个资源竞争的问题,同时出两个块后还是需要某种机制单点处理数据,同时出块就没有意义了,并不能加快整个系统的数据处理速度。 而且,其实平行的 multi-linked list,可以认为是这样: 这样来看,平行的 multi-linked list 就更没有意义了。 总的来说,区块链有可能基于 multi-linked list 的数据结构吗?似乎没有必要。singly-linked list 是简单的数据结构,但又确实是很适合于区块链的数据结构。 背景知识 Singly-linked list Singly-linked list 是一种线性的数据结构, 每个节点都包含一个指向下个节点的 field,用于显式表示节点之间的关联关系,一般在程序中会用对象的引用地址来填充这个字段。当然直接使用节点的值也可以,只是为了在数据类型上有明显的区分。 type Node struct { Value int Next *Node}func main() { node1 := Node{Value: 1} fmt.Printf("%p\n", &node1) // 0x14000104210 node2 := Node{Value: 2} fmt.Printf("%p\n", &node2) // 0x14000104220 node1.Next = &node2 fmt.Println(node1) // {1 0x14000104220} fmt.Println(node2) // {2 <nil>}} node1 的内存地址为 0x14000104210 ,node2 的内存地址为 0x14000104220 ,然后将 node2 的内存地址赋值给 node1 的 next 变量,这个时候只要访问 node1,就可以通过 next 得到 node2 的内容了。以此类推,即使有很多很多 node,只需要知道起始位置 node1 的内容,就可以访问到整个 Linked list 的所有节点了。 Doubly-linked list Doubly-linked list 在 Singly-lined list 的基础上,多了一个字段,用于保存指向上一个节点的信息。在这样的数据结构下,获得一个节点的内容时,不但可以知道下一个节点的位置,还可以知道上一个节点的位置。 type Node struct { Value int Next *Node Prev *Node} (Doubly-linked list) - (Singly-linked list) Singly-linked list 时保留了下一个节点的信息,Doubly-linked list 上一个和下一个节点的信息都保留了,那么有没有一种数据结构,是只保留上一个节点的信息呢?比如这样: type Node struct { Value int Prev *Node} 为什么会需要以保留上一个节点信息的形式,构造 Linked list 呢?因为存在这样一种场景:在创建当前节点的时候,下一个节点的内容和引用地址还不确定或者不存在。 当下一个节点内容确定后,还要更改上一个节点的内容吗?Demo 代码改起来是容易的,如果是在一个海量数据的数据库中呢?update 的成本是很高的;如果是在一个分布式系统中呢?网络交互、数据一致性的成本也很高。 也许这样的数据结构并不差劲,它既没有牺牲 Linked list 的特性,又可以在不改动之前节点数据的基础上,使列表不断延长。只不过访问顺序和 Singly-lined list 是相反的,需要按照从最后一个节点向前的顺序才能遍历所有节点,就像是 Singly-linked list 倒过来了。其实倒过来的 Singly-linked list 也是 Singly-linked list。

2021/10/17
articleCard.readMore

理解哈希函数与序列化

Hash function Hash function 用于处理数据和其 hash values 的映射关系,hash values 是数据类似唯一标识的东西,可以用内存比较小的形式标识数据。hash function 有各种各样的实现,可以认为是一个黑盒子,进去的是 data,出来的是 hash values。 比如,我们可以把字符的 ASCII 码作为字符的 hash values: HASH("a") = 97HASH("b") = 98HASH("c") = 99HASH("d") = 100 对于 2 个字符的 data,就把两个字符的 ASCII 相加,作为 hash values: HASH("ab") = 97 + 98 = 195HASH("cd") = 99 + 100 = 199 但是这样很容易发现存在问题,HASH("ad") == HASH("bc") == 197。对于 3 个、4 个甚至更多字符的情形,hash values 重复的可能性更大。 hash values 是允许重复的,但如果存在大量重复,hash function 也就失去了它的作用和使用场景:如果全部都一样,无法区分,还用 hash values 干嘛? 不幸的是,目前最好的 hash function 也无法避免 hash values 重复的问题,只能尽可能减少 hash values 重复的概率,比如用类似数据库分库分表的方式,给每个字符足够的余量。 我们可以重新设计一下我们的 hash function,在只有 1 个字符的时候,仍然使用 ASCII 作为输出。在有 2 个字符的时候,让 第 1 个字符乘以一个基数,再加上第 2 个字符。由于第 1 个字符在乘以基数后会足够大,无论第 2 个字符是什么,在其基础上加上第 2 个字符的 ASCII 码,应该不会重复。 HASH("ab") = 97 * 1000 + 98 = 97098HASH("cd") = 99 * 1000 + 100 = 99100HASH("ad") = 97 * 1000 + 100 = 97100HASH("cd") = 98 * 1000 + 99 = 98099 这样至少解决了 2 个字符 hash values 重复的问题。 推广到更一般的场景,在面对可能很多字符的情况下,基数使用质数以避免累加造成的重复,为了保证基数足够大,使用质数的不同次方分别作为每个字符的基数,公式为: hashCodes = char1 * base^(l-1) + char2 * base^(l-2) + ... hashCodes 是输出的 hash values,char1 是第 1 个字符,char2 是第 2个字符,base 是基数,l 指字符串的长度。对于 3 个字符长度的字符串,第 1 个字符的基数就是质数的 2 次方,第 2 个字符的基数是质数的 1 次方,第 3 个字符是 0 次方,以此类推。 如果质数选择为 31,hash function 的实现为: public static int hashCode(byte[] value) { int h = 0; for(int i = 0; i < value.length; ++i) { h = 31 * h + value[i]; } return h;} 也许具体的代码不是完全符合直觉,但你可以相信,和上面描述的公式是一致的。 hashCode("a") = 97hashCode("ab") = 97 * 31 + 98 = 3105hashCode("abc") = 97*31^2 + 98*31 + 99 = 96354 这就是 JDK (Java Development Kit) 中 hashCode 的实现方式。 Cryptographic hash function (CHF) 不难发现的是, hash function 比较容易根据 hash values 反推出原始的 data 是什么。我们可以写出这样的程序,假设我们已经知道字符长度是 2,由于字符使用 ASCII 编码,范围在 0 ~ 255,因此设 x 和 y 两个变量,枚举所有符合目标 hash values 的情况: public static String deHashCode(int code) { for (int x = 0; x <= 255; x++) { int y = code - 31 * x; if (y < 0 || y > 255) { continue; } System.out.println(((char) x)+","+((char) y)); } return "";} 比如当 hashCode = 3105,得到的输出是: \,ý],Þ^,¿_, `,a,bb,Cc,$d, 原始数据 ab 就出现在了为数不多不多的可能性中。 那么有没有办法减少 hash values 推出原始 data 的方法?在 Public-key cryptography 中 % 可是起到了很大的作用。hash function 也可以与一些加密算法的原理结合。 cryptographic 是 hash function 的修饰词,即使用了加密算法的 hash function。 md5 是使用非常广泛也接近过时的一种 cryptographic hash function,可以把任意长度的 data 计算输出为 128 bit 的 hash values。 md5("a") = 0cc175b9c0f1b6a831c399e269772661md5("ab") = 187ef4436122d1cc2f40dc2b92f0eba0 md5 的加密原理步骤很多,是一种不可逆的、单向的 hash function,无法轻易根据 hash values 得到 data。md5 的输入可以是任意大小的,1 GB 的二进制文件也可以hash 为 128 bit 的字符串。 md5 之外,SHA-1 的安全性更高,BLAKE2 的计算速度更快,它们都是典型的 cryptographic hash function。 Serialization 序列化是编程中很常见的一种操作,主要用于把复杂格式的数据转化成易于在不同环境中统一处理的格式,类似于定义一种接口格式,便于网络传输。 把数据转换为统一的过程称为 serialization,从统一格式转换为特殊格式的过程为 deserialization。JSON stringify 的过程也可以认为是一种序列化: let object = { field1: "abc", field2: 123}let str = JSON.stringify(object) print(str) // {"field1":"abc","field2":123} Serialization + CHF 可以明确的是,JSON stringify 的结果是一个字符串,这个时候就可以和之前的 cryptographic hash function 结合起来用了: md5(str) = d79152b724c5f1e52e6bd4bfaf6e1532 只要定义过数据的 serialization 方法,我们就可以得到任意数据格式的 hash values。 Serialization + CHF + Linked List Linked list 之间的关联关系常用变量的引用地址表示,但指针不是惟一的方式,数据结构的含义也可以扩展到更大的范围。我们完全可以用节点数据的 hash values 作为关联: 98 是 b 的 hash values,表明值为 a 的节点,下一个节点的 hash values 为 98,也就是值为 b 的节点。 我们也可有使用反向的 linked-list: a 的 hash values 是 97,表明值为 b 的节点,上一个节点的 hash values 为 97。 当然,这里的值可以是更复杂的数据结构,只要定义好 serialization 格式,也可以应用到更复杂的 hash function 上,比如这样正向的 linked-list: type Node struct { Value int Next string}node1 = Node{ Value: "a" }node1_str = JSON.stringify(node1) // { "Value": "a" }node1_hash = md5(node1_str) // 9ad06e8a44d0daf821f110794fb012c7node1.Next = node1_hash 这就构建好了一个节点,以此类推。 另一种也许更好或者更适用于某种特定场景的形式是,将其改为反向的 linked-list: type Node struct { Prev string Value int}node1 = Node{ Value: "a" }node1_str = JSON.stringify(node1) // { "Value": "a" }node1_hash = md5(node1_str) // 9ad06e8a44d0daf821f110794fb012c7node2 = Node{ Value: "b" }node2_str = JSON.stringify(node2) // { "Value": "b" }node2_hash = md5(node2_str) // 7e332b78dbaac93a818a6ab639f5a71bnode2.Prev = node1_hash 这种反向的 linked-list 就是区块链的基础数据结构。

2021/10/13
articleCard.readMore

联盟链比公有链差在哪儿

中文语境下的 “公有链” 和 “联盟链” 并没有明确标准的定义。2018 年,美国国家标准与技术研究院(NIST, National Institute of Standards and Technology)在 Blockchain Technology Overview 中将区块链分为 Permissionless blockchain 和 Permissioned blockchain,但那样的分类方式并不严格对应公有链和联盟链。也许公有链和联盟链的区别在于节点网络规模的大小,也许区别在于区块链面向的范围是公共互联网还是私有局域网。无论是怎样的定义,我们至少可以大概区分出公有链和联盟链。 公有链和联盟链的好坏,不单纯在于技术或者某些评价指标的比较,也许会有人下意识地认为,公有链面对比联盟链更复杂的网络环境和用户体量,但其实技术上的差距总是有办法弥补,联盟链也有少数好于公有链的技术特性。 有一个段子《皇帝的金锄头》: 古代有两个老农民畅想皇帝的奢华生活,一个说:“我想皇帝肯定天天吃白面馍吃到饱!” 另一个说:“不止不止,我想皇帝肯定下地都用的金锄头!” 联盟链就是在用区块链做传统行业的业务,甚至可以说是打着区块链的幌子到处骗钱。联盟链的问题就在于,格局小了。

2021/9/29
articleCard.readMore

在 Dijkstra 算法中保存路径

区块链的 Layer 2 中有一种 State Channels 的扩容方案,其中会需要搜索距离最近的路由节点。 Dijkstra 算法思路 Dijkstra 算法能够解决 single-source 的最短路径问题,算法本身只输出一个点到其他点的最短距离。比如在这样一个图中,起点是 A,想知道到 D 点的最短距离是多少: Dijkstra 算法实质是动态规划的贪心算法的结合,要寻找最短路径,就去遍历所有的点,每到一个点更新最短距离的记录,直到走过所有的点,就可以确信拿到了可靠的最短距离的记录。初始化的状态集合为: ABCD 0--- 此时位于 A 点,未出发的状态,到自身的距离为 0,到其余点的距离未知。 从 A 点出发后,发现 A 点可以到达 B 点和 C 点,距离分别为 4 和 2,那么就更新状态集合为: ABCD 0--- 4[2]- 中括号的含义是在当前这一轮中距离最短的点,哪个距离最短,下一步就到哪个点。到 C 点的距离比到 B 点的距离短,所以下一轮到 C 点: 到 C 点以后,发现 C 点可以到达 A、B、D 三个点,这个时候意识到,其实 A 点已经走过了,不会再往回走的。于是需要另一个集合记录走到过哪些点,以避免下一步重复。定义 prev = [],因为 A 和 C 已经走过了,就把这两个点放到集合里, prev = [A, C]。 在这一步的时候,到达 B 点的距离从 4 变成了 3,A -> C -> B 的距离小于 A -> B 的距离,更新状态集合,同时因为已经能够到 D 点了,更新到 D 点的距离: ABCD 0--- 4[2]- [3]5 这一轮中,到达 B 点的距离小于到达 D 点的距离,中括号选中 3,并且下一步到 B 点: 此时 prev = [A, C, B],状态集合更新为: ABCD 0--- 4[2]- [3]5 [5] 中括号只剩一个选择,只有 D 点没去过了: prev = [A, C, B, D],所有点遍历结束,最终结果为: ABCD 0325 现在就可以知道从 A 点到 D 点的最短距离为 5. 最短路径跟踪 算法结束后,可以得到从 A 点到其他点的最短距离数据。可是如果不只想要距离值,还想要具体路径,比如从 A 点到 D 点的最短路径,该怎么处理? 正向贪心算法 可以判断出,从 A 到 D 的最短路径是 A -> C -> D,而上面的 prev 集合为 A, C, B, D。因为从 C 直接到 D 比 C -> B -> D 的距离要短,所以在路径中抛弃了 B 点。 按照这样的现象进行对比,是不是只要在 prev 的基础上,在合适时候抛弃某些点,就可以得到正确路径了?比如上面从 B 到 D,存在 4 种情况: B 可以到达 D B 不可以到达 D 通过 B 到达 D 是状态集合中到达 D 距离最短的方案 通过 B 到达 D 不是状态集合中到达 D 距离最短的方案 这 4 中情况中,只有 B 可以到达 D 并且 通过 B 到达 D 是状态集合中到达 D 距离最短的方案 的时候,才会保留 B 这个点到路径中。否则就应该去掉 B 点。 中括号每选择到一个点,就把点放到路径中,如果不满足上面的条件,就从路径中去掉这个点,也就是不放到路径里面。这样的话,即使有其他捣乱的点存在,程序也可以应对,比如: 在选中 B 点后,发现 B 点不满足条件,此时路径由 path = [A, C, B] 回退到了 path = [A, C]。如果下一轮最小的点选中了 E,path = [A, C, E],但是 E 点不满足条件,path = [A, C]。直到最小的点选中目标点 D,整个程序结束。 或者这样的,也可以处理,E 点不会被放到路径中: 那么这样的思路存在问题吗?当然有问题,这样的程序是不能处理这种情况的: 假如最短路径是 [A, E, C, D],E 点是不满足上面被放进路径的条件的,E 点无法直接到达 D 点,但是又必须被包含在路径里。去掉 可以直接到达 D 点 的限制?那上上图的 E 点也会被放到路径里。 也就是说,需不需要能够直接到达目标点,取决于对于最终的路径,被选中的点是不是倒数第二个点。这样的条件在一个未知的图中是无法判断的,谁能知道一个点是最终路径的倒数第几个点? 正向的贪心算法试图每一次都把距离最小并且在最终路径上的点记录下来,但其实很难做到,因为根本无法判断一个点是不是在最终的路径上。 反向贪心算法 当 D 点被中括号选中,作为本轮距离最小的点,就已经能够确定从 A 点到 D 点最短距离了。那么只要知道这一步是从哪个点过来的,来源的点就一定是最短路径的倒数第二个点。依次类推,只要层层回推到出发的点,整条路径就出来了。 假如在到达 D 点后,能够知道是从 C 点而不是 B 点过来,在 C 点的时候,能够知道是从 A 点而不是 B 点过来,整个路径就很清晰了。 问题是怎么在 D 点的时候,知道是从 C 点而不是 B 点过来的?选中最小距离点的顺序可是 [A, C, B, D],按照最小点的顺序显然是不行的。 这看起来不是一件难事,在 DFS 或者树的遍历中,经常会前后进入多个路径然后在适当的时候返回以修正路径。换个角度看,其实在 DFS 中维护最短距离,也可以达到目的。维护了距离状态的 DFS == Dijkstra algorithm 吗?显然不是。 递归 vs 尾递归 Dijkstra 适合写成循环的形式: for {} 更适合写成尾递归的形式: func recursion() { recursion()} 总之,程序会是单向的循环。适合写成递归的形式吗? func recursion() { for { recursion() }} 当遇到分支情况的时候,用 for 循环 “同时” 进入多个路径,寻找最合适的那个。比如到 C 点的时候,for 循环前后进入 C -> B -> D 和 C -> D 的路径,每次循环将只保留一条路径,找到最合适的直接终止递归就可以。 这样的写法存在问题吗?问题在于,怎么确定在哪个节点进行分叉。在 C 点分叉?为什么是 C 点?为什么不是 B 点?如果是 B 点,路径上就会多出 B 点。为什么不是 A 点?如果是 A 点,到了 C 点的时候需不需要继续分叉?是每一个点都需要分叉吗?想象一下那会造成多么大的冗余……为什么树可以同时遍历?因为树的节点不会交叉。 第二个动态规划 第一个动态规划是指算法本身距离数据的维护。第二个动态规划可以维护一个路径数据的状态: pathList = { A: [], B: [], C: [], D: []} 路径状态保存从源点到达每个节点在当前阶段的最短路径,在一开始的时候,因为 A 点已经可以到达 B 和 C: pathList = { A: [A], B: [A, B], C: [A, C], D: []} 选择并到达 C 点,这个时候因为 C 点可以到达 B 点并且 A -> C -> B 的距离小于 A -> B,所以更新路径状态数据为 pathList[C].push(B)。D 点也可以到达了,更新路径状态。(更新路径状态数据发生在进入下一个点之前,甚至发生在选择下一个节点之前。可以想一想为什么这样做。) pathList = { A: [A], B: [A, C, B], C: [A, C], D: [A, C, D]} 这一轮在距离的状态数据上,会把 B 点选中为最小距离的节点,判断到达 D 的路径 A -> C -> B -> D 大于目前已有的距离记录 A -> C -> D,所以不更新路径状态。(判断距离是否大于已有距离是根据距离的状态数据,也就是表格的数据。) 最终进入目标 D 结束,路径状态不更新。 得到路径 A -> C -> D。 路径的状态数据可以为了节省空间,只维护到达目标点的路径吗?不可以,因为更新下一个点的路径需要依赖当前点的路径,路径的状态必须是全量的。 非最短路径跟踪 Dijkstra 算法包含了贪心算法的思维,每一步选出的都是距离最短的点。如果需要保存不是最短路径的路径,Dijkstra 算法也许可以做到,但是就已经不需要 Dijkstra 算法了。DFS/BFS 更合适一点。 补充(2025.05.11) 这个 Dijkstra 相关的工作,是当时在一个 State Channels 的项目 pylons 上,用来在多个通道之间寻找最短路径用的,原本是 DFS,后来我加了一个 Dijkstra,带有黑名单的功能,以及把手续费作为路径距离的计算依据。 现在把 route 部分的代码单独拆分出一个仓库 smallyunet/dijkstra-demo 留作纪念。

2021/9/18
articleCard.readMore

为什么数字货币使用区块链是政治问题

本来不想再专门提区块链。由于工作相关,接触相关话题比较频繁。 最近,一个高级别的技术管理吐槽我 “数字货币不可能用区块链” 完全是外行的观点,即使只是把四大行的大额交易记录到区块链上,也是很好的一件事情,而且可以用分层交易的技术架构,顺势解决小额支付在区块链上性能受限的问题…… 我的逻辑很简单。 假如区块链在世界上从来没有出现过,没有存在过。在这种情况下,如果“上面”要求各大银行的交易数据必须同步一致可追溯。下面的人能做到吗? 不但能做到,而且可以做得很好。 区块链能解决的问题,不用区块链也能解决。这几乎是众所周知的事情。这也是为什么有人说 “区块链没有新技术” 的原因。 当然,这里的 “数字货币” 特指中国的数字货币,“区块链” 指——在讨论区块链怎么用之前,是不是应该先把 “区块链是什么” 搞清楚?奇怪的是,似乎没有人关心这个问题。 在这个方面上,和区块链形成对比的是人工智能。人工智能能做的事情,如果技术跟不上,就确实做不到。

2021/4/18
articleCard.readMore

区块链:下一代数字身份认证体系的基石

如何在互联网的世界中,证明“我是我”?在 A 网站认证过了身份信息,到了 B 网站又需要认证一次?手持身份证拍照上传、人工审核,流程太繁琐?多个账户密码记不清、容易混,管理起来困难?自主主权的数字身份(Self-soverieign identity, SSI)正是可以解决这些问题的理念。 SSI 是数字身份运动中的观念,指只有用户自己拥有全部的、完整的数字身份信息,没有其他管理者和组织参与的数字身份体系。在 SSI 的理念中,用户拥有属于自己的去中心化的惟一身份标识(Decentralized identifiers, DIDs),用户可以完全控制自己的身份信息,可以在任何时候使用、更新或者彻底删除信息。用户可以创建并管理自己的可验证证明(Verifiable Credentials),自主决定在什么时候使用和分享自己的证明信息,而不需要请求其他中心化的机构、通过机构授权来使用自己的个人数据。 使用 SSI 的系统,所有的密钥信息都可以通过数字身份钱包进行管理,使用一个账号就可以登录所有的网站。在钱包终端中,用户可以随时向权威机构申请签发证明,包括身份证、驾驶证、居住证等各种形式的证件,都将以数字证明的形式储存在手机或电脑上。数字证明拥有机器可读、机器可验证的特性,不但可以放心地展示给第三方应用,第三方应用还可以在没有人工干预的情况下,直接验证证明的有效性,不需要签发机构的参与。 得益于区块链技术的不断发展,SSI 理念的实现逐渐成为可能。区块链系统本身就是点对点网络,天然拥有去中心化的特性,结合独特的数据结构设计和密码学技术的应用,加上共识算法在多节点数据同步方面的优秀能力,区块链不但能够保护数据的隐私安全,而且数据一旦写入系统便任何人无法篡改,为数据提供了极高可信度的储存环境。在 SSI 系统的建设中,将区块链作为可验证数据的数据中心(Verifiable data registry)无疑是最好的选择。 SSI 目前已经有诸多先例。2017 年,Sovrin 基金会发布了世界上首个公开的用于自主主权的数字身份的分布式账本网络,整个系统运行在开放标准以及公开源码的 Sovrin 协议之上,由 Linux 基金会的 Hyperledge Indy 项目维护。Sovrin 在 2018 年公布的白皮书中自问自答,“为什么网络世界中没有像物理世界一样可以用来证明身份的证书?直到区块链技术的出现,我们解决了这个问题!”结合 W3C 的 DIDs,Sovrin 提出了完整的数字身份和证明的解决方案。Sovrin 的主意一直都很明确,就是一定要构建和使用公开的、任何人都可以访问的、像比特币和以太坊一样的区块链网络。 eSSIF-Lab(European Self-Sovereign Identity Lab)是另一个案例。欧洲区块链联盟提出的 EBSI(The European Blockchain Services Infrastructure)是一个横跨欧洲的分布式节点网络,提供跨境的公共服务,有 28 个成员国签署了相关声明。eSSIF-Lab 项目是 EBSI 的一部分,由欧盟委员会资助,旨在促进 SSI 成为下一代开放、可信、安全的数字身份解决方案。欧盟曾在 2014 年 7 月 23 日建立了针对欧盟共同市场电子交易的电子身份识别和可信服务的法规 eIDAS(electronic IDentification, Authentication and trust Services),2019 年 5 月,eIDAS 宣布支持基于 W3C 相关规范的自主主权的数字身份。 微软在相关领域也表现活跃,2018 年 10 月,微软发布《去中心化的身份》白皮书,介绍了基于区块链的去中心化数字身份系统建设的技术方案,包括 DIDs 规范、去中心化的数据系统、DID 用户终端、DID 通用解析器、DID 身份中心、DID 认证系统、去中心化的客户端和服务等核心模块,详细说明了各模块组件以及各种角色在系统中的交互流程,为 SSI 系统的建设提供了非常好的模板。目前,微软已经提供公开的服务平台,可以体验相关的产品和能力。 此外,构建在以太坊和 IPFS 网络上的 uPort、使用自研区块链和支持第三方 DApp 的 Blockstack、能够适配比特币网络的 ShoCard 等都是优秀的案例。国内的厂商和机构也在进行相关的工作,如蚂蚁链提供的分布式身份服务 DIS(Decentralized Identity Service)、腾讯云的数字身份标识解决方案、微众银行的基于区块链的分布式多中心的技术解决方案 WeIdentity 等,都利用了区块链去中心化、数据高度可信的技术特点,构建了可靠的数字身份标识和认证体系。 区块链是一项极具潜力的先进技术,具有非常广阔的发展前景和应用空间,无论是国家政策的支持还是实际案例的应用,都体现出区块链未来的无数种可能。我们也在积极探索和推进区块链相关的技术发展和场景落地,将区块链与同态加密、联邦学习、多方计算、零知识证明等前沿技术结合起来,使用最优秀的技术能力,促进下一个互联网时代的到来。

2020/12/8
articleCard.readMore

网页技术能实现 3D 建模吗?

网页技术(HTML5、CSS3、JavaScript)能实现效果炫酷的 3D 建模甚至是 3D 动画效果吗?我暂时认为是不可以的。比如期望这样的页面效果: d3.js 是不用考虑的,它仅仅是一个数据可视化的工具,和 3D 建模是两个领域。 three.js 似乎是目前比较流行的 3D 建模库。假如 three.js 可以做到的话,应该怎么做呢?首先的想法是画这样一个正方体出来: 3d 建模里的立方体相当于编程世界的 hello world,很容易就能出来: 给场景加上灯光,正方体就不是黑漆漆的了。然后给正方体加上颜色,改一下场景的背景色,再把灯光调到正方体的上面,正方体就能像样子一点了: 目标正方体的边缘是发光的,而且是渐变色。怎么给正方体加一个边缘线呢,正方体本身是没有这种属性的,只能用线性材料(three.js 里的 LineBasicMaterial,正方体用的是 MeshPhongMaterial)再画一个正方体出来,套在实体正方体上: 怎么让线性的正方体发光呢?线性材料(LineBasicMaterial)是不能使用渐变色的,只有着色器材料(ShaderMaterial)可以使用渐变色。着色器材料可以实现多彩的效果,比如这样(来自 StackOverflow),: 但是到这里遇到问题了。在 three.js 里,渲染一个物体需要两个参数,一个是 geometry(几何体),一个是 material(材料),线性材料和着色器材料都是材料的种类。(TorusKnotGeometry 是上图用到的几何形状) 线条正方体 = EdgesGeometry + LineBasicMaterial渐变曲线条 = TorusKnotGeometry + ShaderMaterial 现在想要线性材料和着色器材料(LineBasicMaterial 和 ShaderMaterial)组合是不合逻辑的,我没有找到实现发光的正方体边缘效果的方法。把着色器用在正方体的效果是这样的(颜色从 0x215ec9 到 0x000000): 所以然后呢?我意识到即使实现了一个好看的正方体,离渲染出整张图还差的太多。比如这样的文字效果怎么做? three.js 的 Texture 本身效果是不错的,可是怎么把文字安安稳稳的放到正方体上,还带透明的黑色背景框?再比如这五彩斑斓的线条,以及准确的箭头指向: 还有整个图上十多种元素的位置布局、动画效果。 我相信 three.js (WebGL)在技术能力上是可以实现这样效果的,甚至官方的 example 里网页游戏都有,不过假如要实现一个网页游戏,一定会用到图像素材,素材从哪儿来呢?还是得回到 PS、AI 之类的工具上,如果用上了那样的生产力工具,就没有必要用 js 来写布局和动画了。单纯的网页技术似乎很难完全解决 3D 建模的问题。H5 动画也是类似的情况。 单纯写代码来 3D 建模的另一个问题是不直观,代码是违反直觉和视觉的,写个网页、APP界面似乎还可以(二维的)。如果可以在一个画布上直接放置正方体和线条,然后鼠标拖动改变位置、调整颜色,以及添加各种其他元素,像玩游戏(比如我的世界)一样操作简便,不就比写代码好多了吗……那不就是 Adobe Animate 吗? 可惜 Adobe Animate 没有 Linux 版本,而且 Linux 下的替代品 Blender 有点性能问题。 回到一开始期望的效果图上,图片出自一个大屏 UI 的 设计演示,其实原效果不是三维的,作者使用的工具是 PS、AI。那么在仅需要二维效果的前提下,网页技术能实现吗?如果要用代码实现各种图形,就依然还是三维建模的问题。最简单的方式是拿个背景图,把文字贴到上面。背景图从哪儿来呢?

2020/9/20
articleCard.readMore

给区块链一个定义

一直以来,区块链似乎都没有一个明确的定义,伴随区块链出现的词语经常是去中心化、溯源、不可篡改、以信用为基础、下一代价值互联网之类,这些都是区块链的特性,不是区块链的组成,这些词都在说区块链有什么,没有说区块链为什么会有那些,以及为什么要有那些。 感觉上很多东西都没有明确的定义,比如,计算机是什么?都知道是那么一个东西,可以打游戏上网,稍微专业点的会说是基于冯诺依曼体系结构的、有 5 个组成部分的什么什么机器。其实计算机可以认为是“做计算的机器”,就这么简单。冰箱是什么?“一个放东西的柜子,有一些冷冻的功能”,就可以了。设计模式是什么?一种软件程序设计的范式。微服务是什么?一种软件架构的模式。所以区块链是什么?奇怪的是,区块链(blockchain)这个词不知从何而起,从来没有人明确提出这个概念,比特币白皮书里也只是提到 “chain of block”。 我以前对区块链有过一些不成熟的认识,虽然好像也没什么错,但不够清晰,尤其是没搞清楚一个问题,区块链是什么?现在来看,区块链的定义应该是: 区块链是一种数据协同软件,或者说,区块链是一种用来同步数据的软件。 数据协同软件决定用什么样的数据结构通过什么样的通信机制同步哪些数据。区块链不是数据库,区块链不负责储存数据,储存数据的事情会交给真正的数据库来做,区块链并不关心数据是怎么存在磁盘上的,不关心储存结构是否合理,利用率高不高,处理速度快不快。区块链关心数据以什么样的方式同步到其他的机器上,如何及时同步,以及其他机器同步过来的数据有没有问题。可以说,区块链是对数据协同软件的一种实现。 因为是数据协同软件,所以区块链多节点、去中心化,这显而易见。 溯源是指交易可溯源,只要数据之间有关联关系就可以,这是数据模型决定的,比如 UTXO。 链式的数据结构,是为了方便数据协同软件校验数据的完整性,类似用 md5 判断文件是否完整。这种数据结构并不是必要的,数据的全量对比也可以实现目的,只是效率非常低下。所以采用加密算法做摘要然后放到下一部分数据里的做法,相当于保证了一大块数据是完整的,仅此而已。 至于不可篡改,其实是数据协同软件带来的特性。区块链的不可篡改,并不是数据不能修改,而是改了之后其他节点不认可。这是不一样的,数据不能更改是技术问题,比如不提供更新数据的接口,用户就没有修改数据的渠道,通过技术手段可以控制。改了之后其他节点不接受,是一种机制,这种机制问题已经脱离技术领域。 区块链目前的发展受技术限制吗?计算机的计算理论包含两个主要部分,可计算性理论和复杂度理论。可计算性理论判断一个问题能不能用算法解决,复杂度理论意在提高算法的效率。和区块链有关系吗?退一步说,区块链需要计算吗?不需要,没有关系,不受限制。有个有趣的脑洞问题,如果把全世界的人都拉到一个微信群里,会发生什么?起码屏幕上的消息肯定刷不过来了。如果全世界的数据共用一条区块链,会发生什么?所以区块链最终还是机制的问题,不是技术问题。 比特币和区块链是两个概念,比特币是一种使用了区块链做数据同步的交易系统,比特币首先是一个交易系统,其次才需要的数据同步。这也是我以前犯的概念上的错误,把区块链等同于比特币了。很多对区块链概念比较模糊的人,提到区块链也都会往比特币之类的数字货币上想。记得去年参加过一个分享会,主讲人是某知名交易所总监,分享标题是区块链和国家政策什么的,整个会议下来,讲的却全是比特币的趣闻轶事。 区块链是比特币的组成部分。比特币的作者看到了比特币的价值,把软件和白皮书发布出来了。为什么比特币的作者没有把区块链的概念抽离出来,发个通用软件和说明书?是水平不够没有意识到区块链潜在的巨大价值吗?不是。区块链的提出,是因为人们看到了比特币的价值,想要复制比特币的成功,所以把比特币的技术组成提取出来,叫做区块链。可惜比特币是一个设计巧妙的系统,单独把某些技术特点拿出来难以产生预期的价值,这也是区块链的现状。这是现代版技术圈的东施效颦。 智能合约(Smart Contract)早在 1997 年由一位金融、法律从业者提出,“智能”是指和纸质合同相比,智能合约达到某一条件时就会自动执行某些操作,确实比纸质合同智能了一点,尤其在那个年代,数字化还没有普及,描述这是一种智能并不为过。而且,作者明确说,智能合约没有用到人工智能。 智能合约抽象一下,达到某一条件自动执行一些动作,不就类似编程语言的条件语句吗,事实上现在的智能合约大多是用图灵完备的编程语言实现的。用编程语言来描述合约的致命问题在于,编程语言的表达能力比自然语言弱太多了,如果试图用编程语言来重写保险说明书里的所有条文,“发生什么,就赔偿多少……”,这种改写的成本太高了,而且很多时候法律条文需要专业律师、法官解释和判断,现实世界的逻辑远比程序逻辑复杂,编程语言是搞不定的。 一种数据同步软件不应该被推崇,区块链被神化、妖魔化了。也因此,不能说区块链没有价值,因为区块链是且只是一种工具软件。

2020/8/9
articleCard.readMore

从 Erlang 开始了解 Actor 模型

Actor Model是一个宽泛的概念,早在上个世纪就被提出来,它将Actor视作一个整体,可以是原子变量,也可以是一个实体,也可以代表一个线程,Actor之间相互通信,每个Actor都有自己的状态,在接收到其他Actor的消息后可以改变自己的状态,或者做一些其他事情。一般提到Actor,会用Erlang、Elixir或Akka来举例,它们都在一定程度上实现了Actor模型。 前端的MVVM框架React、Vue等都有各自的数据流管理框架,比如Redux和Vuex,这些数据流管理框架中有几个类似的概念,Action、Reducer、State之类,这些概念有时候会让人感到迷惑。现在前端变得越来越复杂,其中有一些东西可能是借鉴后端的,像TypeScript的类型系统。我好奇这些前端框架里的Action和后端的Actor模型在概念上是否有相似的地方。 其实Action的本质是简单的,甚至代码的原理也是简单的,reducer里面用switch判断不同的操作类型,去调不同的方法。最简化的形式就是一个方法Action改变了全局变量state的值。Redux文档里说它的设计来自Flux架构,Flux架构的来源暂时不得而知,但也不太可能说是受到了Actor模型的启发。 let state = nullfunction action(val) { state = val} Erlang是一门古老的编程语言,也是一门典型的受Actor Model启发的编程语言。单纯去理解概念是空泛的,从具体的、特定的语言入手也许能帮助我们探索这些理论。就像学习FP,选择Haskell要好过Java很多倍。Elixir是基于Erlang虚拟机的一门语言,与Erlang的关系类似Scala和Java的关系,也因此Erlang的语法相对简单和干净一点。 Erlang Erlang的代码块以.结尾,代码块可能只有一行,也可以有多行,.的作用类似于},只是Erlang里没有{。代码块内的语句以,结尾,意味一个语句的结束,相当于一些语言的;。 Erlang将一个程序文件定义为一个模块,在命令行中使用c(test).可以加载模块。模块名称必须和文件名称一致: -module(test). 文件头部需要定义程序export的函数,这是模块的出口: -export([start/0, ping/3, pong/0]). 这里导出了3个函数,方括号和其他语言一样表示数组,函数名称后面的/0、/3指函数参数的个数。start函数将作为程序的主入口,负责启动整个程序,ping负责发送消息,pong负责接收消息并做出响应。 Erlang里面有个process的概念,它不是线程,也不是指计算机层面的进程,它就是process,或者也能把它当做线程,但是要明白它和线程不一样。我们将启动两个process,一个负责ping,一个负责pong,模拟消息的传输和交互。可以类比启动了两个线程,一个负责生产,一个负责消费。 ping(0, Pong_PID, StartTime) -> Pong_PID ! {finished, StartTime}; 这是ping函数的第一部分,是ping函数的一个分支,接收3个参数,如果第一个参数是0,就会执行这个函数中的语句。第二个参数Pong_PID指包含pong的process,第三个参数指程序启动的时间,用于记录程序的运行时长。函数体内只有一个语句,!是发送消息的意思,意为将数据{finished, StartTime}发送到id为Pong_PID的process中,其中finished是一个Atom,作为标识发送到pong那里。Atom是Erlang的数据类型之一,相当于……不需要声明的常量。 ping(N, Pong_PID, StartTime) -> Pong_PID ! {ping, self()}, receive pong -> io:format("~w~n", [N]) end, ping(N - 1, Pong_PID, StartTime). 这是ping函数的第二部分,如果函数接收到的第一个参数不等于0,就会执行这个函数内的语句。这一部分函数在接收到请求后,首先会做和分支一同样的事情,就是把数据{ping, self()}发送给pong,区别在于这里的标识为ping而不是finished,pong那里会根据这个标识做不同的操作,至于第二个参数,self()会返回当前process的id,也就是把ping的id传给了pong,用以pong回复消息。pong会选择性的使用第二个参数。 把数据发送到pong之后,有一个receive ... end的代码段,这个代码段会阻塞当前程序的执行,直到当前process接收到数据。代码段里是一个简单的模式匹配,pong是一个Atom类型的变量,如果接收到pong这样的标识,就会执行->后面的语句。io:format是一个简单的格式化输出,把N的值打印到屏幕上。 receive结束之后,马上又调了一下ping自己,递归……直到N为0,也就是说ping和pong的交互会持续N次,io:format那里会把交互次数打印出来。这是ping函数的两个分支,pong函数和ping函数的程序类似: pong() -> receive {finished, StartTime} -> io:format("The End"); io:format("~w~n", [erlang:timestamp()]); io:format("~w~n", [StartTime]); {ping, Ping_PID} -> Ping_PID ! pong, pong() end. pong函数在入参层面没有分支,但是receive里有两种匹配,如果接收到了结束标识finished,会把开始时间和结束时间都打印出来,然后程序结束。如果接收到的标识是ping而不是finished,首先给Ping_PID也就是ping的process一个pong的响应,然后调了一遍自己,相当于先发了一个消息出去,接着自己等待消息的回复,如果没有收到回复,它就一直等着。 start() -> Pong_PID = spawn(test, pong, []), spawn(test, ping, [10, Pong_PID, erlang:timestamp()]). 最后是start函数,程序的入口函数,spawn了两个process,这两个process分别单独地运行。当传入ping的第一个参数为10,ping和pong的交互将持续10次。 交互速率 以前听到过一个所谓的“大牛”讲,我们现在想要提高计算机的速率,瓶颈是什么呢,我们应该往哪个方向努力呢,应该是CPU的利用率,Actor是很快的,为什么快呢,因为一个Actor就是一个整体,一个Actor只在一个内核中运行,连CPU内核之间的交互都省了……这种说法的正确性可能有待验证,不过Actor是否真的快呢,我有点好奇,也因此萌生了测试一下Actor速度的想法。 必须要说明的是,我也相当清楚,这种测试方法很不靠谱。 在Erlang程序里启动两个process,两个process之间相互通信,测试不同数量级的通信次数,记录下程序执行所花费的时间。与Erlang作为对比,在Java里启动两个线程,用线程的睡眠和唤醒实现线程间的通信。同样的,在Go语言里用两个协程通信。至于Akka……其实也是Actor的代表。下表是测试之后的结果,次数从1到1亿,时间单位为毫秒。 次数ErlangJavaGoAkka 10003 100107 10034117 1,0002630483 10,00061016842225 100,00027831295404674 1,000,00027,08511,30044893515 10,000,000273,912107,67340,33529,368 100,000,0002,851,6801,092,879482,196300,228 本来尝试用Echarts之类渲染一下这些数据,方便对比,后来发现这些数据绘制出来的折线图并不友好。 总的来看,Erlang的速度是最慢的,这可能和Erlang历史悠久有关,也许是因为没有得到足够的优化,相信Elixir的速度会好一些。相较之下,Java的速度胜过Erlang,Go语言的速度胜过Java,这似乎是意料之中的事情。Java的耗时是Erlang的1/3,Go语言的耗时是Java的1/2。 最让人惊讶的在于,Akka的Actor速度竟然比Go语言的协程还要快。在交互1000次之前,Akka的速度比Erlang还要慢,在10K数量级的时候,它的速度超过了Erlang,在100K数量级的时候,速度超过了Java,直到1M数量级的时候,Akka超过了Go语言,并且一直保持领先。这是一个令人难以置信的结果,同样是运行在JVM上,Akka的耗时是Java的1/3,可能Java线程间的交互确实带来了很大的开销。 没有用Elixir做测试是一个遗憾。关于Akka为什快,和Actor模型有没有关系,有多大的关系,还需要进一步探索。 (The End) Akka 用来做测试的Akka程序是Akka官方的Hello Wrold程序,能看到明显的Actor模型的影子,尤其是!运算符和receive方法。 import akka.actor.typed.ActorRefimport akka.actor.typed.ActorSystemimport akka.actor.typed.Behaviorimport akka.actor.typed.scaladsl.Behaviorsimport GreeterMain.SayHello 这是导入部分,如果使用VS Code之类的编辑器,这段代码还是很重要的。和Erlang的程序类似,有一个发消息的Greeter和一个接收并回复消息的GreeterBot,另外还有一个主方法。 object Greeter { final case class Greet(whom: String, replyTo: ActorRef[Greeted]) final case class Greeted(whom: String, from: ActorRef[Greet]) def apply(): Behavior[Greet] = Behaviors.receive { (context, message) => message.replyTo ! Greeted(message.whom, context.self) Behaviors.same }} 这是发消息的Greeter,当Greeter作为函数被调用,会自动执行apply中的代码。apply方法是一个receive,和Erlang的receive一样会阻塞程序直到Actor接收到消息。replyTo是GreeterBot的”pid”,Greeter接收到消息后会回复消息给GreeterBot。 object GreeterBot { var startTime = System.currentTimeMillis() def apply(max: Int) = { bot(0, max) } private def bot(greetingCounter: Int, max: Int): Behavior[Greeter.Greeted] = Behaviors.receive { (context, message) => val n = greetingCounter + 1 context.log.info("{}", n) if (n >= max) { context.log.info("The End | {}", System.currentTimeMillis() - startTime) Behaviors.stopped } else { message.from ! Greeter.Greet(message.whom, context.self) bot(n, max) } }} 这是GreeterBot,和Erlang简洁的代码比起来,Scala冗长的类型声明可能显得有些……烦杂。GreeterBot接收到来自Greeter的消息后,判断n是否为max,如果已经执行够次数了,就停止,否则调用自己进行递归。 object GreeterMain { final case class SayHello(name: String) def apply(): Behavior[SayHello] = Behaviors.setup { context => val greeter = context.spawn(Greeter(), "greeter") Behaviors.receiveMessage { message => val replyTo = context.spawn(GreeterBot(max = 10), message.name) greeter ! Greeter.Greet(message.name, replyTo) Behaviors.same } }}object AkkaQuickstart extends App { val greeterMain = ActorSystem(GreeterMain(), "AkkaQuickStart") greeterMain ! SayHello("Charles")} 最后是主方法,看着可能也有点……长。继承于App的类是能够运行的主类,向Actor系统中注册了GreetMain,同时GreetMain的apply方法被执行了一次。GreetMain里spawn了两个process,和Erlang的程序行为是类似的。 Go Go语言的程序真的要简洁很多,这是程序头部: package mainimport( "fmt" "time")var maxCount = 100000000var startTime = time.Now().UnixNano() / 1e6 定义了两个变量,一个是程序执行次数,一个是程序开始时间。 func main() { ch := make(chan bool) exit := make(chan bool) go func() { for i := 0; i < maxCount; i++ { fmt.Println(i) <- ch ch <- true } }() go func() { defer func() { timeUsed := time.Now().UnixNano() / 1e6 - startTime fmt.Println("The End | ", timeUsed) close(ch) close(exit) }() for i := 0; i < maxCount; i++ { ch <- true <- ch } }() <- exit} 两个协程,从channel中取数据和向channel中写数据交替。Go语言的程序看着清爽太多了,Scala扎眼睛。 Java Java的冗长程度不比Scala轻。 public class Test{ public static void main(String[] args) { Object lock = new Object(); Thread sender = new Sender(lock); Thread receiver = new Receiver(lock); sender.start(); receiver.start(); }} 主方法里启动了两个线程,锁是共享资源。 class Message { static long MAX_COUNT = 100000000; static String status = new String("init"); static long count = 0; static long startTime = 0; public static void send() { System.out.println(count); status = "sent"; count++; if (count == 1) { startTime = System.currentTimeMillis(); } if (count >= MAX_COUNT) { status = "stop"; long time = System.currentTimeMillis() - startTime; System.out.println("The End | " + time); } } public static void receive() { status = "received"; } public static String getStatus() { return status; }} Message是临界资源,储存消息的内容。消息内容变更时做了一点其他的事情,把需要的日志打印到屏幕上。 class Sender extends Thread { Object lock = null; public Sender(Object lock) { this.lock = lock; } @Override public void run() { while (!Message.getStatus().equals("stop")) { synchronized (lock) { if (Message.getStatus().equals("init") || Message.getStatus().equals("received")) { Message.send(); lock.notify(); try { lock.wait(); } catch (Exception e) { e.printStackTrace(); } } } } }}class Receiver extends Thread { Object lock = null; public Receiver(Object lock) { this.lock = lock; } @Override public void run() { while (!Message.getStatus().equals("stop")) { synchronized (lock) { if (Message.getStatus().equals("sent")) { Message.receive(); lock.notify(); try { lock.wait(); } catch (Exception e) { e.printStackTrace(); } } } } }} Sender和Receiver的程序类似,Sender先发送消息,然后wait,等着接收Receiver的消息,Receiver用while不停地判断有没有收到消息,如果有则回复消息,并且唤醒Sender,通知它该处理消息了,叫醒Sender后自己wait,等着Sender的反馈。

2020/3/31
articleCard.readMore

一种侧边导航栏的交互方式

最近看到几个管理系统的演示项目,结合开发过程中不顺手的地方,发现大多数网站的侧边导航栏都是点击展开,点击关闭。 感觉这样的交互方式稍微有点繁琐: 在不知道子菜单位置的情况下,需要一个一个点开才能找到需要的页面 在知道子菜单位置的情况下,也需要点击一下父级菜单,才能看到想要的子菜单 不一个一个点开,就无法知道子菜单有些什么 子菜单展开之后,需要一次一次点击父菜单才能收起 后来就想,能不能把点击事件换成悬浮事件呢?只要鼠标放上去,菜单就会自动展开,不用点一下的操作了。但是单纯的悬浮展开,需要考虑菜单长度不一致的问题,如果下一个菜单的长度比当前菜单短,鼠标离开当前菜单,当前菜单收回,鼠标所在的位置会直接越过下一长度较短的菜单。 像图片中这样,栏目二的长度是4,栏目三的长度是2,当鼠标从栏目二向下移动,离开栏目二的瞬间栏目二收回内容,鼠标在没有移动的情况下跳过了栏目三,悬浮在栏目四上,这其实是不合理的,会违背用户的预期。栏目二之后是栏目三,这是最正常的逻辑。 为了应对这一问题,也许可以将交互设计成这样,当鼠标离开栏目二后,栏目二不收回,直到鼠标离开整个导航栏,子菜单才自动折叠。如果子菜单展开时用户点击了某一父菜单,那这个父菜单即使鼠标离开导航栏也不收回。 下面是一个demo页面,通过iframe嵌入到这里,可以对比两种侧边导航栏的交互方式(移动端没有鼠标悬浮事件)。我偏爱灵活一点的交互,第二种方式单击父菜单也可以展开收起列表,相当于在方式一的基础上加入了鼠标悬浮自动展开的能力。 相较于鼠标悬浮自动展开不收回的方式,更进阶一点的做法是,当鼠标从上往下移动时,子菜单自动展开但不收回,当鼠标从下往上移动时,子菜单自动展开并且自动收回。因为子菜单要不要自动收回取决于对用户接下来的操作有没有影响。不过这样的效果实现起来有些复杂了,对于网页上的一个导航栏来说,需要不断监听鼠标的坐标,开发和和维护的成本有点高。

2020/3/21
articleCard.readMore

Rust 的 ownership 是什么?

Rust是内存安全的。Facobook的Libra使用Rust开发,并推出了新的编程语言Move。Move最大的特性是将数字资产作为资源(Resource)进行管理,资源的含义是只能够移动,无法复制,就像纸币一样,以此来保证数字资产的安全。其实Move的这种思想并不是独创的,Rust早已使用这样的方式来管理内存,因此Rust是内存安全的。Rust中的内存由ownership系统进行管理。 Java的引用计数 垃圾回收有很多种方式,ownership是其中之一。Java使用的是引用计数,引用计数法有一个广为人知的缺陷,无法回收循环引用涉及到的内存空间。引用计数的基本规则是,每次对内存的引用都会触发计数加一,比如实例化对象,将对象赋值给另一个变量,等。当变量引用被取消,对应的计数就减一,直到引用计数为0,才释放空间。 class Test { Test ref = null;}Test a = new Test(); // a的计数加一Test b = new Test(); // b的计数加一// 此时a的计数是1,b的计数是1a.ref = b; // a的计数加一,因为ref是a的类变量b.ref = a; // b的计数加一,因为ref是b的类变量// 此时a的计数是2,b的计数是2a = null; // a的计数减一,因为a的引用被释放b = null; // b的计数减一,因为b的引用被释放// 此时a的计数是1,b的计数是1 因此,在a和b的引用被释放时,它们的计数仍然为1。想要a.ref的计数减一,就要将a.ref指向nulll,需要手动操作指定为null吗?当然不需要,Java从来没有手动释放内存空间的说法。一般情况下,a.ref执行的对象也就是b的空间被释放(计数为0)时,a.ref的计数也会自动减一,变成0,但此时因为发生了循环引用,b需要a的计数变为0,b的计数才能变成0,可a要想变成0,需要b先变成0。相当于死锁。 这和Rust的ownership有关系吗?当然,没有关系…… ownership ownership有三条基本规则: 每个值都拥有一个变量owner 同一时间只能有一个owner存在 当owner离开作用域,值的内存空间会被释放 作用域多数情况由{}界定,和常规的作用域是一样的概念。 { // s还没有声明 let s = "hello"; // s是可用的} // s已经离开作用域 Rust的变量类型分简单类型和复杂类型,相当于普通变量和引用变量,因为ownership的存在,简单类型发生赋值操作是,值是被复制了一份的,但复杂类型是将引用直接重置到新的引用变量上,原先的变量将不可用。 let x = 5;let y = x; // y是5,x还是5let s1 = String::from("smallyu");let s2 = s1; // s2是"smallyu",s1已经不可用 赋值过程中,s2的指针先指向string,然后s1的指针被置空,这也就是移动(Move)的理念。如果想要s1仍然可用,需要使用clone复制一份数据到s2,而不是改变指针的指向。 let s1 = String::from("smallyu");let s2 = s1.clone(); // s1仍然可用 函数 目前提到的有两个概念,一是ownership在离开作用域后会释放内存空间,二是复杂类型的变量以移动的方式在程序中传递。结合这两个特点,会发生这样的情况: fn main() { let s = String::from("smallyu"); takes(s); // s被传递到takes函数 // takes执行结束后,s已经被释放 println!("{}", s); // s不可用,程序报错}fn takes(s: String) { // s进入作用域 println!("{}", s); // s正常输出} // s离开作用域,内存空间被释放 如果把s赋值为简单类型,比如5,就不会发生这种情况。对于复杂类型的变量,一旦离开作用域空间就会释放,这一点是强制的,因此目前可以使用函数的返回值来处理这种情况: fn main() { let s = String::from("smallyu"); let s2 = takes(s); println!("{}", s2);}fn takes(s: String) -> String { println!("{}", s); s} takes把变量原封不动的返回了,但是需要一个变量接住takes返回的值,这里重新声明一个变量s2的原因是,s是不可变变量。 引用变量 引用变量不会触发ownership的drop方法,也就是引用变量在离开作用域后,内存空间不会被回收: fn main() { let s = String::from("smallyu"); takes(&s); println!("{}", s);}fn takes(s: &String) { println!("{}", s);} 可变变量 引用变量仅属于可读的状态,在takes中,s可以被访问,但无法修改,比如重新赋值。可变变量可以解决这样的问题: fn main() { let mut s = String::from("smallyu"); takes(&mut s); println!("{}", s);}fn takes(s: &mut String) { s.push_str(", aha!");} 可变变量也存在限制,同一个可变变量同一时间只能被一个其他变量引用: let mut s = String::from("smallyu");let r1 = &mut s;let r2 = &mut s;println!("{}, {}", r1, r2); 程序会报错,这是容易理解的,为了保证内存安全,一个变量只能存在一个可变的入口。如果r1和r2同时有权力更改s的值,将引起混乱。也因此,如果是r1 = &s而不是r1 = &mut s,程序会没有问题,只能存在一个引用针对的是可变变量的引用变量。 返回值 函数的返回值类型不可以是引用类型,这同样和ownership的规则有关,返回普通变量相当于把函数里面的东西扔了出来,如果返回引用变量,引用变量指向的是函数里面的东西,但函数一旦执行结束就会销毁内部的一切,所以引用变量已经无法引用到函数。 fn dangle() -> &String { let s = String::from("smallyu"); &s;} // 到这里s的内容空间已经释放,返回值无法引用到这里 ? 没有更多内容了。 最近看了一部能够让人振奋的美剧《硅谷》,编剧给主角挖了很多坑,感觉他们倒霉都是自己作的,编剧也给观众留了很多坑,剧情跌宕起伏到想给编剧寄刀片。抛开那些情节,剧中渲染的geek真的很帅,很帅!当然,神仙打架,凡人也参与不了。

2019/12/21
articleCard.readMore

Haskell 中的 Monad 是什么?

第一次听说Monad是在一个Scala Meetup上,后来试着了解Monad的概念,却头疼于Haskell的各种大部头的书和教程。再后来看到阮一峰在2015年发表的《图解 Monad》,虽然清晰易懂,但是脱离了Haskell,图片的表意和语言中的概念对不上。阮一峰的文章译自《Functors, Applicatives, And Monads In Pictures》,我阅读了原文。 前言 计算机程序用于控制计算机进行运算,程序操作的对象是各种不同类型的值,比如数值。这是一个简单的值2: 用函数对值进行一些处理,可以返回函数执行的结果,比如: 除了简单的数值类型,值也有可能被包含在一些上下文环境中,组成更复杂的值类型。可以把上下文环境想象成盒子,数值放在盒子里面,这个盒子整体作为一个值,描述为Just 2,也就是带盒子的2: 如果对Java有过了解,可以将这个盒子理解为包装类,比如Integer和int,对应带盒子的2和不带盒子的2。 Functors 面对带盒子的2,我们无法直接把+3的函数作用在它上面: 这时需要一个函数fmap来操作。fmap会先从Just 2中取出数值2,然后和3相加,再把结果5放回盒子里,返回Just 5: fmap怎么知道该如何解析Just?换一个其他像Only之类的类型,还能解析吗?所以就需要Functor(函子)来完成定义的的操作。 Functor是一种数据类型: Functor定义了fmap的行为: fmap有两个入参和一个出参,入参分别是一个函数和一个带盒子的值,出参是一个带盒子的值,可以这样使用: fmap (+3) (Just 2)-- Just 5 回到Haskell,在Haskell的“系统类库”中有一个Functor的实例Maybe,Maybe中定义了fmap的行为,指定了面对Just类型的入参时对值进行操作: instance Functor Maybe where fmap func (Just val) = Just (func val) fmap func Nothing = Nothing 表达式fmap (+3) (Just 2)的整个过程类似这样: 同理,从Maybe的定义中能看出,如果传入fmap的第二个参数是Nothing,函数将返回Nothing,事实确实如此: fmap (+3) Nothing-- Nothing 现在假设一个Java的场景,用户使用工具类Request发起一个向服务器的请求,请求返回的类型是Response,Response是一个实体类,可能包含所需数据data也可能不包含: Response res = Request.get(url);if (res.get("data") != null) { return res.data;} else { return null;} 使用Haskell中fmap的写法就变成了: fmap (get("data")) (Response res) 当然Haskell不存在get("data")这样的写法,可以将由Response获取Response.data的操作封装为函数getData,然后传入fmap作为第一个参数。 Haskell提供了fmap函数的语法糖<$>简化fmap的写法: getData <$> (Response res) 再来想一个问题,Haskell的函数是如何对列表进行操作的?函数会对列表的每一个元素都进行计算,然后返回列表: 其实列表也是Functions,这是列表的定义: instance Functor [] where fmap = map Applicatives Applicatives是另一个概念,我们之前说数据被放在盒子里,如果函数也被放在盒子里呢? Haskell的系统提供了操作符<*>用于处理盒子里的函数: 例如: Just (+3) <*> Just 2 == Just 5 使用<*>还可以完成一些有趣的操作,比如分别让列表中的元素*2和+3: [(*2), (+3)] <*> [1, 2, 3]-- [2, 4, 6, 4, 5, 6] Monads 函数的执行是使用带入参的函数处理值,涉及到三个角色。Functors是被处理的值放在盒子里,Applicatives是函数放在盒子里,Monads则是将函数的入参放在盒子里。Monads有一个操作符>>=来实现Monads的功能。假设现在有一个函数half的入参是数值,如果是偶数就除以2,否则返回Nothing: half x = if even x then Just (x `div` 2) else Nothing 想要给half传一个Just类型的值怎么办? >>=可以解决这个问题: Just 3 >>= half-- Nothing >>=操作符把Just 3变成了3放在half中进行计算。Monad是一个数据类型,定义了>>=的行为: class Monad m where (>>=) :: m a -> (a -> m b) -> m b 这里的Maybe是一个Monad(和上文的Maybe同时存在): instance Monad Maybe where Nothing >>= func = Nothing Just val >>= func = func val >>=还支持链式的操作: Just 20 >>= half >>= half >>= half-- Nothing 小结 虽然Haskell的Monad比较有名,但实际上涉及到三个概念Functors、Applicatives和Monads,可能Monad的应用比较广泛一点。在数据处理上,FP并不比OOP高级,逻辑是相似的,只是写法不同。面对同样的问题使用不同的思维方式和表达方式去解决,对应了不同的编程思想和编程范式。世界上有很多精妙的理论等待我们探索。

2019/11/26
articleCard.readMore

浅析 Libra 背后的区块链技术

前段时间国家领导人曾公开表示鼓励区块链技术的研究,称要把区块链作为核心技术自主创新的突破口。Libra的发行计划是区块链发展史上一座重要的里程碑,本文从合约语言、数据库协议、逻辑数据模型、数据结构、共识协议等方面简要介绍Libra区块链的技术方案。 2019年5月,Facebook首次确认推出加密货币的意向,“全球币”、“脸书币”的消息不胫而走。6月18日,Facebook正式宣布将推出名为Libra的加密货币,预计2020年上半年针对性发布。Facebook宣称,Libra建立在安全、可靠、可扩展的区块链上,采用链外资产抵押的模式,锚定一篮子法定货币作为资产担保,由独立的Libra协会治理。Facebook在全球拥有27亿用户,Libra的愿景是建立一套简单、无国界的货币,为数十亿人提供金融服务。 Facebook正式宣布Libra后,同步上线了Libra的官网、白皮书和测试网络等内容。白皮书中提到,Libra网络希望未来以公有链的形式运作,但由于目前没有成熟的技术能在公有链上支撑大规模的交易,Libra将以联盟链的形式起步,计划三到五年内展开由联盟链过渡为公有链的研究。 Libra官网公布了三篇论文详细说明Libra使用的技术方案,这三篇论文分别是《Libra区块链》、《Move:一种具有可编程资源的语言》和《Libra区块链中的状态机复制》。Libra为满足高度安全、足够灵活、吞吐量极高等要求,设计了新的编程语言Move,选择BTF共识机制,采用广泛使用的Merkle Tree作为数据结构。本文简要介绍Libra区块链的相关技术。 Move 当现实世界的资产进入Libra储备,系统会创建相应的数字资产Libra货币,这些数字资产在不同账户之间流通,当现实中的资产离开Libra储备,对应的数字资产也随之销毁。Fackbook设计了新的编程语言Move用于对Libra中的数字资产进行管理,Libra在Move中将数字资产表示为资源(resource)。 Move是带有类型的字节码语言,资源是Move的类型之一,程序在执行前会先经过字节码验证器检验,然后由解释器执行。Move中的资源仅支持copy和move两种操作,可以理解为copy移出资源,move移入资源,也就是说资源和普通的变量类型不同,资源的值可以赋给普通变量,但资源本身只能在地址间移动,不能复制或丢弃。如果在程序中使用了违反规则的copy和move操作,比如copy一次move两次,程序将无法通过字节码验证器,因为在第一次move后原先的资源就已经不可访问了。 使用Move可以编写自定义的交易逻辑和智能合约,相比现有的合约语言要更加强大。比特币脚本提供了简洁优雅的设计用于表达花费比特币的策略,但是比特币脚本不支持自定义数据类型和程序,不是图灵完备的。以太坊虚拟机倒是支持流程控制、自定义数据结构等特性,但太过自由的合约让程序的漏洞随之增多,发生过多起安全事件。Move的静态类型系统为数字资产的安全性提供了保障。 为了配合静态验证工具的验证,Move在设计上采取了一些措施:没有动态调度,让验证工具更容易分析程序;限制可变性,每一次值的变化都要通过引用传递,临时变量必须在单个脚本中创建和销毁,字节码验证器使用类似Rust的”borrow checking”机制保证同一时间变量只有一个可变引用;模块化,验证工具可以从模块层面对程序进行验证而不需要关心具体实现细节,等等。Move的这些特性都使得静态验证工具更加高效可靠。 public main(payee: address, amount: u64) { let coin: 0x0.Currency.Coin = 0x0.Currency.withdraw_from_sender(copy(amount)); 0x0.Currency.deposit(copy(payee), move(coin));} 这是一段交易脚本的示例程序,是Move语言的中间表示(IR),IR更适合程序员阅读和编写。程序实现了一个转移资源的函数,main方法是脚本的入口,包含两个入参:目标地址和金额。程序先从0x0地址Currency模块中移出amount个资源暂存到coin变量,然后将coin的资源移动到payee的地址上。 交易脚本是为Move提供灵活性的一个方面,另一方面来自安全的模块化设计。交易脚本让交易逻辑更加自由,模块化设计则让保证了脚本程序的多样化。模块的类型是module,主要包含Move程序,一个模块可以包含任意个资源,也就是声明另个或多个资源类型的变量,modules/resources/procedures相当于面向对象语言中的classes/object/methods,不同的是Move中并没有self、this之类的概念。 Libra协议 Libra区块链是一个需要经过密码学认证的分布式数据库,用于储存可编程资源,比如Libra货币就是可编程资源,在Move中表现为资源。Libra协议中有两种实体类型,验证节点(validators)共同参与维护数据库,客户端(client)通常发起向数据库的请求。Libra协议会在执行过程中选举出leader接收客户端的请求,然后leader将请求同步到其他验证节点执行,其他验证节点执行结束后把结果返回给leader,leader再把请求的最终结果返回客户端。 Libra的交易会经过很多步骤,包括验签、运行先导程序、验证交易脚本和模块程序的正确性、发布模块、执行交易脚本、运行结尾程序等。为了使合约交易的计算能力可计量,Libra吸收了以太坊中Gas的概念,消耗Gas作为交易的费用。 这张图详细展示了交易请求在Libra的网络组件中流转的过程,客户端发起请求到权限控制层,权限验证后将请求数据转给虚拟机进行预处理,同时数据也会进入内存池中,内存池负责将请求同步到其他节点,共识协议在请求同步的过程中发挥作用,节点同步结束后虚拟机执行真正的交易程序,程序执行完毕对结果持久化,基本流程结束。 逻辑数据模型 Libra区块链上所有的数据都保存在有版本号标识的数据库中,版本号是64位无符号整数。每个版本的数据库都包含一个元组 (T, O, S),T代表交易,O代表交易的输出,S代表账本的状态。当我们说执行了一个Apply操作,表示为Apply(S, T) -> (O, S),意思是在S状态下执行了T交易,产生了O输出并且账本的状态变为S。 账户是资源的拥有者,可以使用账户内的资源进行交易。账户地址是一个256位的值,创建新账户需要一个验证/签名的键值对(vk, sk),新的账户地址a由vk经过公钥加密计算得到,a = H(vk)。具体来说Libra使用SHA3-256实例化哈希函数,使用wards25519椭圆曲线做变量的EdDSA公钥进行数字签名。交易过程中由已经存在的账户调用create_account(a) 指令即可生成新账户。 上图所示有四个以0x为前缀的账户地址,矩形框表示模块,椭圆形表示资源,箭头表示依赖关系。图中0x12账户中的Currency.T在Currency模块中声明,Currency模块的代码储存在0x56地址上。同理,0x34的StatChannel.T声明自0x78的StateChannel模块。当客户端想要访问0x12下的Currency.T,请求资源的路径应写作0x12/resources/0x56.Currency.T。 数据结构 Libra交易区块中包含各节点签名的数据,交易前会对签名数据进行校验,根据这一集体签名客户端可以相信请求的数据库版本是完整有效的,也因此客户端可以请求任意节点甚至是第三方数据库副本进行查询。Libra协议中的数据结构主要基于默克尔树。 如图所示,账本历史数据的根哈希用来验证系统的完整状态,账本数据由默克尔树累加形成,虚线表示数据累积的过程。账本历史数据的每一个节点都包含交易签名、事件树和账本状态,事件树也是基于默克尔树,账本状态则是基于稀疏默克尔树,账本状态的每个叶子节点都包含有账户数据。 Libra协议中验证节点V会对数据D的根哈希a进行校验。例如不受信任的节点在获取到数据D后使用函数f对数据进行运算,希望得到结果r,同时还需要一个用于验证函数结果正确性的数据π,协议会要求节点把(a, f, r, π)都传到验证节点V处进行验证,如果f(D) = r则通过验证。 在上图中,数据D = {0:s0, 1:s1, 2:s2, 3:s3}。假设f是获取第三项数据的函数,也就是要获取h2的数据,期望结果f(D) = h2,此时h2就是r,r = h2,用于验证计算结果正确性的数据π = [h3, h4],根哈希a = H(h4||h5) = H(h4 || H(H(2 || r) || h3)),验证节点将执行Verify(a, f, r, π)对计算结果进行验证。 共识协议 Libra选择使用的是拜占庭容错共识,实现了一种HotStuff共识的变体LibraBFT,简称LBFT。LBFT协议的主要作用是让提交的块在同一个序列上,或者说避免分叉。每三次提交为一轮操作,每一次提交验证节点都会投票选举出下一轮的leader,同时这些投票的集合形成一个法定证书(QC),每一轮的第一个块记为preferred_round,下一个块写入时对preferred_round的QC进行验证,也就是preferred_round后的第一、二、三个块都与preferred_round校验,第四个块将是第二轮的preferred_round,依次更迭。 如图所示,k是preferred_round,如果在k处出现了分叉,并且有2f + 1个验证节点投票给了k,k+1将接在k的后面,k+2依次写入,k左侧的分叉失效。这时假如k+3的leader超时了,k+4成为新的leader并写入在preferred_round(k)的后面,会引起新的分叉。如果k+4获得2f+1个投票,k+5会按照规则写入在k+4后面。 在k+4分叉后,当超时的k+3再次被选为leader并重新提交,验证节点会对k+3的preferred_round(k)的QC进行校验,校验通过,k+3写入在了k+2的后面,这符合规则。再然后,下一个leader(k+6)的preferred_round实际上是k+4,准备提交块到k+3后面时发现QC对不上,k及其后的k+1、k+2、k+3都会被删除,k+4后的链成为主链。 LBFT基本上是对链式HotStuff的实现,并没有太多创新,Libra团队更多的是在共识协议中做出一种选择,对BFT的选择是好是坏还存在争议,需要时间来验证,LBFT算是Libra从联盟链到公有链过渡前的方案,预计至少支持100个节点,上限大概是1000个左右。和HotStuff一样,LBFT最多容忍三分之一的不诚实节点。 小结 Libra协议目前还处于比较早期的阶段,性能上并不算惊艳,支持每秒1000笔交易,每次提交的交易确认时间大概是10秒钟。Libra协议在设计上很多也是兼顾了性能,比如每三次提交进行一次共识,每一轮操作内不需要等待就可以进行投票,这减少了客户端和验证节点之间的网络延时;考虑到并行和分片的思想,稀疏默克尔树的使用使得账户的身份数据可以跨数据库进行验证,也支持并行更新等等。 Libra选择了众多成熟的技术构建Libra系统,使用内存安全的Rust编写核心程序、使用容易验证的Merkle Tree作数据结构、基于Chained HotStuff实现共识协议等等。Move是Libra在技术上最大的亮点之一,在语言层面保证了数字资产的安全性,Move本身是一种字节码语言,难以阅读,所以提供了Move的中间表示IR,用于编写交易脚本和智能合约。Libra作为一种在金融领域的创新实验,基于区块链提出了世界货币的愿景,其社会意义可能要远大于在技术创新上所带来的意义。Libra协会目前拥有16个成员组织,涵盖支付业、电信业、区块链业、风险投资业等领域,已经在世界范围引起广泛关注。 Libra预计2020年上半年针对性发布,让我们拭目以待!

2019/11/20
articleCard.readMore

对区块链的理性认识

曾以为区块链是革命性的、颠覆性的技术,毕竟区块链和人工智能、大数据并列互联网前沿技术。但是,人工智能达到真正的智能暂时还是梦,大数据也实现不了像《复联》里一样的精准分析。前几天国家领导人对区块链的讲话振奋人心,可过去几年的物联网、互联网+,不也都无疾而终了吗。 公有链无法脱离货币 有的观点认为,核反应最初的目的是建造核弹,而核反应现在也作为电能的来源服务于人民,区块链最初的目的是支持比特币的运作,现在我们想要区块链应用于其他方向。也就是说,观点认为核反应的位置和区块链是对等的。 核反应 -> 核弹区块链 -> 比特币 可是仔细想想,到底该如何对比这几乎完全不一样的两种事物。如果你看过几本区块链相关的书,会发现讲的东西并不会特别新鲜,观点也完全够不上所谓革命、创新。比特币诞生至今不过10年左右,并没有克服技术上的难题,只是不同机制的组合,它应该和P2P或者某种电子游戏处在同一地位。比特币的价值在于进行电子交易,而不是货币本身。 核反应 -> 核能量 -> 大爆炸区块链 -> 比特币 -> 电子交易 因为核反应,才能产出核能量,有了核能量,才能够产生大爆炸。因为区块链,才有了比特币,有了比特币,才能够进行电子交易。所以这样来看,核能量是离不开核反应的,比特币是离不开区块链的。 那么,区块链能够离开比特币单独应用于场景吗?或者说,其实区块链和比特币是一体的,就像核反应产生出的核能量,有价值的是能量而不是反应,区块链产出比特币,运作于区块链上的比特币才有所谓匿名、不可篡改、可溯源等特性,有价值的是比特币而不是区块链。 几天前广州市政府发布补贴区块链企业的细则(实施细则),明确要求“无币”公有链项目。这项政策,一方面是国家不允许发币,另一方面,公有链无币其实是区块链最理想的情况,一般来说,理想是难以实现的。 比特币解决的是交易中的信任问题,解决方式归根结底是数据存在哪儿。两个人进行交易,如果由交易发起方记账,或者交易接受方记账,或者两个人都记账,无论谁记,只要有一方说谎,甚至不说谎,他就是记错了,都会产生争执,不认账怎么办!这时就需要第三方机构介入,通过银行记账,通过律所解决纠纷。要是连银行、政府、法律都不相信,就不用活了。 如果真的不信任中心化的机构,比特币提出的办法是,让全世界的人都为你记账,全世界的人都会记住两个人的交易记录,谁给谁转账多少,这样无论如何都不再会有差错,除非全世界一半以上的人都犯了同样的错误。所以问题在于,凭什么让全世界的人为两个人的个人交易记账?人家为什么要记?于是将比特币作为奖励,谁记账了,并且被系统认为记的账是有效的,谁就可以得到奖金。 没有奖励,世界上的人不会主动为你记账,分布式账本还怎么维持运行? 比特币并非去中心化 有人认为分布式记账、分布式数据库就已经是去中心化了,但这一定不是去中心化的最终形态。比如,比特币程序的开发、维护和升级?数据确实天下共享,但程序还是要有人制定规则,有人开发,有人发布,出bug了要有人修复,有更好的点子了要迭代升级,分布式的数据全部经由中心化的程序发布中心发布的程序处理。目前解决程序上信任的方式是将程序开源……这一点暂且可行,但是程序升级带来的困难就要大多了,要么确保向下兼容,要么确保所有人更新程序。 另外,比特币的数据冗余是个极大的问题,每个节点都需要备份全量数据,而且大多数是不相关的历史数据。如果单个节点不保留全部数据,就无法保证分布式数据的可靠性,但如果保留全部数据,又是对资源很大的浪费。中心化系统一份数据就可以解决的问题,为了能够相互信任,就多出来几十亿份数据?就好比我不相信银行,就自己造一个银行,自己管自己? 可以畅想一下,在牺牲去中心化概念的情况下,能够有哪些可能。 一、全球共用一个数据库,数据库只承担储存数据的任务,分布式程序只解决共识问题。数据库非常安全,数据容量非常大,但是写入规则严格,需要全球一半以上的人认可,或者通过其他的共识机制准入。任何人可以随意查询,可溯源,历史数据不能修改。共识程序是必要的,决定了哪些数据可以写入,比如判断余额是否足够,而且是全世界的人一起判断,如果有坏人想要写入非法数据,需要买通全球一半以上的人……这样数据冗余最少。 二、每个节点只保留一半数据,数据拆分为历史的一半和当前的一半。一个人储存最新的一半数据,另一个人储存旧的一半数据,旧数据只需要负责储存,当新数据过多时同步到旧数据这里。新数据负责接收广播、写入数据,功能等同于现在的节点,如果遇到需要查询历史数据的情况,就从旧数据的一半查。相当于两人合作完成一个节点,新旧节点随时随机搭配,节点的新旧由系统平均分配。至于安全性,因为全网的节点随机配对,应该不会低于比特币,最坏的情况是一半的节点全部挂掉。同理,可将两份数据扩展到多份数据的情况。 三、每个节点只保留一半数据或者更多份,数据对半拆分。就是同一条数据,按照一定规则拆分为多个数据包,分别储存在不同的节点,参考HDFS的储存方式,存在一定冗余,但又节省了不少空间。再激进一点,数据可以实现自验证,网络中的每个节点储存的数据大小是随机的,当用户查询某一条数据时,从全网的节点中搜寻可以组成所需数据的节点,然后从中取出数据。也就是说整个节点网络的数据都混杂在一起,难点变成了如何给数据包设计自验证机制。 数字货币和区块链没有关系 有的人谈到区块链的应用,会把央行关于数字货币的研究给扯上,甚至某交易所知名总监,以区块链为主题的演讲,却把比特币和Libra的趣闻轶事说了一遍。很多人都在忽略概念上的区别,这无关紧要,也至关重要。央行说有发行数字货币的计划,也说过区块链可以作为技术选择之一,但区块链从不是必须的技术。区块链对于国家的意义,是“以去中心化之名,行中心化之实”,意在一统国内互联网,方便监管。即使没有区块链,国家也有能力实现各种应用,只是借势上了区块链的船而已。 过去的区块链指支撑比特币运行的技术体系,未来的区块链将几乎约等于联盟链。 华为区块链白皮书中的观点很客观,区块链是互联网的补充,它不会脱离传统数据库,离不开TCP,只是在特定场合下发挥独特的作用。对于国家来说,链上的数据清晰可见,没有人能暗箱操作;对于企业来说,可以方便的实现制衡,几家企业合作共享一组数据,区块链则是打开大门的钥匙。如果没有区块链,可能说不上来数据共享是个什么样子,区块链诞生了,并且比特币在世界范围稳定运行了十多年,所以这是可信的、有前途的技术方向,大家都争先恐后创新、落地。 可以预见,未来区块链的开发会分为两类,一类底层开发,一类应用层开发。底层开发的技术要求更高,开发者素质更高,应用层开发则类似于现在的Web开发。会先后出现一些区块链应用提供商,也会相应的出现一些SDK,开发者调用区块链储存数据、进行交易,类似于现在调用数据库提供的API、请求支付机构的接口。 所以 区块链会被广泛应用到我们的网络中,但不足以改变世界。(不要笑) 更新 无意间发现了分布式网络 ZeroNet ,是一个早在2015年就发布的项目,它几乎满足了所有我对区块链储存系统的想象,而且功能完备,可以基于这个网络搭建博客、论坛、邮箱、共享文件等。当然,我曾想到的、应该存在的问题,ZeroNet也一个都没有解决,算是对我的一些想法的验证,惟一不同的是我希望将分布式网络对接到公网,但ZeroNet的做法是建立了一套自治的网络系统,包括.bit域名也只能作为URI的后缀,这无疑限制了该网络无法被更加广泛传播使用。另外,由于点对点文件系统难以监管,GWF将ZeroNet列入名单,这虽然是特殊现象,但ZeroNet和IPFS等网络似乎可以说明,区块链最适合也只能应用于金融领域或者受限制的互联网中。

2019/11/5
articleCard.readMore

Rust 基础语法概述

Rust是复杂度和应用场景都对标C++的语言,一起学习吧! 最近,我开始思考像本文这样类型的内容算什么,编程语言的教程?内容不全面;对语言的评价?够不着;学习笔记?如果是,那绝非我本意。我倾向于认为这是一个探索的过程,无论对于我自己还是对于别人,我希望可以表现出来的是,你看,新的编程语言没什么神秘的,它如此简单!有的程序员终其一生,都将某种语言作为自己职业头衔的前缀,“Java程序员”或是“后端开发”,我们该跳出这种怪圈。 语句 Rust必须以;结尾。 常量和变量 Rust使用let定义常量,使用let mut定义变量。这样的写法可能稍微有点奇怪: fn main() { let x = 1; println!("{}", x); let mut y = 2; println!("{}", y); y = 3; println!("{}", y);} 不同于其他语言的是,Rust允许在同一作用域中多次声明同一常量。也就是说,Rust里的常量虽然不可以被第二次赋值,但是同一常量名可以被多次定义。我们虽然能在系统层面明白常量和变量的区别,但是写法上稍微有点容易引起混淆。我多次给同一组符号赋值,这个符号不就是变量吗? fn main() { let x = 1; println!("{}", x); let x = 2; println!("{}", x);} 另一个有点奇怪的地方是,Rust的变量不允许重复定义。我们无法推测语言设计者的初衷,这明显不是为了允许重复定义而允许。也许,Rust中只存在常量,mut关键字的作用就是给常量一个可以被多次赋值的接口。没有mut,常量就是个常量,有了mut,常量就有了获得新值的“入口”。至于变量重复定义的问题,要啥自行车? fn main() { let mut x = 1; let mut x = 2;}// warning: variable does not need to be mutable 控制流 Rust的条件部分不需要写小括号,和Go语言一样。谁先谁后呢? fn main() { let number = 2; if number == 1 { println!("1") } else if number == 2 { println!("2") } else { println!("3") }} 由于if语句本身是一个表达式,所以也可以嵌套进赋值语句中,实现类似其他语言三目运算符的功能。(Rust是强类型的语言,所以赋值类型必须一致。) fn main() { let number = if true { 3 } else { 4 }; println!("{}", number);} 与Go语言简洁的多功能for循环相比,Rust支持多种类型的循环: fn main() { loop { // ... } while true { // ... } let a = [1, 2, 3]; for item in a.iter() { println!("{}", item); }} 函数与值的传递 Rust似乎不存在值传递与引用传递的区别,因为Rust中全都是引用传递,或者分类为常量的传递与变量的传递。对比Java中字符串的创建,Rust中创建字符串也可以使用“声明对象”的方式: fn main() { // 常量传递 let a = String::from("a"); testa(&a); // 变量传递 let mut b = String::from("b"); testb(&mut b); println!("{}", b);}fn testa(a: &String) { println!("{}", a);}fn testb(b: &mut String) { b.push_str(" b");} 函数当然也是可以有返回值的,Rust中函数的返回值用->定义类型,默认将函数最后一行的值作为返回值,也可以手动return提前结束函数流程。需要注意的是,在最后一行用来作为返回值的表达式,记得不要加封号…… fn main() { let mut a = test(); println!("{}", a); a = test2(); println!("{}", a);}fn test() -> u32 { 1}fn test2() -> u32 { return 2;} 结构体 结构体的基本用法比较常规,没有new关键字,直接“实例化”就可以使用: struct Foo { a: String, b: i32}fn main() { let t = Foo { a: String::from("a"), b: 1, }; println!("{}, {}", t.a, t.b);} 同样可以给结构体添加方法: struct Foo { a: String, b: i32}impl Foo { fn test(&self) -> i32 { self.b + 1 }}fn main() { let t = Foo { a: String::from("a"), b: 1, }; println!("{}, {}, {}", t.a, t.b, t.test());}// a, 1, 2 列表与模式匹配 下面的例子创建了包含3个元素的向量,然后将第0个元素赋值给常量one。之后使用模式匹配判断列表的第0个元素是否等于one的值,如果相等则输出字符串”one”,否则为”none”。Rust的模式匹配中,Some()和None都是内置的关键字: fn main() { let v = vec![1, 2, 3]; let one = &v[0]; println!("{}", one); match v.get(0) { Some(one) => println!("one"), Some(2) => println!("two"), None => println!("none"), }} 错误处理 panic函数用于抛出异常: fn main() { panic!("new Exception");}// thread 'main' panicked at 'new Exception', test.rs:4:3// note: Run with `RUST_BACKTRACE=1` environment variable to display a backtrace. 针对错误处理,Rust提供了两个简写的方法,用于便捷的处理错误信息。unwrap()函数会自动抛出panic,如果不使用unwrap(),程序则会跳过发生panic的代码。这在某种程度上与Java的异常处理逻辑相反,因为Java如果不对异常进行处理,程序就无法继续运行。而Rust如果使用unwrap()对panic进行处理,程序将不再继续执行,同时打印出错误信息。 use std::fs::File;fn main() { let f = File::open("hello.txt"); println!("a"); let f2 = File::open("hello.txt").unwrap(); println!("b");}// a// thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }', src\libcore\result.rs:999:5// ... 另一个简写的方法是expect(),可用于替代unwrap()。它与unwrap()的区别在于,unwrap()使用系统内置的panic信息,而expect()可以传入参数作为panic的错误信息。仅此而已。 use std::fs::File;fn main() { let f = File::open("hello.txt").expect("Failed to open hello.txt");}// thread 'main' panicked at 'Failed to open hello.txt: ...// ... Lambda表达式 Rust中的Lambda表达式使用|作为入参的界定符,即使用||来代替()。此外Lambda的公用和其它语言是相同的: fn main() { let test = |num| { num == 1 }; println!("{}, {}", test(1), test(2));}// true, false 其他 Rust的语言特性远不止此,尤其是Rust与众不同的内存管理机制,以及让Rust新手得其门不得其道的概念”ownership”,都需要我们不断前行。

2019/8/19
articleCard.readMore

基于 Java 的爬虫框架 WebCollector

Long, Long Ago,网络上出现大量Python爬虫教程,各种培训班借势宣扬Python,近几年又将噱头转向人工智能。爬虫是一个可以简单也可以复杂的概念,就好比建造狗屋和建筑高楼大厦都是在搞工程。 由于工作的缘故,我需要使用WebCollector爬取一些网页上的数据。其实宏观上,爬虫无非就是访问页面文件,把需要的数据提取出来,然后把数据储存到数据库里。难点往往在于,一是目标网站的反爬策略,这是让人比较无奈的斗智斗勇的过程;二是目标网页数量大、类型多,如何制定有效的数据爬取和数据分析方案。 概述 这是一张简略的概念图,受屏幕宽度限制,可能无法看清内容,请在新标签页打开图片,或者直接点击 这里。这张图片并不是完美的,甚至还包含不完全正确的实现方式,具体内容会在后面阐述。 我将目标网页分为4种类型: 静态的网页文档,curl就可以加载到 需要自定义HTTP请求的页面,比如由POST请求得到的搜索结果页面,或者需要使用Cookie进行鉴权的页面 页面中包含由JavaScript生成的数据,而我们需要的正是这部分数据。由于js是加载后才执行的,就像CSS加载后由浏览器进行渲染一样,这样的数据无法直接得到 页面中包含由JavaScript生成的数据,且需要自定义HTTP请求的页面 测试环境 为了便于测试,在本地使用Node.js启动一个简单的服务器,用于接收请求,并返回一个页面作为响应。server.js的内容如下: var http = require('http')var fs = require('fs')var server = http.createServer((req,res) => { // 返回页面内容 fs.readFile('./index.html', 'utf-8', (err,data) => { res.end(data); }); // 打印请求中的Cookie信息 console.log(req.headers.cookie)})server.listen(9000) index.html的内容更加简单,只包含一个title和一个p标签: <!DOCTYPE html><html><head> <title>This is a title</title></head><body></body></html> 静态页面 这是一个最简版的爬虫程序,在构造方法中调用父类的有参构造方法,同时添加url到待爬取队列中。visit是消费者,每一个url请求都会进入这个方法被处理。 public class StaticDocs extends BreadthCrawler { public StaticDocs(String crawlPath, boolean autoParse) { super(crawlPath, autoParse); this.addSeed("http://127.0.0.1:9000/"); } @Override public void visit(Page page, CrawlDatums next) { System.out.println(page.doc().title(); // This is a title } public static void main(String[] args) throws Exception { StaticDocs crawler = new StaticDocs("crawl", true); crawler.start(1); }} Cookie鉴权 需要在header中带cookie请求同样简单,在构造方法中添加相应配置就可以,node.js的命令行会打印出cookie的内容: public CookieDocs(String crawlPath) { super(crawlPath, true); // 设置请求插件 setRequester(new OkHttpRequester() { @Override public Request.Builder createRequestBuilder(CrawlDatum crawlDatum) { return super.createRequestBuilder(crawlDatum) .header("Cookie", "name=smallyu"); } }); this.addSeed("http://127.0.0.1:9000/");}// name=smallyu JavaScript生成的数据 测试js生成数据的情况需要做一点准备,修改index.html,在body标签中加入这样几行代码: <div id="content">1</div><script> document.getElementById('content').innerHTML = '2'</script> 可以预见,请求中直接返回的div内容是1,然后js经由浏览器执行,改变div的内容为2。访问静态页面的爬虫程序只能进行到第1步,也就是直接获取请求返回的内容。修改StaticDocs.java的visit方法,打印出div的内容看一下,可以确信是1: System.out.println(page.select("div").text());// 1 这是一个官方提供的Demo,用于获取js生成的数据。WebCollector依赖于Selenium,使用HtmlUnitDriver运行js: public class JsDocs { public static void main(String[] args) throws Exception { Executor executor = (CrawlDatum datum, CrawlDatums next) -> { HtmlUnitDriver driver = new HtmlUnitDriver(); driver.setJavascriptEnabled(true); driver.get(datum.url()); WebElement divEle = driver.findElement(By.id("content")); System.out.println(divEle.getText()); // 2 }; //创建一个基于伯克利DB的DBManager DBManager manager = new RocksDBManager("crawl"); //创建一个Crawler需要有DBManager和Executor Crawler crawler = new Crawler(manager, executor); crawler.addSeed("http://127.0.0.1:9000/"); crawler.start(1); }} 如果你看过WebCollector的主页,一定可以注意到这个Demo和其他Demo的明显不同。在不需要js生成的数据时,新建的类继承自BreadthCrawler,而BreadthCrawler继承自AutoParseCrawler,AutoParseCrawler又继承自Crawler。现在获取js数据的Demo,直接跳过BreadthCrawler和AutoParseCrawler,实例化了Crawler。 为什么要这样做呢?再次强调,这是官方提供的Demo。 Cookie鉴权后JavaScript生成的数据 根据官方提供的用例,显然是无法设置cookie的,因为Crawler类并没有提供自定义Header的方法。这个自定义Header的方法继承自AutoParseCrawler类。那么如何做到既可以添加Cookie又可以使用HtmlUnitDriver? 其实结果很简单,我在看过WebCollector的代码后发现AutoParseCrawler实现了Executor接口,并且在构造方法中将this赋值给了父类的executor。也就是说,AutoParseCrawler本身就是一个Executor。下面的代码用以表示它们的关系: public class Crawler { protected Executor executor; public Crawler(DBManager dbManager, Executor executor) { // ... }}public class AutoParseCrawler extends Crawler implements Executor { public AutoParseCrawler(boolean autoParse) { // 这里的executor指向父类 this.executor = this; }} new Crawler时传入一个executor,相当于直接new一个AutoParseCrawler。BreadthCrawler继承自AutoParseCrawler,所以BreadthCrawler本身也是个Executor。再看官方关于自定义Cookie的Demo,如何在其中使用HtmlUnitDriver呢?重写Executor的execute方法。 所以,在定义cookie后获取js生成的数据,使用继承BreadthCrawler的类,然后重写execute就可以。这是一个完整的Demo: /** * @author smallyu * @date 2019.08.11 12:18 */public class JsWithCookieDocs extends BreadthCrawler { public JsWithCookieDocs(String crawlPath) { super(crawlPath, true); // 设置请求插件 setRequester(new OkHttpRequester() { @Override public Request.Builder createRequestBuilder(CrawlDatum crawlDatum) { return super.createRequestBuilder(crawlDatum) .header("Cookie", "name=smallyu"); } }); this.addSeed("http://127.0.0.1:9000/"); } // 直接重写execute即可 @Override public void execute(CrawlDatum datum, CrawlDatums next) throws Exception { super.execute(datum, next); HtmlUnitDriver driver = new HtmlUnitDriver(); driver.setJavascriptEnabled(true); driver.get(datum.url()); WebElement divEle = driver.findElement(By.id("content")); System.out.println(divEle.getText()); // 2 // 同时,node.js的命令行中打印出cookie内容 } // 重写execute就不需要visit了 public void visit(Page page, CrawlDatums crawlDatums) {} public static void main(String[] args) throws Exception { JsWithCookieDocs crawler = new JsWithCookieDocs("crawl"); crawler.start(1); }} 外部代理 也许还没有结束。在一开始概述的图片上,同时定义cookie以及获取js生成的数据,实现方式是内部Selenium + 外部browsermob-proxy。假设没有上述重写execute的方法(官方也确实没有提供类似的Demo),该如何实现想要的效果?一种实践是本地启动一个代理,给代理设置好cookie,然后让Selenium的WebDriver通过代理访问目标页面,就可以在带header的情况下拿到js生成的数据。这是在JsDocs.java的基础上,使用代理的完整实现: public class JsWithProxyDocs { public static void main(String[] args) throws Exception { Executor executor = (CrawlDatum datum, CrawlDatums next) -> { // 启动一个代理 BrowserMobProxy proxy = new BrowserMobProxyServer(); proxy.start(0); // 添加header proxy.addHeader("Cookie" , "name=smallyu"); // 实例化代理对象 Proxy seleniumProxy = ClientUtil.createSeleniumProxy(proxy); // 由代理对象生成capabilities DesiredCapabilities capabilities = new DesiredCapabilities(); capabilities.setCapability(CapabilityType.PROXY, seleniumProxy); // 内置,必须设置 capabilities.setBrowserName("htmlunit"); // 使用capabilities实例化HtmlUnitDriver HtmlUnitDriver driver = new HtmlUnitDriver(capabilities); driver.setJavascriptEnabled(true); driver.get(datum.url()); WebElement divEle = driver.findElement(By.id("content")); System.out.println(divEle.getText()); // 2 }; //创建一个Crawler需要有DBManager和Executor Crawler crawler = new Crawler(new RocksDBManager("crawl"), executor); crawler.addSeed("http://127.0.0.1:9000/"); crawler.start(1); }} 其他 对于WebCollector我已经没有兴趣了解更多,倒是在注意到框架的包名cn.edu.hfut后有种豁然开朗的感觉。凌乱的代码风格,随处可见不知所以的注释,毫无设计美感的代码架构,倒也符合国内不知名大学的开源软件水平,距离工业级的框架,可能还需要N个指数倍东的时间。至于使用过程中遇到depth含义不明、线程非法结束、next.add失效等问题,就这样吧,也在情理之中,整个框架都像是赶工的结果,或者说是学生们拿来练手的项目。我在WebCollector的Github上RP了关于重写execute的问题,从开发者回复的只言片语中,我怀疑开源者自己都没有把里面的东西搞清楚 :P

2019/8/10
articleCard.readMore

Kotlin:简化版的 Scala

行走江湖的剑客,必然要有一柄趁手的宝剑。好的程序语言就像一把好剑,重量合适,拿着舒服,挥舞起来优雅,杀伤力过关。Kotlin官方对待Kotlin和Scala的关系是,“如果你玩Scala很happy,那你就不需要Kotlin。” 脚本化 Scala执行的基本单位和Java一样是类,而Kotlin允许文件中的main方法直接运行,不需要类。Java的入口函数定义在类中: public class Java { public static void main(String[] args) {}} Scala的入口函数定义在样本类而不是普通的类中: object Scala { def main(args: Array[String]): Unit = {}} Kotlin的入口函数则直接定义在.kt文件中,相应的,Kotlin的类仅相当于一种数据结构,类中无法定义入口函数: fun main(args: Array<String>) {} 构造函数与单例模式 Kotlin的构造函数同Scala一样写在类定义处,因此也无法像Java的构造函数一样直接写入初始化代码。Kotlin中使用init代码块来执行初始化程序: class Test(arg: String) { init { println("This string is ${arg}") }}fun main(args: Array<String>) { val test = Test("smallyu")}// This string is smallyu 如果需要第二个构造函数,就要使用类似ES6的constructor函数,或者类似Scala的辅助构造器。这实在是丑陋的写法,相比之下Java真的友善多了。 class Test(arg1: String) { init { println("This string is ${arg1}") } constructor(arg2: Int): this("smallyu2") { println("This int is ${arg2}") }}fun main(args: Array<String>) { val test = Test(1)}// This string is smallyu2// This int is 1 Kotlin的构造函数是需要用constructor关键字定义的,默认可以省略,但如果要加权限修饰符自然就不能省了。在Kotlin中实现单例模式的思路与Java相同,让构造器私有,然后通过静态方法暴露实例: class Test private constructor() { companion object Factory { fun create(): Test = Test() }}fun main(args: Array<String>) { val test = Test.Factory.create()} Kotlin中的object定义静态代码块,companion允许在类内部定义静态代码块,因此compaion object定义了类外部可以访问的方法create()。 getter和setter Kotlin另一个有趣的玩意儿是getter和setter。前端框架React或Vue实现数据双向绑定的原理即使用Object.defineProperty()定义对象的getter和setter,使得对象的变化可以实时同步到页面上。Kotlin提供了对属性getter和setter的支持: var test: Int get() { println("There is test getter") return 2 } set(arg) { println("The setter arg is ${arg}") }fun main(args: Array<String>) { println(test) test = 3}// There is test getter// 2// The setter arg is 3 其他 开始对Kotlin感兴趣是因为发现Kotlin竟然支持协程,如果Kotlin真的有语言级别的协程支持,加上运行在Jvm上的特点,以及能够开发多平台应用包括Server Side、Android、JavaScript、Native,那Kotlin无疑是异常强大的编程语言。然而事实上Kotlin的协程只是一个扩展包,甚至还需要使用编译工具来引入,对协程的支持还是Go语言独大。用于JavaScript平台也是个幌子,并没有比TypeScript好用,至于Android和Native本身也是Java的应用场景…… Kotlin提供了许多语法糖,看似可以简化程序员的代码量,但是为了熟练应用Kotlin的特性,使用者又不得不搞清楚类似data class的概念,就像Scala的case class一样。Kotlin的学术性弱于Scala,工程能力又不比Java有大的优势。Go语言虽然另辟蹊径,语言特性上有广为诟病的地方,但是看着爽,写着也爽。所以Kotlin和Scala一样,并不会有广泛的应用前景。也就是说,它并不会是下一个很流行的编程语言。

2019/7/6
articleCard.readMore

JavaScript 有关联数组吗?

如果你接触过PHP,那你对关联数组一定不陌生。C或Java中数组下标都是从0开始的数值,而PHP除了数值,还可以用字符串作为数组的下标。用数值做下标的数组叫做索引数组,用字符串做下标的数组叫做关联数组,他们都是合法的数组。 <?php$arr[0] = 1; // 索引数组$arr["a"] = "b"; // 关联数组echo $arr[0]; // 1echo $arr["a"]; // b 在JavaScript中,同样可以使用字符串来作为数组的下标: let arr = []arr[0] = 1arr['a'] = 'b' 昨天,我和漂亮同事在使用JavaScript中用字符串做下标的数组时,遇到了令人困惑的问题。 缘起 在Express.js框架的路由处理中,用res.json()返回数组,下标为数值的数组可以正常返回,下标使用字符串的数组却始终返回空。这是一段最简代码,可以用来描述该过程: app.get('/', (req, res) => { let arr = [] arr['a'] = 'b' console.log(arr) // [a: 'b'] res.json(arr) // []}) 预期返回的数组arr包含1个元素,console.log()直接在命令行打印的文本内容是[a: 'b'],和预期一致,然而如果通过页面请求路由,返回的内容是[],这是匪夷所思的,也就是说res.json()把数组的内容吞掉了。 探寻 为了寻找问题的真实原因,我在框架的中找到res.json()方法的定义: res.json = function json(obj) { var val = obj; // ... var body = stringify(val, replacer, spaces, escape) // ... return this.send(body);}; 返回内容body经过了stringify()方法处理,stringify()方法调用的是JavaScript中JSON标准库的方法JSON.stringify(): function stringify (value, replacer, spaces, escape) { var json = replacer || spaces ? JSON.stringify(value, replacer, spaces) : JSON.stringify(value); // ...} 那么就说明,JSON.stringify()方法的返回值,会忽略用字符串做下标的数组。为了证实这一现象,用简单的Demo测试一下: let arr1 = [], arr2 = []arr1[0] = 1arr2['a'] = 'b'JSON.stringify(arr1) // "[1]"JSON.stringify(arr2) // "[]" 所以问题又来了,JavaScript标准库中的JSON.stringify()方法,为什么要忽略数组中下标为字符串的元素?是有意为之,官方不赞成使用字符串做下标,还是无奈之举,存在不可抗拒的原因无法实现?为了找到问题的根源,我试着从Chrome解析JavaScript的 V8引擎 中寻找JSON.stringify()的定义。 V8引擎是用C++写的,关于JSON.stringify()的定义应该是这一段代码: // ES6 section 24.3.2 JSON.stringify.BUILTIN(JsonStringify) { HandleScope scope(isolate); JsonStringifier stringifier(isolate); Handle<Object> object = args.atOrUndefined(isolate, 1); Handle<Object> replacer = args.atOrUndefined(isolate, 2); Handle<Object> indent = args.atOrUndefined(isolate, 3); RETURN_RESULT_OR_FAILURE(isolate, stringifier.Stringify(object, replacer, indent));} 可以推测出,object即JSON.stringify()处理并返回的内容,返回之前使用args.atOrUndefined()方法进行包装。这里atOrUndefined()被反复调用,传入两个参数,可以理解为,第一个参数isolate保存有完整的参数信息,第二个参数是数据的索引,结合起来便是atOrUndefined()方法要处理的完整数据。 然后看atOrUndefined()的定义,在下面的代码中,tOrUndefined()调用了at()方法,at()方法又调用了Arguments的at方法: Handle<Object> atOrUndefined(Isolate* isolate, int index) { if (index >= length()) { return isolate->factory()->undefined_value(); } return at<Object>(index);}Handle<S> at(int index) { DCHECK_LT(index, length()); return Arguments::at<S>(index);} Arguments::at()方法中,指针value获取了待处理参数的内存地址,然后使用reinterpret_cast对value的值进行类型强转。 Handle<S> at(int index) { Object** value = &((*this)[index]); // This cast checks that the object we're accessing does indeed have the // expected type. S::cast(*value); return Handle<S>(reinterpret_cast<S**>(value));} 到这里值就返回了,但是并没能解释为什么使用字符串做下标的数组内容会被忽略。只要是同一个数组,它的值就会保存在一段连续的地址空间中,即使reinterpret_cast处理的是指针变量,也应该无论多少都照常输出才是。 真相 最后,通过Google找到了一个关于数组使用字符串做下标的问题和答案(String index in js array),我才明白为什么字符串做下标的数组如此特殊,因为JavaScript里压根就没有关联数组! let arr1 = [], arr2 = []arr1[0] = 1arr2['a'] = 'b'arr1.length // 1arr2.length // 0 给一个数组使用字符串作为下标赋值后,数组的长度不会改变,赋的值并没有作为数组元素储存到数组里。使用字符串作为下标能够正常对数组取值赋值的原因是,JavaScript将字符串作为数组的属性进行了储存。 let arr = []arr['a'] = 'b'arr.hasOwnProperty('a') // true 因此,JSON.stringify()处理的是数组的内容,reinterpret_cast也只是基于指针对数组内容进行类型转换,属性什么的,当然不会有输出! 后续 为什么console.log()可以将数组的属性也输出?对于要输出的内容,它是怎么定义的? 为什么JavaScript中typeof []的值是"object",也就是数组的类型是对象,但对象的属性会被处理,而数组不会?

2019/5/18
articleCard.readMore

主流编程语言的异常处理机制

学习编程语言应该从语言特性入手,而不是编程语言本身。这里尝试对各种编程语言的异常和错误处理机制做一个横向的、简单的了解。涉及到的编程语言包括C、C++、Go、Java、Scala、Kotlin、Ruby、Rust、JavaScript、PHP、Python、Lisp。 C C语言没有异常捕获机制。程序在发生错误时会设置一个错误代码errno,该变量是全局变量。C语言提供了perror()和strerror()函数来显示与errno相关的描述信息。perror()函数可以直接调用,入参是一个字符串,输出入参: 错误文本。strerror()函数入参是一个数字(错误码),返回一个指针,指针指向错误码对应的文本。 #include <stdio.h>#include <errno.h>#include <string.h>void main (){ // 打开一个不存在的文件,会发生错误 fopen ("unexist.txt", "rb"); // 2 printf("%d\n", errno); // No such file or directory perror(""); // No such file or directory printf("%s\n", strerror(errno));} C++ C++支持异常捕获机制。C++可以抛出或捕获两种内容,一种是int或char*之类的内容,程序可以捕获并抛出,这一点和Java相比有差异,因为Java并不支持直接抛出基本类型的异常: #include <iostream>#include <exception>using namespace std;int main () { try { throw "error"; } catch(const char* msg) { cout << msg << endl; }} // error 另一种内容就是类,可以是内置的标准异常类,或是自定义的异常类: #include <iostream>#include <exception>using namespace std;int main () { try { throw exception(); } catch(std::exception& e) { cout << e.what() << endl; }} // std::exception Go Go语言作为非OOP派系的编程语言,并不支持try-catch的语法,但仍然具有类似抛出和捕获的特性。Go语言有3个错误相关的关键字,panic()、recover()和defer。可以理解为,panic()函数抛出异常,recover()函数捕获异常,defer关键字定义最后也就是finally执行的内容: package mainimport "fmt"func main() { defer func() { err := recover() fmt.Println(err) }() panic("error")}// error Java Java是纯粹的OOP语言,仅支持对象的抛出和捕获: public class ErrorTest { public static void main(String[] args) { try { throw new Exception(); } catch (Exception e) { System.out.println(e); } }}// java.lang.Exception Scala Scala和Java是一个流派,同样仅支持对象的抛出和捕获,除了语法上和Java稍有差异,概念上基本是一jian样rong的: object ErrorTest { def main(args: Array[String]): Unit = { try { throw new Exception() } catch { case e: Exception => print(e) } }}// java.lang.Exception 另外,Scala抛出的是Java的异常,也许Scala不能算作是独立的编程语言,而是依附于Java、为Java提供语法糖的编程语言。这一点值得深入思考和探究。 Kotlin Kotlin和Scala是一种性质的语言,默认抛出的同样是Java的异常: fun main(args: Array<String>) { try { throw Exception() } catch (e: Exception) { print(e) }}// java.lang.Exception Ruby Ruby使用关键字raise和rescue代替try和catch来实现异常的抛出和捕获。Ruby同样支持try-catch关键字,这里暂不讨论,因为我没搞清楚它的用法。 begin raise "error" rescue Exception => e puts eend// error Rust Rust没有try-catch的语法,也没有类似Go的错误处理函数,而是用对错误处理进行过包装的Option<T>或Option的加强版Result<T, E>进行错误处理。Rust的模式匹配和Scala类似: fn main() { match find() { None => println!("none"), Some(i) => println!("{}", i), }}fn find() -> Option<usize> { if 1 == 1 { return Some(1); } None}// 1 JavaScript 脚本语言在变量类型上不做强制约束,捕获时也就不能按照异常类型来做区分。抛出错误的内容还是相对自由的: try { throw 1} catch (e) { console.log(e)}// 1try { throw new Error('')} catch (e) { console.log(e)}// Error PHP PHP的try-catch和Java类似,并没有特殊之处: <?php try { throw new Exception("error"); } catch (Exception $e) { echo $e->getMessage(); } Python Python在语法上能找到Ruby的影子,raise触发异常,execpt捕获异常: try: raiseexcept: print("error") Lisp Lisp整体较复杂,Lisp捕获处理异常的内容暂时留坑。以下是Common Lisp触发错误的情形之一,declare会声明函数入参类型,传入错误参数将引发错误: (defun df (a b) (declare (double-float a b)) (* a b)) (df "1" 3)// *** - *: "1" is not a number 后续 原先想梳理这些语言的大部分异常和错误处理相关概念,然而真正开始后发现比较困难,并且之前我没能区分”exception”和”checked exception”,以致从立意到标题到内容可能都有偏差。这次就先提及”exception”,之后讨论关于”checked exception”的内容。

2019/4/24
articleCard.readMore

Go 语言基本语法

Go语言虽然在语言设计上不被王垠看好,但它如此简洁的代码结构确实让人着迷。 语句 Go语言语句结尾不需要;。 变量和常量 使用var声明变量。当变量需要初始化时,可以使用赋值符号:=代替=以省略var关键字。 var a intvar b stringvar c int = 10var d = "golang" // 编译器自动推断类型d := 10 与C语言或Java不同,Go语言的类型声明在变量右侧。需要注意的是,如果程序中声明的变量未经使用,程序将无法通过编译。Go语言是一种工程化的语言,因此它的一些特性让人感觉不可理喻,但又会在实际工程中提高效益。 Go语言的变量赋值支持一些炫酷的写法,比如要交换变量x和y的值,可以使用这种违反直觉的写法: x, y = y, x Go语言中使用const定义常量,true、false和iota是预定义常量。其中iota稍显特殊,iota会在每一个const关键字出现时重置为0,然后在下一次const出现前,每出现一次iota,iota的值加1。 const a = iota // 0const b = iota // 0const ( c = iota // 0 d = iota // 1) 数组和切片 声明一个元素个数为3的数组,并初始化: array := [3]int{0, 1, 2}array[0] = 3fmt.Println(array) 和其他语言一样,Go语言在声明数组后并不能改变数组的大小。所以Go语言提供了像Python一样的切片。切片可以从数组中产生,也可以使用make()函数新建。 array := [3]int{0, 1, 2}slice1 := array[:2] // 从数组中创建slice2 := make([]int, 3) // 直接创建fmt.Println(slice1) // [0 1]fmt.Println(slice2) // [0 0 0] 除切片外,映射也是使用make函数创建,映射的类型全称是var myMap map[string] int,意为声明变量myMap,key为string,value为int。 流程控制 Go语言允许if-else语句的条件表达式不加小括号,当然加上也无妨。 a := 1if a == 1 { print(1)} else if (a == 2) { print(2)} else { print(3)} 选择语句的条件表达式同样不需要小括号,另外也不需要break,其他匹配项并不会执行,这一点和Scala相同。对选择语句的优化貌似已经是不约而同的做法。 i := 0switch i {case 0: print(0)case 1: print(1)} 循环结构的条件表达式依然不需要小括号。Go语言只支持for循环。同时对无限循环的场景也做了优化,不再需要for(;;)的写法。 for { print(1)} 函数 Go语言诞生自C语言的派系,因此Go语言从一开始就不是OOP或FP的语言,没有类、对象等概念。函数是程序中的一等公民。和C语言相同,(main包下的)main函数是整个程序的入口。 func add(a int, b int) (int, int) { return a + b, a - b}func main() { x, y := add(1, 2) print(x, y)} Go语言的语句简洁高效,函数名后的第一个括号为入参,第二个括号是出参。函数支持多返回值。如果参数类型相同,可以将类型声明合并到一起,如(a, b int)。 结构体 刚才提到Go语言没有类、对象等概念,但是Go语言有类似C语言的结构体,并且能力强大。这里定义一个Person结构体,包含两个属性name和age,并为Person添加一个方法getInfo,用于输出Person对象的信息: type Person struct { name string age int}func (p Person) getInfo() { print(p.name, p.age)}func main() { smallyu := new(Person) smallyu.name = "smallyu" smallyu.age = 1 smallyu.getInfo()} 用OOP的思想理解这样的程序并不违和。除了结构体,Go语言还保留有指针的概念。Java程序员对指针可能稍感陌生,关于指针在结构体方法中的应用,可以通过一个简单的例子来了解: type Person struct { name string}func (p Person) setName() { p.name = "set name"}func (p *Person) setName2() { p.name = "set name"}func main() { smallyu := &Person{"smallyu"} smallyu.setName() fmt.Println(smallyu) // &{smallyu} bigyu := &Person{"bigyu"} bigyu.setName2() fmt.Println(bigyu) // &{set name}} 使用值类型定义的结构体方法,入参为形参;使用引用类型定义的结构体方法,入参为实参。&{}是初始化对象的方法之一,等同于new()。 匿名结合 Go语言中匿名结合的概念,相当于OOP语言的继承。一个结构体可以继承另一个结构体的属性和方法,大致是这样。 type Father struct { name string}func (f Father) getName() { print(f.name)}type Son struct { Father}func main() { smallyu := &Son{} smallyu.name = "smallyu" smallyu.getName() // smallyu} Son并没有定义name属性,也没有定义getName()方法,它们均继承自Father。 接口 Go语言的接口是非侵入式的,结构体只要实现了接口中的所有方法,程序就会认为结构体实现了该接口。 type IPerson interface { getName()}type Person struct { name string}func (p Person) getName() { print(p.name)}func main() { var smallyu IPerson = &Person{"smallyu"} smallyu.getName()} 协程 使用协程的关键字是go,从命名就能看出协程对于Go语言的重要性、协程是轻量级的线程,启动一个协程非常简单: func f(msg string) { println(msg)}func main() { f("直接调用方法") go f("协程调用方法")} 运行程序,你会发现程序只打印出”直接调用方法”几个字。这种情况是不是似曾相识?go启用了另一个”线程”来打印消息,而main线程早已结束。在程序末尾加上fmt.Scanln()阻止main线程的结束,就能看到全部的打印内容。 通道 通道即协程之间相互通信的通道。 func main() { message := make(chan string) go func() { message <- "ping" }() msg := <-message println(msg)} make函数返回一个chan string类型的通道,在匿名函数中将字符串”ping”传入通道,之后将通道中的数据输出到变量msg,最后打印出msg的值为”ping”。 错误处理 Go语言在错误处理部分有两个函数较为常用,panic函数和defer函数。panic函数会打印错误消息,并终止整个程序的执行,类似Java的Throw Exception;defer函数会在当前上下文环境执行结束前再执行,类似try catch后的finally;panic函数虽然会终止整个程序,但不会终止defer函数的执行,可以将defer函数用于打印日志。这是一个简单的例子: func main() { println("beginning") defer func() { println("defer") } () println("middle") panic("panic") println("ending")} 来分析一下程序的执行结果。首先beginning被打印;然后遇到defer,暂不打印;middle在defer之前被打印;遇到panic,程序将终止,打印defer和panic。 这里要注意,defer是在程序结束前执行,而不是在其他语句结束后执行,这是有区别的。就像这里,panic函数引起了当前程序的结束,所以defer会在panic函数前执行,而不是panic后。程序的执行结果如下: beginningmiddledeferpanic: panicgoroutine 1 [running]:main.main() D:/go/src/awesomeProject/main.go:12 +0x7f 其他 除此之外Go语言还有很多语言特性,也提供了非常多实用的工具包。Go语言是一种值得我们尝试去使用的语言。关于协程和通道,后续会单独探讨这一重要特性。 参考 《Go语言编程》 Go by Example

2019/3/15
articleCard.readMore

Scala 语法基础

Scala语法较复杂,参考软件的增量开发,学习一门编程语言也应先找到一种能够驾驭的表达方式,之后再逐步添枝加叶。Scala同时支持面向对象和函数式编程,是其语法复杂的原因之一。一些教程非常全面,但也因为全面,导致难以抽丝剥茧,抓住主干。 以下内容关注最简单的基础语法,希望根据这些内容,可以尝试编写面向对象风格的Scala代码。 语句 Scala允许语句结尾不加;,这一点类似JavaScript。 变量定义 val定义不可变变量(常量),var定义可变变量: val msg1 = "Hello World"var msg2 = "Hello Wrold"val msg3: String = "Hello World" 定义变量时,类型声明在变量右侧,而且是可选的,可以不声明,编译器会自动推断。Scala中的基本类型包括: Byte、Short、Int、Long、Char、String、Float、Double、Boolean 函数定义 函数即方法,下面是定义函数的例子: def max(x: Int, y: Int): Int = { if (x > y) { return x } else { return y }} 与Java中方法定义的显著区别有三处:一是使用def关键字定义函数;二是类型声明在变量右侧,上文已提及;三是函数声明和函数体中间使用=连接。 注意函数声明的参数必须明确定义类型,编译器无法自动推断入参类型。返回类型则是可选的,除非函数使用了递归。另外,return关键字也是可选的,如果没有显式的返回语句,程序会将最后一次运算结果作为返回。 当然if后是单个语句也可以不使用大括号,因此该函数还可以这样描述: def max2(x: Int, y: Int) = if (x > y) x else y 选择结构 上面的示例已经用到了if语句,Scala的if语句并无特殊之处,不过与其他语言相比,Scala用模式匹配的概念代替传统的switch结构: val a = 1a match { case 1 => println(1) case 2 => println(2) case _ =>} _通配符匹配所有值,用于捕获默认情况。匹配表达式中,备选项永远不会掉到下一个case,因此不需要break或return。(如果将_放到首句,程序不会继续向下执行)。但是要小心,如果程序没有匹配到选项,会抛出MatchError。 循环结构 while循环并不是Scala推荐的代码风格: var i = 0while (i < 5) { println(i) i += 1} 似乎并没有难以理解的地方,这就是典型的while循环。与指令式语言相比,Scala没有++运算符,只能使用i += 1这样的语句。 提起while,就一定会想到for。Scala中的for循环与指令式语言有一些差异,简单的示例如下,程序会从0打印直到5(不包括5)。 for (i <- 0 until 5) { println(i)} Scala不推荐while循环,而更倾向于函数式的编程风格,用于遍历的foreach方法就是其一: "abc".foreach(c => println(c)) 程序会依次换行打印出a b c三个字符。如果函数体只有一行语句并只有一个参数,这行代码还可以更简洁: "abc".foreach(println) 数组 Scala的数组并不在语言层面实现,可以实例化Array类来使用。相应的,数组下标使用小括号(也就是方法参数)表示: val greet = new Array[String](3)greet(0) = "a"greet(1) = "b"greet(2) = "c"greet.foreach(println) 实例化对象时,也可以直接传入默认参数。Array确实只是一个普通的类,下面的书写方式并没有黑魔法,只是用到了样本类。关于样本类,后文有提及。 val greet2 = Array("a", "b", "c")greet2.foreach(println) 类 类使用class关键字定义,类中也包含字段和方法,即典型的面向对象。与Python不同,Scala仍然支持权限控制: class Accumulator { private var sum = 0 def add(b: Byte): Unit = { sum += b println(sum) }} 单例对象 单例对象(Singleton对象)相当于Java中的静态类,使用object替代class关键字定义。单例对象由程序共享,可直接调用。单例对象可以作为程序入口,即将main方法定义在单例对象中。下面的程序从上面定义的Accumulator类中实例化出对象c,并调用其add方法,最终程序打印1: object Run { def main(args: Array[String]): Unit = { val a = new Accumulator a.add(1) }} 在同一源文件中,当单例对象和类同名时,称单例对象为类的伴生对象,类为单例对象的伴生类。类可以访问其伴生对象的私有属性和方法。 构造方法 Scala中构造方法的规则比Java要严格。Scala通过类参数的概念来实现构造方法: class Accumulator(a: Int, b: Int) 如果类没有主体,大括号是可以省略的。实例化这个类时,就需要传入参数。在Java中的构造方法重载,对应Scala中的辅助构造器,它看起来像这样: class Accumulator(a: Int, b: Int) { def this(c: Int) = this(c, 1)} 这时类拥有两个构造方法: val a1 = new Accumulator(1)val a2 = new Accumulator(1, 2) Scala构造器的严格之处就在于,第二个构造器只能借助第一个或超类的构造器。 继承与重写 Scala的继承与Java没有明显差异,只是方法重写必须要使用override关键字: class A(a: Int) { def test = println("a")}class B(b: Int) extends A(b) { override def test = println("b")} 特质 特质(trait)和单例对象相像,除了定义时使用的关键字不同,其余和普通的类一样,可以包含字段和方法。特质的意义在于,支持混入(Mixins),并且允许混入多个特质。这一特性经常和多重继承进行对比。 trait A { def aMethod = println("A")}trait B { def bMethod = println("B")}class C extends A with B 这样C的实例就可以调用aMethod和bMethod: val c = new Cc.aMethodc.bMethod 样本类 样本类的定义要在class前加case关键字,即类在定义时用case修饰。这种修饰可以让Scala编译器自动为类添加一些便捷设定:1. 实例化可以省略new关键字;2. 自动将参数作为类字段;3. 自动为类添加toString、hashCode和equals: case class A(a: Int) { def aMethod = println(a)}object Run { def main(args: Array[String]): Unit = { val a = A(1) a.aMethod // 1 println(a) // A(1) println(a.a) // 1 }} 其他 与Java相比,Scala支持抽象类,但不支持接口,抽象类使用abstract定义,接口则由特质代替。Scala同样支持泛型、注解等语法。 后续 以上内容并不全面,也许并不够用。使用一种编程语言,除了掌握它的基本语法外,还要熟悉它的惯用写法,尤其像Scala这种多范式的编程语言。之后会持续修改完善此篇内容,也将继续讨论Scala的其他语言特性。

2018/12/17
articleCard.readMore

用 Scala 改写 Java 浅度实践

起源 想要用Java实现Markdown解析器,目前只完成了多级标题的解析。其实也就是正则匹配之后替换掉相应内容,程序暂时比较简单,大致流程如下: 改写 按照同样的流程,用Scala来实现该功能,之后也将使用Scala继续完成开发。首先读取文件内容,IO操作参考《Scala Cookbook》,只需一行代码即可: var srcLines = Source.fromFile(srcFile).getLines().toList 与冗长的Java相比,Scala确实精简了不少。这是之前使用Java读取文件封装的方法: /** * 读取文件内容 * * @param src 读取文件路径 * @return 读取文件内容 */private static String readFile(String src) throws IOException { StringBuffer content = new StringBuffer(); InputStream is = null; BufferedReader reader = null; is = new FileInputStream(src); reader = new BufferedReader(new InputStreamReader(is)); String line = reader.readLine(); while (line != null) { content.append(line); content.append("\n"); line = reader.readLine(); } if (reader != null) { reader.close(); } if (is != null) { is.close(); } return content.toString();} 至于Scala版本将字符串改为列表操作的原因在于,Scala和Java在使用正则匹配替换的API上有差异。Java使用Matcher对象进行迭代,Matcher对象拥有查找、替换等方法: 而Scala的Regex对象虽然拥有findAllMatchIn、replaceAllIn等方法,但在find中的对象仅用于查找,replace方法中又无法定位匹配项的内容。因此在Scala中,将文件读入列表,使用如下方式带索引遍历文本内容: List.range(0, srcLines.size).foreach(index => { srcLines = srcLines.updated(index, regexReplace)}) 无论是否含有匹配项,循环内都对列表执行一次updated,更新原内容为正则替换后的内容。这样做可能稍微欠妥,关于性能问题将持续关注并整改。可以看到的是,Scala的程序思想与Java典型的OOP确实存在些许差异。 最后关于文件写入,SDK中没有提供专门的操作对象,可使用JDK中的PrintWriter: val pw = new PrintWriter(new File(outFile))pw.write(outString)pw.close() 后续 “Scala是一门会伴随开发者成长的语言”,我将用它完成我的毕业设计。

2018/12/14
articleCard.readMore

Python 获取海贼王更新信息

12月2日,晴,海贼王停更。 问题 做为一个合格的肥宅,海贼王和妖精的尾巴每周必追。这两部动漫播放源都在爱奇艺,都是VIP内容。每周末看动漫,都要在YouTube或其它网站上找资源。问题是,资源网站的更新往往不及时,常常需要Google“动漫名称 + 最新集数”,比如“海贼王 864”。 每个星期都精确的记住一部动漫应该更新的最新一集集数是多少,恐怕不是正常肥宅会做的事情,况且两部。这样,每次搜索资源前,都需要进入爱奇艺,搜索海贼王,看到最新的一集集数,关闭页面,进入Google搜索。妖精的尾巴也要同样的操作来一次。 而且,在爱奇艺里看到最新一集集数的瞬间,无法判断它是否停更,还需要在复杂的PC页面中找到“更新时间”这一标签,看更新状态是否正常,才可以做出判断。至于手机页面或APP,更是没有途径可以查看动漫的更新状态。 另一个获取动漫最新集数的方式是,百度直接搜索动漫名称,首页就倒序显示最新几集的列表(这一点百度好于谷歌),但也无法判断更新是否正常。再者,浏览器默认为Google,百度搜索需要先输入baidu,按TAB切换至百度搜索引擎,再输入要搜索的内容敲回车,步骤同样繁琐。 解决 写一段简单的Python脚本,从爱奇艺页面上抓取信息,自己直接访问程序便能知晓动漫的更新情况。引入工具包: import urllib.requestfrom bs4 import BeautifulSoupfrom wsgiref.simple_server import make_server# 海贼王页面链接url = "http://www.iqiyi.com/a_19rrhb3xvl.html?vfm=2008_aldbd" urllib用于发送http请求,并接收页面数据;bs4用于解析页面,更轻易获取内容;wsgiref用于建立http服务器,提供网络服务。url是全局变量,储存海贼王页面的链接地址。 # 从页面获取数据def reciveData(url): # 获取页面内容 response = urllib.request.urlopen(url) html = response.read() # 解析器 soup = BeautifulSoup(html, "html.parser", from_encoding="utf-8") # 更新时间 p = soup.find('p', class_="episodeIntro-update") # 最新集数 i = soup.find('i', class_="title-update-num") return p, i 这几行代码发送了请求,并从页面中获取信息。这里更新时间和最新一集集数的信息就已经拿到了。接着要创建一个http服务器,让程序输出内容到页面: # 服务器环境的处理函数def application(environ, start_response): # 获取数据 p, i = reciveData(url) # 拼接出页面内容 start_response('200 OK', [('Content-Type', 'text/html')]) content = ('<h3>海贼王</h3>' + 'msg: ' + p.contents[2].get_text().strip() + '<br>' + 'num: ' + i.get_text()) return [bytes(content, encoding = "utf-8")] 最后启动一个本地服务器,访问8010端口即可看到页面。可将程序部署到服务器,之后直接访问服务器: # 启动服务器httpd = make_server('', 8010, application) httpd.serve_forever() 运行结果如图: 扩展 从这一想法出发,可以扩展程序。一种是从各大网站获取全面的动漫更新信息,主动提供服务;再一种是根据用户的输入,提供自定义的动漫更新信息;或者将两者结合,提供一种大而全的、可收藏、可定制的服务。虽然这种想法毫无意义。 更正 之前的代码犯了一个低级错误,程序只会在首次运行时发起网络请求,之后由于网络服务一直处于启动状态,返回网页的内容始终都是初始数据。解决这个问题也很容易,将请求网络的操作封装到一个函数中,再到application函数中调用该函数即可。(代码已更正,为保证简洁,去掉了妖尾部分的代码和控制台的日志输出)

2018/12/2
articleCard.readMore

HTML5 音乐可视化

下面梳理一下HTML5实现音乐可视化的流程。 搭建静态页面 静态页面的结构分三部分,header,left,right。header为语义化标签,left和right都用div来实现。 类似这样,然后把背景颜色改为黑色,字体改成白色,加上或不加边框线,给一定padding或margin,静态页面就搭建好啦。 不过重点不是页面布局。可以在左侧栏有一个曲目列表,点击切歌。这里就不那么麻烦,直接后台加载指定的歌曲。歌曲是许嵩的烟花笑,左侧栏显示部分歌词。 创建音频文件对象 AudioContext对象用于获取音频文件源。 (节点)AudioContext:包含各个AudioNode对象以及它们的联系的对象,可以理解为上下文对象。绝大多数情况下,一个document中只有一个AudioContext。 (方法)createBufferSource():创建audioBufferSourceNode对象。 可以这样写: var ac = new window.AudioContext();    // 实例化ac为一个音频对象var bs = ac.createBufferSource();      // 用bs来保存音频文件资源 有了音频资源,是否就可以播放音频了呢?当然不可以,因为还没有指定文件,告诉对象播放哪一个音频文件。 获取音频数据 创建bufferSource后,到了AudioBufferSourceNode这个节点。这个节点有一个属性值buffer,用来指定文件资源。这个属性值需要一个buffer类型的数据。 (节点)AudioBufferSourceNode:表示内存中的一段音频资源,其音频数据存在于AudioBuffer中(其buffer属性)。 (属性)buffer:AudioBuffer对象,表示要播放的音频资源数据。 获取buffer有两种方式,一种是ajax设置返回值类型为arraybuffer,然后解码,一种是用FileReader读取文件,获得blob值。后一种方式多用input上传文件后获得文件,再进行解析。前面一种好用一点。(留坑) ajax的原生写法: var url="...";var xhr = new XMLHttpRequest();xhr.abort();xhr.open("GET", url);xhr.responseType = "arraybuffer";xhr.onload = function(){    return xhr.response;}xhr.send(); onload触发的函数中xhr.response就是我们想要的值。 (方法)decodeAudioData(arrayBuffer, succ(buffer), err) :异步解码包含在arrayBuffer中的音频数据。 将arraybuffer解码: ac.decodeAudioData(arraybuffer, function(buffer){ ... },function(err){ ... });bs.buffer = buffer;    // 回调函数中的buffer就是想要的buffer 播放音乐 (方法)start:开始播放音频 这个时候调用start方法: bs.start(0); 打开页面,音乐开始播放。 音量控制 (节点)GainNode:改变音频音量的对象,会改变通过它的音频数据所有的sample frame的信号强度。 (方法)createGain():创建GainNode对象。 (属性)value:可以改变音频信号的强弱,默认为1,最小值为0。 音乐播放还不行,要添加一个音量控制控件,音量控制用GainNode节点: var gainNode = accreateGain();    // 创建GainNode对象gainNode.connect(ac.destination);    // 将gainNode连接到AudioDestinationNode节点 这样就有了一个音量控制的对象。 gainNode.gain.value= ... ; gain.value用于控制输入信号的强弱,也就是音量的大小。HTML中用type为range的input,把值传递给对象,就可以实时控制音量了。 得到音频解析数据 (节点)AnalyserNode:音频分析对象,他能实时的分析音频资源的频域和时域信息,但不会对音频流做任何处理。 (方法)createAnalyser():创建AnalyserNode对象。 (属性)fftsize:设置FFT(是离散傅里叶变换的快速算法,用于将一个信号变换到频域)值的大小,用于分析得到频域,为32 ~ 2048之间2的整数次倍,默认为2048,实时得到的音频频域的数据个数为fftSize的一半。 (属性)frequencyBinCount,FFT值的一半,即实时得到的音频频域的数据个数 (方法)getByteFrequencyData(Uint8Array),复制音频当前的频域数据(数量是frequencyBinCount)到Uint8Array(8位无符号整型类型化数组)中。 先创建AnalyserNode对象,然后设置fftsize的值。FFT用于数字信号的处理,会把音频文件流输入的信号变换到频域,用getByteFrequencyData()方法得到经系统处理之后的频域数据。 var analyser = ac.createAnalyser();    // 实例化对象analyser.fftSize = 32;                 // 设置fftsizevar arr = new Uint8Array(analyser.frequencyBinCount);    // getByteFrequencyData()方法要求参数为8位无符号整型类型化数组analyser.getByteFrequencyData(arr);    //    arr为所需频域数据 这样得到的数组arr就是用于可视化效果绘制的数据。 (方法)window.requestAnimationFrame():告诉浏览器您希望执行动画,并请求浏览器调用指定的函数在下一次重绘之前更新动画。该方法将在重绘之前调用的回调作为参数。 一次解析只能得到一组数据,所以需要requestAnimationFrame不断解析,不断得到arr。 前端界面绘制 前端界面使用canvas绘制,实现音乐可视化的效果。 var box = document.getElementById("right");    // 获取right区域的dombox.appendChild(canvas);                       // 创建canvas节点var ctx = canvas.getContext("2d");             // 实例化canvas画笔 接下来就是使用for循环和ctx画矩形、圆圈,填充渐变的操作了。(留坑吧,估计不填了) 案例用到web Audio API的关系: 虚线是可以跳过的节点,直接播放音频文件。好吧有错。

2017/5/17
articleCard.readMore