您正在查看: Ethereum-优秀转载 分类下的文章

以太坊源码解析:区块同步-Protocol

本篇文章分析的源码地址为:https://github.com/ethereum/go-ethereum
分支:master
commit id: 257bfff316e4efb8952fbeb67c91f86af579cb0a

引言

区块链本质上是分布式的,因此同步区块数据是必不可少的一个功能模块。在这篇文章以及接下来的几篇文章里,我们就来看一下以太坊中关于区块同步的代码。由于区块同步的代码比较多,逻辑也比较复杂,因此本篇文章里我们只是先看看关于协议的内容和数据收发的主要流程,后面的文章将会单独分析其它内容。

所有的公链项目在底层都使用了p2p技术来支持节点间的互联和通信。但本篇文章不会涉及到p2p的内容,一方面是因为p2p技术包含的内容很多,再多写几篇文章也不能分析完,更无法在当前文章里讲清楚;另一方面也是因为p2p属于自成体系的成熟技术了,也有成熟的库可以使用。

源码目录

以太坊中关于区块同步和交换的代码位于eth目录下和les目录下。其中eth实现了所有的相关逻辑,而les只是light同步模式下的实现。这可以从cmd/utils/flags.go中的RegisterEthService函数中看出来:

func RegisterEthService(stack *node.Node, cfg *eth.Config) {
    if cfg.SyncMode == downloader.LightSync {
        err = stack.Register(func(ctx *node.ServiceContext) (node.Service, error) {
            return les.New(ctx, cfg)
        })
    } else {
        err = stack.Register(func(ctx *node.ServiceContext) (node.Service, error) {
            ......
            return fullNode, err
        })
    }
}

当配置文件中的SyncMode字段为downloader.LightSync时,会使用les目录下的代码;否则使用eth目录下的代码。由于eth目录下的代码实现了全部的逻辑和功能,因此我们主要关注eth目录下的代码。

在eth目录下,和区块同步相关的主要的源代码文件有handler.go、peer.go、sync.go,以及downloader目录和fetcher目录。其中前三个源码文件定义了区块同步协议及整体工作框架,也是本篇文章要重点分析的内容;而后两个目录是我们之后的文章要分析的内容。

运行框架

我们用一张图来概括一下网络模块的运行框架:

这个框架主要由ProtocolManager对象实现。可以看到这个框架实现了消息处理、广播区块和交易、同步区块和交易等功能。这也是一个区块链项目区块同步的最基本功能。下面我们对一些功能进行详细的分析。

消息处理(handle)

每当p2p模块发现一个新的节点并连接完成时,就会调用p2p.Protocol.Run函数。而此函数的注册是在NewProtocolManager中完成的:

func NewProtocolManager(......) (*ProtocolManager, error) {
    ......

    for i, version := range ProtocolVersions {
        manager.SubProtocols = append(manager.SubProtocols, p2p.Protocol{
            ......
            Run: func(p *p2p.Peer, rw p2p.MsgReadWriter) error {
                peer := manager.newPeer(int(version), p, rw)
                select {
                case manager.newPeerCh <- peer:
                    manager.wg.Add(1)
                    defer manager.wg.Done()
                    return manager.handle(peer)
                case <-manager.quitSync:
                    return p2p.DiscQuitting
                }
            },
            ......
        })
    }

    ......
}

从代码中可以看出,每当有新的节点到来时,会先把节点对象发给newPeerCh,之后再调用ProtocolManager.handle方法。如果这个方法返回,那么p2p.Protocol.Run函数也就返回了,这代表这个连接也就可以断开了。

另外您可能也注意到上面的代码有一个基于ProtocolVersions的for循环。ProtocolVersions的定义如下:

var ProtocolVersions = []uint{eth63, eth62}

这表示在以太坊中有两个版本的区块同步协议。主要区别在于有一些消息是在eth63中新加的,eth62中没有。后面在涉及具体的消息处理代码时我们会看到区别。

ProtocolManager.handle

ProtocolManager.handle方法对每个新到来的连接进行处理。此方法稍长,我们一点一点进行分析。先看一下最开始的代码:

func (pm *ProtocolManager) handle(p *peer) error {
    // Ignore maxPeers if this is a trusted peer
    if pm.peers.Len() >= pm.maxPeers && !p.Peer.Info().Network.Trusted {
        return p2p.DiscTooManyPeers
    }

    ......
}

以太坊的节点连接有一个最大值,如果连接数已超过这个最大值且不是信任的连接,就马上返回,因此当前的连接就会中断。也就是说,如果是可信任的节点(Trusted Node),那么即使超过最大连接数也可以正常连接。

什么是可信任的节点呢?可信任的节点是我们自己设置的,你可以在js console中通过admin.addTrustedPeer来填加,或者在/trusted-nodes.json中设置。(详细信息可以参看这里,但文章中并没有提及trusted-nodes.json的设置,因此如何使用这个文件我也还没搞明白)。

接下来就是和对方“握手”,相互交换信息了:

func (pm *ProtocolManager) handle(p *peer) error {
    ......
    if err := p.Handshake(pm.networkID, td, hash, genesis.Hash()); err != nil {
        return err
    }
    ......
}

“握手”的详细信息我们后面会单独说明,这里暂时跳过。

我们继续来看“握手”完成后的代码:

func (pm *ProtocolManager) handle(p *peer) error {
    ......
    if err := pm.peers.Register(p); err != nil {
        return err
    }
    defer pm.removePeer(p.id)

    // Register the peer in the downloader. If the downloader considers it banned, we disconnect
    if err := pm.downloader.RegisterPeer(p.id, p.version, p); err != nil {
        return err
    }
    // Propagate existing transactions. new transactions appearing
    // after this will be sent via broadcasts.
    pm.syncTransactions(p)
    ......
}

这块代码有两个功能,一是告诉peers对象(一个peerSet类型的对象,用来管理所有的peer)和downloader对象有新的节点连接进来了;二是与新的节点同步自己所有pending状态的交易(syncTransactions)。

我们再来看接下来的代码:

func (pm *ProtocolManager) handle(p *peer) error {
    ......
    for number := range pm.whitelist {
        if err := p.RequestHeadersByNumber(number, 1, 0, false); err != nil {
            return err
        }
    }
    ......
}

这段代码向当前连接的节点请求所有的“白名单区块”。所谓的“白名单区块”是你认为的可信任的区块,这些区块的高度和哈希被写在配置文件里的Whitelist字段中。这些区块的目的是为了对接入的节点的数据正确性作一个简单的验证。在ProtocolManager.handleMsg中接收到区块时会判断区块高度是否在“白名单区块”列表中,如果在但哈希不一致,则认为这个节点的数据有问题,立刻断开与它的连接。这在后面介绍ProtocolManager.handleMsg时还会讲到。

