区块链2.0——以太坊

0. 前言

如果说比特币是区块链1.0,那以太坊就是区块链2.0。比特币的出现,让大规模、去中心化的电子货币交易成为可能。而以太坊的出现,让任何人都能构建去中心化的合约与应用。根据白皮书的标题,我们也可以清晰地知道:比特币的核心是点对点电子货币,而以太坊的核心是智能合约与去中心化应用。

1. 比特币回顾

状态转移系统

在以太坊白皮书中提到,从技术角度,比特币就是一个状态转移系统(state transition system)。状态是系统中各个账户的剩余比特币,准确一点,是各账户的UTXO。每当成功发生一笔交易,就是从一个状态转移到一个新的状态。比如,状态1中A有10个币B有2个币,发生一笔交易A→B 5个币,就会转移到状态2(A有5个币,B有7个币)。

From a technical standpoint, the ledger of a cryptocurrency such as Bitcoin can be thought of as a state transition system, where there is a “state” consisting of the ownership status of all existing bitcoins and a “state transition function” that takes a state and a transaction and outputs a new state which is the result.

脚本

在比特币中,存在一个弱化版的智能合约——交易脚本。交易脚本除了能实现基本的交易验证功能之外,还能实现一些稍微复杂的功能,比如:多重签名,需要3个公私钥账户中2个的签名,才能完成验证。

但比特币中的脚本语言存在一定的局限性,比如:缺乏图灵完备性。无法直接循环运算,这样做是避免交易验证时出现无限循环。

2. 账户

比特币是基于交易的记账系统,当统计某个账户(地址)有多少币时,需要遍历区块链上该账户的未花费币,即UTXO。而以太坊是基于账户的记账系统,全节点会保存系统中所有账户的信息,可以直观地看到账户的余额。

为什么要使用基于账户的记账系统?因为以太坊的核心在于智能合约,为了方便智能合约的制定与执行,需要依靠账户系统。

相比于基于交易,基于账户的记账系统不用考虑双花攻击。因为不需要指向上一笔交易,付款金额从账户中扣除,若付款方将同一笔交易公布两次,相当于花了自己两笔钱。但是,基于账户的系统会出现重放攻击,即收款方将同一笔交易再次公布,以此收取两笔钱。为了解决这个问题,以太坊为每个账户保存一个记录交易数量的值nonce,并在交易中包含一个序号,规定序号大于nonce时交易才有效。

账户类型

以太坊中包含两类账户:

  • **外部持有账户(externally owned accounts)**,即普通账户,自行产生公私钥加入系统。

  • **合约账户(contract accounts)**,用于智能合约。

账户字段

以太坊帐户有四个字段:

  • nonce - 记录从帐户发起的交易数量,即交易数量计数器,初始化为0。这确保交易只处理一次,防止重放攻击。在合约帐户中,这个数字代表该帐户创建的合约数量(?)。

  • balance - 账户余额,拥有的Wei数量(1 ETH=1e+18 Wei)。

  • codeHash - 合约帐户所拥有的代码片段。所有代码片段都被保存在状态数据库的相应哈希下,供后续检索。对于普通帐户,该字段为空。

  • storageRoot - 存储合约账户的相关状态,其值是Merkle Patricia trie根节点的哈希值。

账户地址

用户先随机生成一个私钥,通过椭圆曲线算法生成公钥,再通过Keccak-256哈希生成256位的数,取最后160位(20个字节)作为账户地址。

账户存储——状态树

虽然账户带来了便利,但也制造了一些麻烦:每个全节点需要保存各个账户的状态,同时还要对这些账户状态达成共识。为此,以太坊使用了一种Merkle Patricia trie(MPT)的数据结构,对账户状态进行存储。存储账户状态的这棵MPT,又被称为状态树。

Merkle Patricia trie的由来如下:

  1. trie: 字典树/前缀树

1

  1. Patricia trie: 经路径压缩的前缀树

1

  1. Merkle Patricia trie: 使用哈希指针的Patricia trie

  2. Modified Merkle Patricia trie: 以太坊中所使用的,做了稍许修改

1

为什么不使用hash表?账户地址与状态有着明显的一对一关系,若使用hash表,查找、增加、更新都是O(1)时间复杂度。但是,hash表不利于达成共识。为了保证各节点之间账户状态的一致性,每次都在发布区块中包含所有账户的状态是不可能的。而常用方法是:取一个总哈希值,每次发布区块时,各节点对这个哈希值达成共识。然而,每次生成区块,都会有账户状态发生变化,那就需要对所有账户重新取哈希,工作量极大。