然后我们看看最后一段代码:

func (pm *ProtocolManager) handle(p *peer) error {
    ......
    for {
        if err := pm.handleMsg(p); err != nil {
            return err
        }
    }
}

这段代码很简单但很关键。可以看到在一个for循环中不停调用ProtocolManager.handleMsg方法,如果失败就立刻返回。

ProtocolManager.handleMsg

接下来我们看看ProtocolManager.handleMsg的实现。看名字就知道这个方法是用来处理接收到的消息的。其主要的框架代码也非常简单,就是从对方连接中读取消息,根据消息码的不同进行处理:

func (pm *ProtocolManager) handleMsg(p *peer) error {
    msg, err := p.rw.ReadMsg()
    if err != nil {
        return err
    }
    if msg.Size > ProtocolMaxMsgSize {
        return errResp(ErrMsgTooLarge, "%v > %v", msg.Size, ProtocolMaxMsgSize)
    }
    defer msg.Discard()

    switch {
    case msg.Code == StatusMsg: ......
    case msg.Code == GetBlockHeadersMsg: ......
    case msg.Code == BlockHeadersMsg: ......
    case msg.Code == GetBlockBodiesMsg: ......
    case msg.Code == BlockBodiesMsg: ......
    case p.version >= eth63 && msg.Code == GetNodeDataMsg: ......
    case p.version >= eth63 && msg.Code == NodeDataMsg: ......
    case p.version >= eth63 && msg.Code == GetReceiptsMsg: ......
    case p.version >= eth63 && msg.Code == ReceiptsMsg: ......
    case msg.Code == NewBlockHashesMsg: ......
    case msg.Code == NewBlockMsg: ......
    case msg.Code == TxMsg: ......
    default: return errResp(ErrInvalidMsgCode, "%v", msg.Code)
    }
    return nil
}

具体的针对每一个消息的处理,我们就不一一展开分析了,这些代码虽然较多,但逻辑还是比较简单的。不过有几个问题这里仍然需要多说一下。

fetcher与downloader

在一些消息处理代码中,会将收到的数据交给fetcher的filter方法处理,剩下的再交给downloader的Deliver方法处理。这是因为与其它节点发起数据交互的地方不止一处,其中就有fetcher和downloader对象,而所有交互数据都会经过ProtocolManager.handleMsg进行处理,因此就需要知道这些数据是谁主动发起获取的。这里将数据先传给fetcher的fileter函数,就是让fetcher把自己发起获取的数据留下,从而将剩下的交给downloader。
关于fetcher和downloader的详细信息,我们会在以后的文章进行介绍。

NewBlockMsg与NewBlockHashesMsg

NewBlockMsg消息用来接收或发送整个block数据(普通获取完整block的方式是先获取header、再获取body);NewBlockHashesMsg只是接收或发送一个block的哈希。这俩个消息是有些类似的,为什么会存在这么两个消息呢?

当ProtocolManager.broadcastBlock的第二个参数propagate为true时,会向其它节点发送NewBlockMsg消息。而propagate为true的情况有两种:一是fetcher模块中将一个同步到的区块加入到本地数据库前,使用这个消息通知其它节点自己的区块有更新;二是本地节点的挖矿模块出了一个新的区块时,使用消息将这个新区块发送给自己的其它节点。相关代码如下:

//订阅新挖到的区块消息,并广播新区块
func (pm *ProtocolManager) minedBroadcastLoop() {
    for obj := range pm.minedBlockSub.Chan() {
        if ev, ok := obj.Data.(core.NewMinedBlockEvent); ok {
            pm.BroadcastBlock(ev.Block, true)  // First propagate block to peers
            pm.BroadcastBlock(ev.Block, false) // Only then announce to the rest
        }
    }
}

//fetcher中将区块插入本地数据库之前,广播将要插入的区块
func (f *Fetcher) insert(peer string, block *types.Block) {
    ......

    switch err := f.verifyHeader(block.Header()); err {
    case nil:
        go f.broadcastBlock(block, true)
    }
    ......
}

也就是说,当fetcher在本地插入新区块时和本地挖到新的区块时,将会发送NewBlockMsg消息。

无论ProtocolManager.broadcastBlock的第二个参数propagate为true还是false,都会向其它节点发送NewBlockHashesMsg消息。所以除了刚才提到的发送NewBlockMsg消息的情况,还有一种情况是区块同步完成时,会广播自己的最新区块信息:

func (pm *ProtocolManager) synchronise(peer *peer) {
    ......
    if err := pm.downloader.Synchronise(peer.id, pHead, pTd, mode); err != nil {
        return
    }
    ......

    if head := pm.blockchain.CurrentBlock(); head.NumberU64() > 0 {
        go pm.BroadcastBlock(head, false)
    }
}

还有一点需要注意的是,当ProtocolManager.broadcastBlock的第二个参数propagate为true时,不会给所有节点发送NewBlockMsg消息,而是从没有这个区块的节点中,选取一部分(平方根):