为什么不使用Merkle树?使用默克尔树结构的好处在于:账户状态发生更新时,只需要更新对应叶子节点到根节点路径上的哈希值。但是,默克尔树对新增账户并不友好。在叶子节点中,不同的排序会产生不同的根哈希值。如果按照账户地址排序,那么新增账户的地址有可能在序列的中间,将导致大半棵树重新计算。

为什么使用MPT树?账户地址等长,20个字节,用16进制表示则有40个字符,那么树最大高度就是40,查询复杂度并不高。账户更新时,只需更新到根节点路径上的哈希值即可。新增账户时,不会影响其它账户,也只用更新到根节点路径上的哈希值。发布区块时,只需在区块体中包含变化账户的信息,大家会对区块头中状态树的根节点哈希值达成共识。

同时,以太坊中还有另外两棵树——交易树收据树,它们都采用MPT的数据结构。收据是交易执行后产生的信息,与交易一一对应,用于智能合约。交易树和收据树都只包含当前区块中的交易数据,而非全部。

3. 交易

交易信息

交易(Transactions)是由外部持有账户(即普通账户)发送的,包含如下信息:

  • recipient - 接收地址,可以是普通账户,也可以是合约账户

  • signature - 发送者的签名。

  • value - 交易的金额,以Wei为单位。

  • data - 一些可选的数据字段。

  • STARTGAS - 表示允许交易执行的最大计算步骤数(当实际计算步骤大于这个值时,交易将不会被执行),单位是gas。通常一个计算步骤花费1gas或者更多。

  • GASPRICE - 表示支付方为每个计算步骤支付的费用,即每个gas需支付多少以太币。

汽油费Gas是一个单位,用于衡量交易执行所消耗的资源。

以太坊规定,交易执行所导致的每个计算、存储都需要消耗费用,而设计gas限额是为了防止恶意者发布无限循环代码或者浪费计算资源。

消息(一种特殊的交易)

消息(Messages)由合约账户向另一个合约账户发送,包含如下信息:

  • 消息的发送者(隐含)

  • 消息的接收者

  • 与消息一起传输的ether数量

  • 一个可选的数据字段

  • 一个STARTGAS

当正在执行的合约执行到CALL操作码时,就会产生一条消息。交易或合约指定的gas限额,适用于该交易和所有次级执行所消耗的总gas。例如,外部账户A向合约账户B发送了一笔1000 gas的交易,B已花费了600 gas,此时执行CALL指令向合约C发送消息,C执行完花费300 gas,那B还剩100 gas。

交易执行

  1. 检查交易是否格式正确,签名是否有效,以及nonce是否与发送者帐户中的nonce匹配。

  2. 计算交易手续费/汽油费=STARTGAS * GASPRICE,并从签名中确定发送者账户。从发送者帐户的余额中减去这笔费用,并增加发送者的nonce。如果发送者没有足够的余额,则返回错误。

  3. 初始化GAS = STARTGAS,并先根据交易的字节数支付一定量的gas(存储也需消耗gas)。

  4. 将交易额从发送者帐户转移到接收者帐户。如果接收帐户尚不存在,就创建它。如果接收帐户是合约,则运行合约的代码,要么执行完成,要么gas被消耗完。

  5. 如果发送者账户没有足够的金额,或者代码执行耗尽了gas,或者出现错误,则回滚除了支付汽油费之外的所有状态,并将汽油费添加到矿工的帐户(已消耗的汽油费不退,防止恶意节点浪费资源)。

  6. 如果一切顺利执行完,则将剩余的gas费用退还给发送者,并将消耗的gas费用发送给矿工。

举例:假设存在一个合约账户,初始状态中,存储为空,代码(实际上合约代码是用低级EVM代码编写的,这个例子是用Serpent编写的,这是以太坊的高级语言之一,可以被编译成EVM代码)如下:

1
2
if !self.storage[calldataload(0)]:
self.storage[calldataload(0)] = calldataload(32)

此时,有一笔交易发送到合约账户,它包含10 ether,2000 gas(gas价格是0.001个ether)和64个字节的数据,字节0-31存储着数字2,字节32-63存储着字符串CHARLIE。交易执行的过程如下:

  1. 检查交易是否有效且格式正确。

  2. 检查交易发送者是否至少有2000*0.001 = 2 ether。如果有,则从发送者的帐户中减去2 ether。

  3. 初始化gas=2000。假设交易总长度是170字节,且每个字节的费用是5 gas,则减去170*5 = 850 gas,还剩1150 gas。

  4. 从发送者的帐户中再减去10 ether,并将其添加到合约的帐户中。

  5. 运行代码。代码中calldataload(i)是取交易数据中字节i位置的值,因此代码的作用是:检查self.storage[2]存储中索引2处有没有值,若没有值,则将字符串CHARLIE存储到这。假设这需要187 gas,那么gas剩余1150 - 187 = 963。

  6. 将963 * 0.001 = 0.963 ether返回发送者帐户,并返回结果状态。

如果交易是发送给普通账户的,那么交易手续费直接等于GASPRICE * 交易的字节长度。

另外,消息的回滚与交易的回滚一样。如果消息执行消耗完gas,消息的执行和其触发的次级执行都会回滚。但是,父级执行不会回滚。

4. 区块

区块头

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Header struct {
ParentHash common.Hash `json:"parentHash" gencodec:"required"`
UncleHash common.Hash `json:"sha3Uncles" gencodec:"required"`
Coinbase common.Address `json:"miner" gencodec:"required"`
Root common.Hash `json:"stateRoot" gencodec:"required"`
TxHash common.Hash `json:"transactionsRoot" gencodec:"required"`
ReceiptHash common.Hash `json:"receiptsRoot" gencodec:"required"`
Bloom Bloom `json:"logsBloom" gencodec:"required"`
Difficulty *big.Int `json:"difficulty" gencodec:"required"`
Number *big.Int `json:"number" gencodec:"required"`
GasLimit uint64 `json:"gasLimit" gencodec:"required"`
GasUsed uint64 `json:"gasUsed" gencodec:"required"`
Time uint64 `json:"timestamp" gencodec:"required"`
Extra []byte `json:"extraData" gencodec:"required"`
MixDigest common.Hash `json:"mixHash"`
Nonce BlockNonce `json:"nonce"`
}
  • ParentHash - 父区块块头的哈希值

  • UncleHash - 叔叔区块块头的哈希值

  • Coinbase - 挖出区块矿工的地址

  • Root - 状态树的Hash

  • TxHash - 交易树的Hash

  • ReceiptHash -收据树的Hash

  • Bloom - 布隆过滤器,帮助轻节点筛选区块中是否存在需要的收据信息

  • Difficulty - 挖矿难度

  • Number - 区块序号

  • GasLimit - 区块中所有交易所消耗的gas总量必须低于该值。挖出区块的矿工可以对该值进行微调:$上一个区块的GasLimit \pm \frac{1}{1024}$ ,最终这个值会趋于一个大家都觉得合理的值

  • GasUsed - 区块中所有交易实际消耗的gas总量

  • Time - 区块创建时间

  • Extra - 额外信息

  • MixDigest - 区块哈希

  • Nonce - 挖矿使用的随机数

区块验证

  1. 检查引用的前一个区块是否存在且有效。

  2. 检查区块的时间戳是否大于前一个区块的时间戳,并且比当前时刻不超过15分钟。

  3. 检查区块编号、难度、交易树根(transaction root)、叔叔区块根(uncle root)和gas限制是否有效。

  4. 检查区块上的工作量证明是否有效。

  5. 以前一个区块的状态为初始状态,依次执行区块中的交易。如果任何交易执行失败,或者消耗的总gas超过GASLIMIT,则返回错误。

  6. 若都执行成功,将出块奖励添加到矿工账户。

  7. 检查最终状态的Merkle树根是否等于区块头中提供的最终状态根。如果是,则该区块有效;否则,它无效。

5. 共识

工作量证明PoW

以太坊GHOST

比特币的平均出块时间为10分钟(通过挖矿难度控制),一笔交易被确认写入区块链需要1小时(6个区块),如此高的延迟对于日常交易是极其不便的。但如果出块时间过快,又会导致系统容易被攻击(比如双花攻击)。因为,区块在网络中传播需要时间,如果出块时间小于网络传播时间,就会导致算力分散(当新区块传播到对方时,对方的新区块甚至第二个新区块都已经产生了)。从而导致攻击者即使不够51%算力,也能发动攻击。如下图,攻击者A集中算力私自产生6个区块,即使它未超过系统总算力一半(6/18),也能超过最长链:

GHOST(Greedy Heaviest Observed Subtree,贪婪的最重要的观察子树)协议由Yonatan Sompolinsky和Aviv Zohar于2013年12月首次推出,是一种主链选择协议,为了解决出块时间缩短导致系统不安全的问题。它在选取主链时,不是根据长度,而是根据子树中区块的数量,因此GHOST也可被称为最重子树原则。如上图,面对1B和1A的分叉,GHOST会选择1B,因为1B所在子树中区块数量更多,接着选择2C→3D→4B。

比特币中还存在一大问题:算力中心化。时至今日,最大的矿池已占系统总算力的30%。当出块时间过快时,大矿池可能主导挖矿过程。进而导致其它矿工的算力被浪费,无法获得奖励,其积极性也被打压。

以太坊在GHOST协议上作出修改,既保证主链的安全,又使得与主链相连的孤块也能获取收益。具体定义如下:

  • 区块必须指定父区块,同时还可以指定0-2个叔叔区块。

  • 区块B中指向的叔叔区块必须具有以下属性:

  • 它必须是B的第k代祖先的直接子区块,其中2 <= k <= 7(注:第1代祖先指自己,第2代祖先指父区块,第3代祖先指爷爷区块)。

  • 它不能是B的祖先。

  • 叔叔区块必须是有效的区块头,但不需要是先前验证、甚至有效的区块,区块中交易不需要执行

  • 叔叔区块不能被双重包含。

  • 对于区块B包含的每个叔叔区块,B的矿工获得额外1/32的出块奖励,叔叔区块的矿工获得的(8-k+1)/8出块奖励(注:父区块的子区块获得7/8奖励,爷爷区块的子区块获得6/8,直到2/8)。

上述定义的作用:

  1. 避免算力中心化,激发个体矿工积极性。

  2. 鼓励大家沿着主链挖矿而不是攻击链(即便攻击链更长,但主链上叔叔区块更多),从而达到GHOST选择最重子树的效果

为什么规定7代之内的叔叔区块才有奖励?因为不限制的话,有可能包含前100代的叔叔区块,那全节点要保存的区块数据将非常庞大(因为要验证叔叔区块不能被重复包含)。

以太坊挖矿算法ETHASH

比特币中,矿工通过不断调整区块头数据,并用SHA256计算区块头哈希,使区块哈希小于目标难度值,从而达成工作量证明,即成功挖出区块。随着比特币的飞速发展,挖矿过程逐渐专业化和中心化。首先,挖矿的芯片已由普通CPU转向了ASIC等专业芯片。其次,挖矿越来越趋于中心化,依赖于中心化矿池,排名前几的矿池已拥有超过系统50%的算力。普通人难以参与到挖矿过程中,这显然违背了中本聪创建比特币时”one-CPU-one-vote”的初衷。

为了抵制专用芯片导致的挖矿专业化,一种方法是设计Memory-Hard(花费大量内存)挖矿算法:在挖矿时不仅要考虑算力,还需考虑内存。因为,专业挖矿芯片的哈希计算能力强,但内存访问效率并不高。如果在挖矿算法中不仅要求计算哈希,还需要不断访问内存,算法瓶颈就会受内存访问速率的限制(冯诺依曼体系的问题)。那会不会出现计算能力和内存访问都高效的新设备呢?会,但不会像ASIC芯片那样拥有极高的哈希计算能力,因为受制于内存访问速率。而如果能让内存访问速度和芯片计算速度一样,那将是计算机领域的重大突破。目前,以太坊主要是使用GPU和部分专业矿机挖矿,而比特币只能使用专业矿机挖矿,这说明ETHASH能一定程度上抵制专业矿机,但无法杜绝挖矿越来越卷。

设计Memory-Hard算法还需考虑的一个问题是:如何方便轻节点验证?如果内存要求太大,轻节点将无法验证(轻节点内存有限)。而如果要求太小,又起不到明显的抵制作用(缓存可以解决)。莱特币也采用了Memory-Hard挖矿算法,但只要求168K的内存,最终并未起到显著效果。但莱特币凭借着可抵制专业矿机的宣传,解决了冷启动问题(货币初期没人使用的问题)。