func (pm *ProtocolManager) BroadcastBlock(block *types.Block, propagate bool) {
    if propagate {
        ......
        transferLen := int(math.Sqrt(float64(len(peers))))
        if transferLen < minBroadcastPeers {
            transferLen = minBroadcastPeers
        }
        if transferLen > len(peers) {
            transferLen = len(peers)
        }
        transfer := peers[:transferLen]
        for _, peer := range transfer {
            peer.AsyncSendNewBlock(block, td) //发送NewBlockMsg
        }
        return
    }
    ......

整体上来看,NewBlockMsg和NewBlockHashesMsg这两个消息的达到的目的相同,我猜测使用NewBlockHashesMsg的目的,是为了减少同一时刻的网络流量,因为在广播时有的节点直接发送整个区块数据,而另一些只发送哈希。但NewBlockMsg有一个重要的功能,就是接收者可以更新本地记录的对方的Head数据,这一点我们会在后面"Head数据的更新"小节里详细说明。

whitelist block

前面我们提到whitelist字段,在连接之初时会向对方请求whitelist中的哈希代表的区块。在请求发生后,正常情况应该会收到对方回复的BlockHeadersMsg消息,我们看一下这个消息其中一处的处理代码:

func (pm *ProtocolManager) handleMsg(p *peer) error {
    ......
    switch {
        case msg.Code == BlockHeadersMsg:
            filter := len(headers) == 1
            if filter {
                if want, ok := pm.whitelist[headers[0].Number.Uint64()]; ok {
                    if hash := headers[0].Hash(); want != hash {
                        return errors.New("whitelist block mismatch")
                    }
                }
            }
    }
}

可见当收到的header只有一个时,就会去判断whitelist,如果高度在whitelist列表中但哈希不一致,说明对方的数据有问题,我们要马上断开与它的连接。所以此处的代码和连接之初发送whitelist中的区块请求的代码,一起构成了对白名单区块的验证。

handshake

在与别的节点建立连接之初,会与其进行“握手”操作,这就是是由peer.Handshake方法完成的。这一小节里我们就详细看一下握手的功能实现。

peer.Handshake特别简单,就是给对方发送Status数据,并等待接收对方的Status数据,然后保存接收到的数据。这中间会设置一个超时时间。代码如下:

func (p *peer) Handshake(network uint64, td *big.Int, head common.Hash, genesis common.Hash) error {
    //发送自己的数据对方
    go func() {
        errc <- p2p.Send(p.rw, StatusMsg, &statusData{
            ProtocolVersion: uint32(p.version),
            NetworkId:       network,
            TD:              td,
            CurrentBlock:    head,
            GenesisBlock:    genesis,
        })
    }() 
    //等待接收对方的数据
    go func() {
        errc <- p.readStatus(network, &status, genesis)
    }()

    //等待数据发送和接收都完成,或者超时
    //有个for循环2是因为要分别等待和判断发送/接收两个情况
    timeout := time.NewTimer(handshakeTimeout)
    defer timeout.Stop()
    for i := 0; i < 2; i++ {
        select {
        case err := <-errc:
            if err != nil {
                return err
            }
        case <-timeout.C:
            return p2p.DiscReadTimeout
        }
    }

    //保存td和head
    p.td, p.head = status.TD, status.CurrentBlock
    return nil
}

可以看到握手时使用的消息是StatusMsg,交换的数据由statusData结构体保存:

type statusData struct {
    ProtocolVersion uint32
    NetworkId       uint64
    TD              *big.Int
    CurrentBlock    common.Hash
    GenesisBlock    common.Hash
}

通过这个结构体,握手时交换的哪些信息也是一目了然的。

另外值得注意的是,peer.Handshake是在消息处理循环(ProtocolManager.handle最末尾的那个循环)之前调用的,所以才会自己接收消息而不会经过ProtocolManager.handleMsg(其它情况下消息都是由ProtocolManager.handleMsg处理的)。StatusMsg消息在握手以后就再不会被使用,因此在ProtocolManager.handleMsg中对StatusMsg的处理是返回失败:

func (pm *ProtocolManager) handleMsg(p *peer) error {
    ......
    switch {
    case msg.Code == StatusMsg:
        // Status messages should never arrive after the handshake
        return errResp(ErrExtraStatusMsg, "uncontrolled status message")
    }
    ......
}

发起区块同步

区块同步的功能由downloader实现。这里我们只关注于区块同步的发起,具体如何同步的我们将在后面的文章中进行介绍。

在ProtocolManager.Start中创建几个go routine,其中一个就是ProtocolManager.syncer,而区块同步的发起就是在这个方法里的。我们先看一下它的代码:

func (pm *ProtocolManager) syncer() {
    ......
    forceSync := time.NewTicker(forceSyncCycle)
    defer forceSync.Stop()

    for {
        select {
        case <-pm.newPeerCh:
            if pm.peers.Len() < minDesiredPeerCount {
                break
            }
            go pm.synchronise(pm.peers.BestPeer())

        case <-forceSync.C:
            go pm.synchronise(pm.peers.BestPeer())

        case <-pm.noMorePeers:
            return
        }
    }
}

可以看到有两种情况会发起区块同步:一是有新的节点建立连接时;二是每隔forceSyncCycle长的时间(10秒)就会同步一次。我认为这里最值得关注是Bestpeer这个方法。在以太坊的同步时,会先选择一个节点从它那同步一些“骨架”区块(skeleton,即每隔一定的高度同步一个区块),然后从其它节点中获取区块填充“骨架”(这一机制在downloader中实现,后面的文章我们会详细分析)。那么如何选取同步“骨架”节点呢?就是这里的BestPeer。我们看一下它的实现:

func (ps *peerSet) BestPeer() *peer {
    ps.lock.RLock()
    defer ps.lock.RUnlock()

    var (
        bestPeer *peer
        bestTd   *big.Int
    )
    for _, p := range ps.peers {
        if _, td := p.Head(); bestPeer == nil || td.Cmp(bestTd) > 0 {
            bestPeer, bestTd = p, td
        }
    }
    return bestPeer
}

很简单,所谓“最好的节点”就是TD(TotalDifficulty,关于TD的详细信息可以参看这篇文章)最大的节点。

真正发起同步的代码中ProtocolManager.synchronise中,代码简单,这里只说一下两个细节:

如果对方节点的TD小于自己的,就没必要同步了。
同步完成后,通过NewBlockHashesMsg广播自己的最新的区块,从而告诉别人。

广播

ProtocolManager对象还有广播的功能,主要是广播本地刚挖到的区块和新生成的交易。这主要是通过ProtocolManager.minedBroadcastLoop和ProtocolManager.txBroadcastLoop实现的。这两个方法订阅了本地挖到新区块和本地生成新交易的信息,每当有数据到来时,就调用ProtocolManager.BroadcastBlock或ProtocolManager.BroadcastTxs将其广播给其它节点。

peer与peerSet

在这一小节里,我们说的peer和peerSet对象指的是eth/peer.go文件中的对象。peer对象代表的是与之建立连接的对方节点,其主要功能有两个:一是在本地记录对方一些已经拥有的数据,比如对方已经拥有了哪些block、哪些transaction、对方最新的区块哈希和TD是多少;二是发送数据给对方。而peerSet是peer对象的集合,是对peer对象的管理。下面我们就来看看一些主要的功能。

已知数据

在peer对象中,保存着对方的一些我们可以统计到的数据,这主要包括三部分内容:

  1. 对方的主链上的最新区块哈希和TD。这是由字段peer.head和peer.td保存的
  2. 对方已经拥有的区块哈希。这是由字段peer.knownBlocks保存的
  3. 对方已经拥有的交易哈希。这是由字段peer.knownTxs保存的

拥有了所有连接的最新的区块哈希和TD,我们就可以在同步前从这些连接中找到TD值最高的那个连接与之同步(peerSet.BestPeer);而知道哪些节点忆拥有哪些区块和交易,我们在广播区块和交易时就可以不再给这些节点广播,从而避免流量的浪费。

在peer对象中,设置peer.knownBlocks和peer.knownTxs的地方有两大块:一是几个send方法,比如peer.AsyncSendTransactions和peer.SendNewBlock等;二是两个mark方法:peer.MarkBlock和peer.MarkTransaction。其中peer.MarkBlock在处理NewBlockHashesMsg和NewBlockMsg时被调用;peer.MarkTransaction在处理TxMsg时被调用。

发送

peer对象还负责给对方发送数据,这主要由一些send方法实现的,它们的命名很容易理解,因此这里不再一一列举。其有基本上每个数据都有同步发送和异步发送的方式。发送数据的实现很简单,这里不再详细说明了。

Head数据的更新

这里所谓的“Head数据”,是指peer.head和peer.td字段,这两个数据记录着对方节点的主链上最新的区块的哈希,和主链上的TD。

前面我们提到过,在发起同步时的"BestPeer"选取的是所有连接中TD值最大的那个。但这个TD值是缓存在本地的,如果对方的TD发生了变化,我们本地怎么能知道并及时更新呢?

想要解决我这个问题,我们先看看代码中哪些地方修改了peer.td 这个字段。经过查找只有peer.SetHead这个方法。而调用这个方法的代码只有处理NewBlockMsg消息时:

func (pm *ProtocolManager) handleMsg(p *peer) error {
    ......
    switch {
    case msg.Code == NewBlockMsg:
        ......
        if _, td := p.Head(); trueTD.Cmp(td) > 0 {
            p.SetHead(trueHead, trueTD)
            ......
        }
    }
}

也就是说只有收到对方的NewBlockMsg消息,我们才有可能更新对方的Head数据。在前面分析NewBlockMsg和NewBlockHashesMsg的小节里,我们已经分析过有两种情况对方有可能会发出NewBlockMsg消息:

  • fetcher模块中将一个同步到的区块加入到本地数据库前
  • 本地挖矿模块出了一个新的区块时

之所以说有可能,原因之一是需要对方认为我们没有将要广播的区块,才会发NewBlockMsg消息给我们(即对方的peer.knownBlocks中没有将要发出的区块)。

而fetcher在什么情况下会得到一个区块并将其加入到本地数据库中呢?经过分析ProtocolManager对象是如何使用ProtocolManager.fetcher字段的,可以发现在处理NewBlockHashesMsg时会调用fetcher.Notify;而在处理NewBlockMsg时会调用fetcher.Enqueue。这两个方法都会导致将一个区块加入本地数据库中。

因此我们可以说,当发生以下任意一种情况时,且对方认为我们没有它将要广播的区块时,就会发NewBlock消息给我们,让我们有机会更新它的Head数据:

  • 对方挖矿模块挖出一个新区块
  • 对方收到NewBlockMsg消息
  • 对方收到NewBlockHashesMsg消息并成功获取到其中的哈希代表的区块

这里要注意一点的是,“对方认为我们没有它将要广播的区块”这一前提使得在有些情况下并不能使自己最新的Head数据及时让其它节点知道。比如某对方节点A虽然一直在更新,但它的本地记录(peer.knownBlocks)显示自己要广播的区块我们都有,就不会发送NewBlockMsg给我们,我们自然也就不会及时更新A节点的Head数据。但这不重要,因为A节点有的区块其它节点都有,我们也没必要从A这同步数据。

总结

我们在这篇文章里主要分析了以太坊区块同步协议框架的实现。以太坊会在ProtocolManager中设置有新连接时的回调函数,当这个函数返回时,连接断开。当有新连接加入时会与其“握手”交换一些基本信息(statusData),随后循环调用ProtocolManager.handleMsg对接收到的消息进行处理。

由于所有消息都由ProtocolManager.handleMsg接收处理,而消息发起方可能有fetcher或downloader模块。因此当handleMsg接收到一些回复消息时,就先将数据送给fetcher过滤,fetcher留下自己发起的消息返回的数据,返回其它数据。这些其它数据再全部被送给downloader。

ProtocolManager还会定时或在有新节点接入时发起区块同步。区块同步的逻辑主要是在downloader中实现的,本篇文章没有涉及downloader模块。

handleMsg对接收到的消息进行处理,而代表每一个连接的peer对象则具体负责发送消息。此外peer对象还保存了对方连接的一些“已知的数据”,这些数据让我们自己知道对方应该已经有了哪些区块、哪些交易,这样我们在广播数据时就可以避免给拥有这些数据的节点广播,从而避免了流量的浪费。

转载自:https://www.jianshu.com/p/73e50e4fb57b

结合 GHOST 和 Casper

https://arxiv.org/abs/2003.03052

一种基于权益证明的共识协议,它是所提议的以太坊 2.0 信标链的理想化版本。该协议将 Casper FFG(一种确定性工具)与 LMD GHOST(一种分叉选择规则)相结合。我们在不同的假设集下证明了安全性、似是而非的活跃度和概率活跃度。

https://cs.paperswithcode.com/paper/combining-ghost-and-casper
https://github.com/ethereum/eth2.0-specs

Eth2.0 中的 Casper FFG

感谢 Danny Ryan 的讨论和评论

我的上一篇文章讨论了基本的 Casper Friendly Finality Gadget。本文的第一部分将重点介绍信标链中 Casper FFG 机制实现的高级细节。这篇文章的第二部分将讨论分叉选择规则和其他活性考虑因素。

这篇文章直接从 Eth2.0 规范中解释了一些事情。在可能的情况下,规范中的相关参数和功能的链接已包含在内。理解这篇文章不需要遵循这些链接——它们只是为了参考而包含在内。

第 1 部分 - Casper FFG 机制

插槽、时期和证明

Slots

时间被划分为 slot,每个 slot 可以提出一个新的区块。当前时隙的持续时间是SECONDS_PER_SLOT = 12。对于每个插槽,都会分配一个验证器来生成一个新块

Epochs

Casper FFG 机制不对完整的块树进行操作,而是仅考虑来自某些槽的块用于投票。这减少了在尝试通过查看投票来检测最终性时检查太多源-目标对的开销。由于这些特殊插槽之间有足够的空间,预计每次运行 FFG 最终性检查时都会看到来自绝大多数验证者的新投票。时隙被组合成 epoch,当前参数为SLOTS_PER_EPOCH = 32,导致每个 epoch 为 6.4 分钟。FFG 机制仅考虑位于这些 epoch 周期边界的块(“检查点”或“epoch 边界块 (EBB)”)。

证明

证明是 Casper FFG 投票,其中包含诸如源和目标块、证明时的槽号、验证者的标识符等信息。证明由验证者广播到 p2p 网络,并最终被一个块接收生产者被打包成块。

Casper FFG 机制的变化

与我在上一篇文章中提到的定义相比,最终确定的定义发生了变化。

最终确定:一个块在以下B_0情况下被确定:

  • 它是创世区块,或者
  • 以下两个条件成立:
    • 有一系列检查点,按照槽号的递增顺序,[B_0, B_1, ... , B_n]使得n >= 1所有这些块都在同一个链中并且是合理的,并且
    • 超过 2/3 的验证者投票(B_0, B_n)。

定义的这种变化仍然保留了上一篇文章中 Casper FFG 安全证明的大纲。完整的证明可以在本文的“安全”部分找到

检测 Casper FFG 确定性

信标链具有链上 FFG 机制,可处理块和证明以检测最终性。在每个 epoch 边界,该机制处理新的证明并更新其对合理和最终区块的知识。

为了降低在任何可能的源-目标对之间处理证明的开销,链上 FFG 机制只考虑特定的源-目标对。仅处理在当前和上一个 epoch 中进行的证明(不过还有一些条件!)。这导致链上 FFG 机制无法检测到所有确定性实例!总之,链上机制健全但不完善。

还引入了网络同步假设,因为仅处理来自最后两个时期的证明:证明在两个时期内在网络中广播。
链上FFG机制的规范非常简单:

  • 第一步是检测块的合理性。使用最近两个 epoch 的新证明检查两个最近的 epoch 边界块是否合理。
  • 下一步是检测块的最终确定,检查最后两个纪元边界块。最终性检查仅使用四种源-目标组合(用于提高性能和规范的简单性):

第 2 部分 - 分叉选择和验证者时间表

虽然 Casper FFG 机制概述了保证区块最终确定的规则,但它没有提及在实践中如何实现活跃性(注意:这篇文章并不试图证明活跃性,而是概述了预期实现活跃性的过程。有关严格的分析,请参阅本文)。这篇文章的这一部分将重点关注两个主要的活跃度考虑:

  • 验证者为找到链头而执行的分叉选择规则
  • 验证者生成区块和证明的时间表

HLMD GHOST 分叉选择规则

提出区块的验证者必须首先找到其链的本地头,为此他们使用混合最新消息驱动 (HMLD) GHOST 分叉选择规则。
此分叉选择的规格如下:

  1. 在每个 epoch 开始时,在验证器的当前视图中识别最新的合理区块。该变量在该时期被冻结,并在下一个时期开始时再次更新。
  2. 从步骤 1 中过滤掉任何没有对齐块的块,作为该块链中的最新对齐块。
  3. 使用通常的 LMD GHOST 规则通过块树下降,直到找到叶块。

有关前叉选择的更多信息,请查看本文中的“Hybrid LMD GHOST”部分

验证人时间表

每个验证者对网络有两个主要责任:提出新区块,并在他们的本地视图中证明最佳区块。指定验证器时间表以防止混乱并简化网络中的消息传递。该时间表由每个验证者通过从当前信标链状态中获取随机性来计算,这可以防止攻击者指定要启用的时间表。

提案时间表

对于每个 epoch 中的每个时隙,分配一个验证者作为提案者。然后,验证器在其块树的本地视图中使用分叉选择找到链的头部,并为头部生成一个新的子块。验证者看到的证明可以打包到区块中以获得奖励,这些作为最终性检查运行时链上 FFG 机制的输入。

证明时间表

每个验证者都被分配在每个时期的一个插槽中进行证明。在实践中,整个验证人集被随机划分SLOTS_PER_EPOCH为每个 epoch 的大小相等的委员会,并且每个委员会在 epoch 中被分配一个特定的插槽来进行证明。当验证者进行证明时,他们应该将源作为最后一个合理的块,并将目标作为链头后面的最新检查点,根据他们的本地视图。

有关验证器时间表的更多信息,请查看Eth2.0 规范中的验证器指南

参考资料和附加材料

  1. 以太坊 2.0 阶段 0 规范
  2. “结合 GHOST 和 Casper”论文

原文:https://www.adiasg.me/eth2/2020/04/09/casper-ffg-in-eth2-0.html

何为CASPER FFG

译者序:Eth 2.0信标链的共识协议将逐渐从PoW向PoS过渡,在这条全新的链上,如何保障其安全性和活性呢?Casper FFG 作为区块最终确定工具,为eth2带来了福音。通过这篇文章,我们可以对Casper FFG的运行规则、安全性证明和活性证明有一个初步的了解。

为了对用户负责,任何区块链都必须保证链上区块的最终确定性和链的活性,而区块链共识机制的根本就是提供这些保障。在Eth2.0中,共识过程只在信标链中进行,而Casper FFG机制则保证其运行。本文主要描述了Casper FFG机制的基本概念,举例阐释其在信标链上的作用。

Casper FFG作为一项“最终确定性工具”(finality gadget),为区块的最终确定制定了规则,并对已被确定的区块进行检测。FFG独立于区块链本身的增长过程,可以作为一个叠加层为任何有效的区块链协议提供区块最终确定性。

就这方面而言,Casper FFG并非一项成熟的共识协议,因其本身并没有相关设置,以保证链的活性。(或许我会择时再写一篇文章阐释影响Eth2.0信标链活性的相关因素)

让我们先来看看Casper FFG机制的结构,然后深入了解其规则,从而理解FFG如何保证信标链的安全性和活性!

投票


验证者通过验证区块间的交易是否有效,给链上的区块进行投票。每记投票的格式为(S, T),包含以下信息:

  • 来源区块 (S)
  • 目标区块 (T), 必须产生于S之后

实际操作中,一记投票需包含以下内容:验证者的数字签名以及相应区块信息(包括区块哈希和区块高度)。

证明&最终确定

“证明”(Justification) 和“最终确定”(finalization) 是Casper FFG机制下的两个共识执行阶段,可以类比于更为传统的拜占庭容错共识协议 (BFT) 的“准备”和“执行”阶段


2/3验证者投票证明了区块A和区块B

证明

区块B如果满足以下条件,则已被证明:

  • 是创世区块,或者
  • 超过2/3的验证者投出了(A,B)票,A是在B之前产生的区块,且已被证明。

最终确定

区块B如果满足以下条件,则已被最终确定:

  • 是创世区块,或者
  • B已被证明,且超过2/3的验证者投了 (B,C) 票,以及C是B的直接子区块(即C区块高度=B区块高度+1)

注意:关于Eth2.0 Casper FFG机制的最终确定规则,还有更加全面的阐释,详情请参阅此论文。上文所给的具体阐释,只是为了让本文更加通俗易懂。

Casper FFG的规则

Casper FFG机制有两条简单的规则:

验证者不可以进行以下任何一种情况中的 (S1, T1) 和 (S2, T2) 投票 1.区块高度(T1) = 区块高度(T2),或者 2.区块高度(S1) < 区块高度(S2) <区块高度(T2) < 区块高度(T1)

(译者注:这两种情况在Eth2中被描述为“双重投票”和“环绕投票”,恶意验证者会因此受到罚没。)

Violation of Casper FFG Rule 1: height(T1) = height(T2)


Violation of Casper FFG Rule 2: height(S1) < height(S2) < height(T2) < height(T1)

安全性&活性

Casper FFG的目的是保障区块最终确定这一共识执行过程的安全性和活性,下面两点具体阐述了其安全性和活性:

  • 可追责安全性 (Accountable Safety) : 如果两个互相冲突的区块被最终确定,那么至少有1/3的验证者违反了Casper FFG规则,他们则会被标记下来。
  • 合理的活性 (Plausible Liveness) : 不管协议处于哪一阶段,验证者都可以在不违反Casper FFG规则的前提下发起投票,对新区块进行最终确定。

尽管和传统的拜占庭容错共识机制 (BFT)文献相比,本文对FFG安全性和活性的阐释显得有些业余,但是对于大家理解区块链的最终确定机制,却刚好合适。

事实上,如果读者对BFT相关文献非常熟悉,会认为对于“plausible liveness”的解释十分荒谬。然而,由于Casper FFG只是最终确定性机制,因而说到保证系统的活性,其只需要避免以下情况的出现:诚实验证者为了继续提议或证明区块,不得不违反FFG规则。

至于安全性,可靠的安全性尤为重要,如违反规则的验证者会被标记下来,并将标记信息发送到PoS的机制上,从而对恶意验证者进行惩罚。这样做有助于协议的实现,以达到系统的平衡。

安全证明

使得两个相斥的区块A和B最终确定(且互不为对方的子区块),有两种情况:

  • A区块高度=B区块高度
    • 由于A和B在被最终确定之前都需要被证明,至少2/3的验证者需要分别为目标点A和B投票。这就意味着至少有1/3的验证者违背了第一条Casper FFG的规则。
  • A区块高度<B区块高度
    • 区块A要被最终确定,那么至少2/3的验证者都要对区块(A,C)投票,而区块C是A的子区块。
    • B区块要被证明,那么随着区块高度增加,区块应该按[genesis, B_0, B_1, … , B_n, B]排列,其中每个区块都能按顺序证明下一个区块,即至少2/3的验证者要做出类似(G, B_0), (B_0, B_1)的投票。假设B_m是该序列中的首个区块,且A区块高度 < B_m区块高度。
    • 需要注意的是,如果该序列中的任何区块和区块A或C的高度相同,那么形同以上第一种情况,我们已经得到了证明。
    • 在(B_n, B_m)投票中 (n = m-1),B_m能够被证明。但由于B_n或B_m和区块A或C不在同一个区块高度,那我们就能得到区块高度的排列:B_n< A < C < B_m。
    • 因此,有2/3的验证者都违反了Casper FFG的第二条规则。

安全性证明,区块高度A<B
还要注意的是,仅通过检查所有投票集合,找到有冲突的投票并检查相应的验证者签名,我们很容易确定违反Casper FFG规则的验证者。

活性证明

  • 假设P_0是经证明后的最高区块,而Q是某些验证者所认为并且投票的最高区块。
  • 区块P_1作为P_0的子区块且区块高度Q< P_1。此时如果有2/3的验证者对(P_0, P_1)投票使其被证明,并没有违反Casper FFG规则。
  • P_2是P_1的子区块,如果有2/3的验证者对(P_1, P_2)进行投票,并使得P_1被最终确定。这也并没有违反Casper FFG规则。

因此,至少2/3的诚实验证者总是能够对一个新区块进行最终确定,这就保证了共识机制的活性。

转载自:https://www.ethereum.cn/casper-ffg-explainer

以太坊源码解析:state

本篇文章分析的源码地址为:https://github.com/ethereum/go-ethereum
分支:master
commit id: 257bfff316e4efb8952fbeb67c91f86af579cb0a

引言

对于任何一个区块链项目来说,账户信息的保存都是关键。在以太坊中,保存账户信息的代码是由 state 模块实现的。

与比特币区块链不同的是,以太坊没有使用 UTXO(Unspent Transaction Output) 模型,而是使用的账户余额模型。这篇文章,我们就来看看 state 模块是如何实现账户余额模型的。

什么是 state

要介绍 state,就不得不提区块链的账户模型了。在各个区块链项目中,都会有一个账户地址,类似于我们的银行账户;每个账户都会对应着一些信息,比如有多少币等,类似于我们在银行某个账户下的余额。而保存这些账户对应信息的方式,就是账户模型。

目前在区块链的世界里有两种账户模型:UTXO(Unspent Transaction Output) 模型和账户余额模型。UTXO 的中文翻译为「未花费的交易输出」。这种方式不记录账户余额,只记录每笔交易(一次转账就是一笔交易),账户的余额是通过计算账户的所有历史交易得出的(想像一下如果你知道你老婆/老公的银行账户的每笔交易,那么你就可以算出她/他现在卡还有多少钱了)。(这篇文章里我们不详细讨论 UTXO 模型,感兴趣的读者可以自己搜索相关文章,网上的讲解还是挺多的)。

账户余额模型与我们常用到的银行账户相似,都是保存了我们账户的余额。当有人给我们转账时,就将余额的数字加上转账的值;当我们转账给别人时,就将余额的数字减去转账的值。

这么看来,账户余额模型是比较容易理解的。以太坊使用的就是账户余额模型,而实现这一模型的,正是 state 模块。之所以模块名叫 state,我猜也是因为它就像一个状态机:它记录了每个账户的状态,每当有交易发生时,就更改一下相应账户的状态。

state 模块中主要的对象是 StateDB 对象,正是它记录了每个账户的信息,其中包含 balance(以太币的数量)、nonce(交易标号,见「重放攻击」小节),等信息。这从它的方法中就能看出来,比如:

func (self *StateDB) GetBalance(addr common.Address) *big.Int
func (self *StateDB) AddBalance(addr common.Address, amount *big.Int)
func (self *StateDB) SubBalance(addr common.Address, amount *big.Int)
func (self *StateDB) SetBalance(addr common.Address, amount *big.Int)
func (self *StateDB) GetNonce(addr common.Address) uint64
func (self *StateDB) SetNonce(addr common.Address, nonce uint64)
func (self *StateDB) GetCode(addr common.Address) []byte
func (self *StateDB) SetCode(addr common.Address, code []byte)
......

所以,总得来说,以太坊的 state 实现了账户余额模型,保存了以太坊账户的所有信息。每当这些信息发生改变,state 对象就会发生改变,就像一个状态机一样。

实现架构

state 的实现其实比较简单,但由于加了一些缓存功能,乍看上去会觉得比较乱。我画了一张图来示意 state 的主要实现:

从图上可以看出,state 模块对外的主要对象是 StateDB,这个对象提供了各种管理账户信息的方法(可以很容易地在 state/statedb.go 中查看到,这里就不一一列举了)。在对象的内部主要有四个字段,下面我们分别简单的解释一下这四个字段。

stateObjects 是一个 map,用来缓存所有从数据库(也就是 trie 字段)中读取出来的账户信息,无论这些信息是否被修改过都会缓存在这里。因此在需要将所有数据提交到数据库或 trie 字段代表的 trie 对象时,只需将 stateObjects 中保存的信息提交即可(当然需要借助 stateObjectsDirty 字段踢除没被修改过的信息)。

stateObjectsDirty 很显然是用来记录哪些账户信息被修改过了。需要注意的是,这个字段并不时刻与 stateObjects 对应,并且也不会在账户信息被修改时立即修改这个字段。事实上,这个字段是与 journal 字段相关的:在进行某些操作时(StateDB.Finalise 和 StateDB.Commit)时,才会将 journal 字段中记录的被修改的账户整理到 stateObjectsDirty 中。

journal 字段记录了 StateDB 对象的所有操作,以便用来进行回滚操作。需要注意的是,当 stateObjects 中的所有信息被写入到 trie 字段代表的 trie 树中后,journal 字段会被清空,无法再进行回滚了。

db 字段代表的是一个从数据库中访问 trie 对象的对象,比如 OpenTrie。db 是一个接口类型,但在实际的调用代码中,它只有一个实例就是 cachingDB 这个对象。这个对象是对 trie.Database 的一个包装,通过 pastTries 字段缓存了部分曾经访问过的 trie 对象。这样当下次再次访问这个 trie 时,就不需要从数据库中读取了。这个缓存的功能需要 cachedTrie 对象的配合,在将 trie 提交到数据库的同时通知 cachingDB 进行缓存。

trie 字段也是一个接口类型的字段,它代表了保存账户信息的 trie 对象。在实际调用代码中,它也只有一个实例,就是 cachedTrie 这个对象。这个对象是对 trie.SecureTrie 的一个封装,主要修改是改写了 trie.SecureTrie 的 Commit 方法:在新的 Commit 方法中,除了调用 trie.SecureTrie 的 Commit 方法外,还会与 cachingDB 配合,调用 cachingDB.pushTrie 将当前的 trie.SecureTtrie 缓存到 cachingDB 中。

可以看出代表账户余额模型的对象就是 StateDB 对象,它就像一个 KV 数据库,以账户地址作为 Key、以账户信息作为 Value 进行数据的存储和查询。其底层使用 trie 对象来实现这种类似 KV 结构的数据的存储。而在数据库层面,StateDB 又增加了一些缓存机制,使得运行时效率更高(但也使得代码更复杂一点)。

但不得不说,由于底层使用 trie 对象保存所有数据,StateDB 与 KV 数据库不同的是,在任意时刻或提交数据到数据库(Commit)后,可以得到 trie 对象的哈希值。在生成区块时,这个哈希值是保存在区块头的 Root 字段中的。如此一来,就能随时从数据库中读取任意区块的 state 信息了。

功能解析

了解了 state 模块的整体设计结构以后,其实代码是很容易读懂的。因此我不打算面面俱到的介绍 state 模块的所有功能。在这一节里,我选了几个比较重要的功能,进行简单的介绍。

保存账户信息

前面我们说过,StateDB 就像一个 KV 数据库,底层使用 trie 保存数据。那么到底 K 是什么、 V 是什么呢?

其实要了解 KV 分别是什么,从下面的代码就可以看出来:

func (self *StateDB) getStateObject(addr common.Address) (stateObject *stateObject) {
    ......
    enc, err := self.trie.TryGet(addr[:])
    if len(enc) == 0 {
        self.setError(err)
        return nil
    }
    var data Account
    if err := rlp.DecodeBytes(enc, &data); err != nil {
        return nil
    }
    ......
}

很明显,所谓的 Key 就是账户地址,Value 就是一个 Account 对象。它的定义如下:

type Account struct {
    Nonce    uint64
    Balance  *big.Int
    Root     common.Hash // merkle root of the storage trie
    CodeHash []byte
}

因此每个账户中,都包含了四个信息:代表账户操作编号的 Nonce,代表账户余额的 Balance,代表数据存储 trie 哈希的 Root,和代表合约代码哈希的 CodeHash。我们会在介绍以太坊合约时再介绍 Root 和 CodeHash 字段代表的含义;关于 Nonce 的意义,参看本篇文章中的「重放攻击」小节。

可以看到,所谓的余额模型真的非常简单,就是用 Balance 字段记录当前余额就可以了。在矿工生成一个新的区块时,会处理所有的交易和合约。如果涉及到A账户给B账户转账的操作,就从A的账户中减去交易数值,然后给B账户加上同一数值。当所有交易处理完成后,这些交易引起的 StateDB 的变化使其内容的 trie 对象生成了新的 root 哈希;矿工将这个啥希记录到区块的 header 中。这样当别人收到这个区块时,可以重复这一过程,如果得到的 StateDB 的哈希与区块头中记录的哈希一致,则交易验证成功,说明这个区块的交易是正确的。

重放攻击

我们知道,以太坊中的转账是通过一个个交易完成的。现在设想这样一个场景:A 给 B 发起了一笔转账交易,这次交易被所有矿工确认为合法的,因此转账成功;这时候 B 机灵一动,突然想到这笔交易既然之前被认定为合法的,那再次把这笔交易发给矿工,应该还是合法的吧?毕竟交易本身的数据没有变过。所以 B 把之前 A 发起的这笔交易找出来,又重新发到了网络上。这就是重放攻击。如果没有预防措施,那么 B 就可以用这个交易不断地把 A 的钱转给自己。

在以太坊中,防止重放攻击的任务正是由前面提到的 Account 结构中的 Nonce 字段完成的。具体来说,以太坊中每笔交易中都需要记录一个发起账户的 nonce 值:

type Transaction struct {
    data txdata
    ......
}

type txdata struct {
    AccountNonce uint64          `json:"nonce"    gencodec:"required"`
    ......
}

一笔交易就是由 Transaction 结构代表的,而 txdata 结构中的 AccountNonce 就是 Account.Nonce 的值。在构造一笔交易时,发起者需要将 txdata.AccountNonce 字段设置为发起账户的 Account.Nonce 值加 1。

在矿工出块进行验证时,会对 Transaction 中的 AccountNonce 值进行验证,如果这个值确实比发起者账户的 Account.Nonce 值大 1,则为有效的;否则这个交易目前是无效的(如果 txdata.AccountNonce 与 Account.Nonce 的差 > 1,说明这笔交易可能以后会生效,就暂时保留;如果这个差 < 1,则直接丢弃这个交易)。

除了验证 Transaction 与 Account 的 nonce 值,还需要在 Transaction 结构整体验证成功、转账完成后,将发起账户的 Account.Nonce 值加 1。这样才能在使用这笔交易发起重放攻击后,让这种攻击失效。

可以看到,Account.Nonce 主要功能就是用来避免重放攻击。但这需要代表交易的 Transaction 结构和矿工的配合,即 Transaction 中有 AccountNonce 字段记录着此次转账完成后账户的 Account.Nonce 值应该是多少;而矿工需要验证这个值,且在转账完成后修改账户的 Account.Nonce 值。

快照与回滚

在 StateDB 的实现中,还有快照与回滚的功能,这两个功能主要是由下面两个方法提供的:

func (self *StateDB) Snapshot() int {
    id := self.nextRevisionId
    self.nextRevisionId++
    self.validRevisions = append(self.validRevisions, revision{id, self.journal.length()})
    return id
}

func (self *StateDB) RevertToSnapshot(revid int) {
    // 根据快照 id,从 validRevisions 中查找快照信息
    idx := sort.Search(len(self.validRevisions), func(i int) bool {
        return self.validRevisions[i].id >= revid
    })
    if idx == len(self.validRevisions) || self.validRevisions[idx].id != revid {
        panic(fmt.Errorf("revision id %v cannot be reverted", revid))
    }
    snapshot := self.validRevisions[idx].journalIndex

    // 恢复快照
    self.journal.revert(self, snapshot)
    self.validRevisions = self.validRevisions[:idx]
}

StateDB.Snapshot 方法创建一个快照,返回一个 int 值作为快照的 ID。StateDB.RevertToSnapshot 用这个 ID 将 StateDB 的状态恢复到某一个快照状态。

这两个方法的实现都很简单,从中可以看出,StateDB.nextRevisionId 字段用来生成快照的有效 ID,而 StateDB.validRevisions 记录所有有效快照的信息。关键实现其实在 StateDB.journal 字段中,这个字段的类型是 journal 结构。我们详细看一下这个结构的实现。

journal 结构在 state/journal.go 中,它的定义如下:

type journal struct {
    entries []journalEntry         // Current changes tracked by the journal
    dirties map[common.Address]int // Dirty accounts and the number of changes
}

其中 entries 字段的类型是 journalEntry 类型的数组,journalEntry 是一个接口类型,主要方法就是用来恢复数据的 revert 方法,它代表了对某一操作进行回滚的操作,因此实现了 journalEntry 接口的对象有很多个,我把它们罗列在这里:

type createObjectChange struct
type resetObjectChange struct
type suicideChange struct
type balanceChange struct
type nonceChange struct
type storageChange struct
type codeChange struct
type refundChange struct
type addLogChange struct
type addPreimageChange struct
type touchChange struct

可以看到这些代表具体回滚操作的对象,对应了所有对 StateDB 的操作。每当有对 StateDB 的操作时,就会构造一个对应的回滚操作并调用 journal.append 方法将其加入到 journal.entries 中。比如对于增加余额的操作:

func (self *StateDB) AddBalance(addr common.Address, amount *big.Int) {
    stateObject := self.GetOrNewStateObject(addr)
    if stateObject != nil {
        stateObject.AddBalance(amount)
    }
}

func (c *stateObject) AddBalance(amount *big.Int) {
    ......
    c.SetBalance(new(big.Int).Add(c.Balance(), amount))
}

func (self *stateObject) SetBalance(amount *big.Int) {
    // 构造 SetBalance 的回滚操作 balanceChange 并加其记录到 `journal.entries` 中
    self.db.journal.append(balanceChange{
        account: &self.address,
        prev:    new(big.Int).Set(self.data.Balance),
    })
    self.setBalance(amount)
}

journal.append 的实现很简单直接:

func (j *journal) append(entry journalEntry) {
    j.entries = append(j.entries, entry)
    if addr := entry.dirtied(); addr != nil {
        j.dirties[*addr]++
    }
}

这样,journal.entries 中积累了所有操作的回滚操作。当调用 StateDB.RevertToSnapshot 进行回滚操作时,就会调用 journal.revert 方法:

func (j *journal) revert(statedb *StateDB, snapshot int) {
    for i := len(j.entries) - 1; i >= snapshot; i-- {
        // Undo the changes made by the operation
        j.entries[i].revert(statedb)

        // Drop any dirty tracking induced by the change
        if addr := j.entries[i].dirtied(); addr != nil {
            if j.dirties[*addr]--; j.dirties[*addr] == 0 {
                delete(j.dirties, *addr)
            }
        }
    }
    j.entries = j.entries[:snapshot]
}

在 journal.revert 中,会从 journal.entries 中最后一项开始、向前至参数中指定的项,调用它们的 revert 方法。我们以 balanceChange 为例看看这些回滚对象是如何操作的。刚才提到过在修改 balance 的 stateObject.SetBalance 中会构造一个 balanceChange 对象:

func (self *stateObject) SetBalance(amount *big.Int) {
    // 构造 SetBalance 的回滚操作 balanceChange 并加其记录到 `journal.entries` 中
    self.db.journal.append(balanceChange{
        account: &self.address,
        prev:    new(big.Int).Set(self.data.Balance),
    })
    self.setBalance(amount)
}

其中 balanceChange.prev 字段保存了修改之前的 balance 值。那么在 balanceChange.revert 中就将这个值重新恢复到账户信息中就行了:

func (ch balanceChange) revert(s *StateDB) {
    s.getStateObject(*ch.account).setBalance(ch.prev)
}

注意这里调用的是 stateObject.setBalance 而不是 stateObject.SetBalance,后者会再次将修改加入到 journal 中,这并不是我们想要的操作。

现在我们可以总结一下 state 模块是如何实现快照和回滚功能的:

  1. 将所有可能的修改作一个统计。
  2. 实现所有可能操作对应的回滚操作对象。
  3. 在每次进行操作前,将对应的回滚对象加入到回滚操作的数组中,例如 journal.entries。
  4. 要在当前状态下创建一个快照,就记录下当前 journal.entries 的长度(因为 journal.entries 中一个数组)。
  5. 要恢复某个快照(即实现回滚操作),就从 journal.entries 中最后一项开始,向前至指定的快照索引,逐一调用这些对象的 revert 操作。

其实还是挺简单的,我们日常开发中要实现类似的功能,也可以参考这个实现方式。

要注意一点的是快照与回滚只能针对还未提交到数据库中的账户信息,即存在于 stateObject 中的信息。如果已经被提交到数据库中,就无法回滚了。(其实要想实现也是可以的,只是以太坊的代码没有这么实现而已)

总结

在这篇文章里,我们介绍了以太坊的 state 模块。 state 模块实现了以太坊的账户余额模型,它以一种类似 KV 数据库的形式存储账户信息,其中以账户地址作为 Key,以账户信息(Account 结构)作为 Value。它的底层使用 trie 对象对数据进行存储,这样不但可以快速获取某一账户的信息,还可以得到 trie 的 root 哈希保存到区块头中,这样矿工生成区块后,别的节点就可以重现交易对账户的修改,并将最终的保存账户信息的 trie 的 root 哈希与区块头中保存的哈希进行比较,方便对区块进行验证。

以上就是对 state 模块的所有分析。水平有限,如果有错误还请留言或邮件指出,非常感谢。

转载自:https://www.jianshu.com/p/bdc9b669576d