为了既能保证Memory-Hard,又方便轻节点验证,以太坊设计了挖矿算法ETHASH。算法过程如下:

算法初始化:

  • 通过一个seed种子生成一个16MB的缓存数组cache:cache[0]=keccak512(seed),cache[i]=keccak512(cache[i-1]),keccak512是第三代哈希算法(SHA-3)。

  • 再根据缓存数组生成一个1GB的数据集数组dataset,也被称为DAG。数组中第i个元素的生成过程如下:

  • 每隔30000个区块,seed会重新生成,缓存数组和数据集数组的大小也会增大一次,增大初始大小的1/128。

dataset数组用于全节点挖矿,cache数组用于轻节点验证。dataset数组中每个元素都可通过cache数组计算生成。全节点挖矿时,需要不断计算区块哈希,而计算区块哈希又需要频繁访问dataset数组,为了节省时间,全节点会在内存中保存dataset数组。而轻节点验证时,只需计算一次区块哈希,则可临时通过cache数组计算出dataset数组中相应位置的值。挖矿和验证的伪代码如下:

为什么要抵制ASIC专业矿机?为了让普通计算机也参与到系统中,参与的计算机越多越能保证系统的安全性。但也有人认为专业矿机更能保证系统安全,因为攻击者想要攻击系统必须购买专业矿机,而专业矿机只适用于某一种加密货币,无法通用。因此攻击者发动攻击的成本更高,如果攻击失败,矿机只能挖矿或出售;如果攻击成功,加密货币声誉受损、价值暴跌,矿机又没用了。

值得一提的是,ETHASH算法仍属于PoW范畴,而以太坊已计划向PoS转移。

权益证明PoS

工作量证明(PoW)是由中本聪提出的一种共识机制,已在比特币中得到充分的安全性验证。但它消耗大量电力资源的问题,也被广为诟病。权益证明机制(Proof of Stake, PoS) 是一种新的共识机制,它根据参与者的权益(比如持有币的数量)投票达成共识(类似于股份制),大幅减少电力消耗。以太坊将要使用的PoS算法为:Casper,但目前依旧处于探索阶段。

对于PoW的电力消耗,存在另一种声音:挖矿所消耗的电力并不算太大,同时很多发电厂产生的电力并不能被充分使用,甚至被浪费。而大部分矿池修建在发电厂旁边,能够更好将电力转换为金钱。

6. 智能合约

在以太坊中,智能合约是一种账户,拥有余额也能发送交易。此外,合约账户还拥有code字段(用于存储代码)和storage字段(用于存储数据)。用户可以通过向合约发送交易,来执行合约中的相关代码

合约创建/部署

智能合约可先由Solidity、Vyper等高级语言编写,再编译成字节码(EVM代码,类似汇编代码),然后才能部署到以太坊上。当部署合约时,外部账户发起一笔收款地址为0x0的转账交易,并将编译好的合约代码放在交易的data域中。交易的转账金额是0,但需支付gas汽油费。当交易被成功写入区块链时,就意味着合约被成功创建。

合约代码执行

合约账户保存的代码是由基于堆栈的字节码语言编写的,是一种低级语言,被称为“以太坊虚拟机代码”或“EVM代码”。EVM代码就像一个字节数组,每个字节可表示一个操作码,每个操作都会消耗一定量的汽油费(gas)。 比如操作码ADD,将栈顶两个元素弹出并相加,消耗3 gas。代码会依次执行,每次执行程序计数器(pc)都会加1,直到代码结束、或遇到错误、或执行到STOP/RETURN指令。

代码可以访问的数据空间:

  • 堆栈,栈。

  • 内存,一个无限可扩展的字节数组。

  • 合约的存储,键/值存储。与堆栈和内存不同,堆栈和内存在计算结束后重置,而存储会持久化。

  • 消息的金额值、发送者、数据,以及区块头数据

代码可以返回数据的字节数组作为输出。

注意:

  • 为了保证一致性,智能合约不支持多线程。因为不同线程对内存访问顺序不一致的话,执行结果可能不一致。

  • 智能合约的代码**一旦发布就不能修改(code is law)**,好处是谁也无法修改规则,坏处是无法修复漏洞。

重入攻击

一个用于拍卖的智能合约代码示例:

其中,withdraw函数由投标者调用,用于取回出价。函数代码中,msg.sender.call.value(amount)是向消息的发送方(也就是代码的调用者)发送amount数量的以太币。

代码逻辑初看没有问题,但却存在致命漏洞。攻击者先创建一个合约账户HackV2,在账户的代码中定义fallback函数(匿名函数,当向合约地址转账而不指定函数时,或指定函数不存在时,fallback函数会被调用),并在fallback函数中调用拍卖合约的withdraw函数。然后,攻击者就可以发动攻击:

  1. 攻击者以合约账户HackV2的身份加入拍卖;

  2. 拍卖结束后,攻击者通过合约账户HackV2的hack_withdraw函数,调用拍卖合约中的withdraw函数取回自己的出价。当withdraw函数执行到msg.sender.call.value(amount)时,会向HackV2账户发送一笔转账交易,交易会触发HackV2的fallback函数。而fallback函数又会调用拍卖合约中withdraw向自己转账,如此无限递归调用,直到gas汽油费被耗尽。如此,攻击者将获得数倍于出价的金额。

出现这个漏洞的原因是:withdraw函数先转账再将对应账户的余额置为0,而转账操作可以触发无限递归。正确的写法是先将账户余额置为0再转账,修改如下:

2016年,以太坊发生著名的**“The DAO”事件**,其原因就是黑客对合约发动重入攻击。为了挽救损失,以太坊社区决定发动硬分叉回滚:开发团队发布以太坊新版本,在代码中强制The DAO合约相关账户的钱转到另一个合约,甚至不需要私钥。但由于部分人不赞成这种回滚,从而导致以太币分化成两个版本ETH和ETC,延续至今。

7. 反思

肖老师课程倒数第二节内容:

  • Is smart contract really smart?

  • Irrevocability is a double edged sword.

  • Noting is irrevocability.

  • Is solidity the right programming language?

  • What is decentralize mean? 在The DAO事件中,部分人为了去中心化信仰,拒绝硬分叉。但硬分叉真的破坏了去中心化吗?以太坊开发团队虽然能发布升级,但最终实现硬分叉是因为大部分矿工同意更新,以太坊团队并不能强制矿工执行。所以,这还是一个去中心化的过程。

去中心化的规则并不是不能修改,而是要用去中心化的方法来完成。分叉恰恰是民主的一种体现。

  • decentralized ≠distributed。去中心必定是分布式,但分布式不一定去中心化。

  • 以太坊虽然能实现大规模分布式一致性,但性能并不高。所以它并不适用于大规模计算和存储,而更适用于编写关键逻辑。

8. 总结

13岁的V神沉迷暴雪公司的魔兽世界,可是有一天暴雪公司把他很喜欢的一个技能删除了,他多次发邮件反应,但都得不到想要的答复。V神很气愤,凭什么暴雪公司不征求用户意见,想删除就删除。怀揣着对这种中心化公司任性行为的不满,V神在他19岁那年,发布了以太坊白皮书,开创了区块链2.0时代。

比特币开创了区块链时代,但它仅仅实现了交易的去中心化。而以太坊让各式各样的去中心化应用成为可能,去中心化的拍卖、去中心化的慈善募捐、去中心化的投资等等。以太坊的出现,吸引了许多像V神这样对中心化不满的人们,他们从以太坊上看到了去中心化的光明未来。但随着热潮褪去,随着The DAO等安全事件的发生,我们发现去中心化还有很长的路要走,我们也不能盲目地推崇去中心化。

去中心化一定是万能的吗?去中心化一定好吗?去中心化与中心化一定是非黑即白的吗?去中心化意味着民主,但也存在效率低下、决策正确性等问题。而在一些场景,中心化已经表现得很好,就不必使用去中心化了,比如:在奶茶店买一杯奶茶,使用微信付款只需片刻,而若使用比特币你将等待1个小时。另外,对于The DAO这种去中心化风投,其效益还真不一定比中心化的专业风投机构高。其实,中心化与去中心化并不互斥,而是可以相辅相成,比如:在中心化的平台上使用用去中心化的交易方式。关于中心化与去中心化,映射到现实,就类似于专制与民主,值得深思与探讨。(参考肖老师课程最后一节)

参考

北京大学肖臻老师《区块链技术与应用》公开课_哔哩哔哩_bilibili