以太坊的区块同步

以太坊的区块同步

以太坊的数据同步是提供区块链基本数据存储的重要环节,正是因为数据在P2P网络的分发,形成了完全分布式的去中心化的支持。
以太坊的区块同步分成两种形式:
一、 主动同步:
Downloader调用syncer主动同步启动有三种情况:
1、启动时。
2、接收到NewBlockMsg :这种情况下经常被fetcher覆盖
3、新的Peer连接。
4、定时
二、 被动同步
fetcher被动同步有两种:
1、 接收到NewBlockMsg
2、 接收到NewBlockHashesMsg

三、 同步过程
挖矿成功后Miner会发布一个NewMinedBlockEvent,订阅事件的协程接收到事件后,向自己连接的Peer发送NewBlockMsg和NewBlockHashesMsg的消息,发送的比例为sqrt(N),N为总的连接结点。P2P节点在收到消息后根据取得消息的不同,来采用不同的动作。当Peer节点的fetcher把新区块数据接收完成后,经过验证处理,发送到blockchain继续处理,在这个过程中,会对所有的交易进行重放,没有问题,会加入本地维护的区块链中。同时,将区块的哈希值再次广播。
四、 处理过程
1、分发消息的中心
handle.go中

// handle is the callback invoked to manage the life cycle of an eth peer. When
// this function terminates, the peer is disconnected.
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
   }
   p.Log().Debug("Ethereum peer connected", "name", p.Name())

   // Execute the Ethereum handshake
   var (
      genesis = pm.blockchain.Genesis()
      head    = pm.blockchain.CurrentHeader()
      hash    = head.Hash()
      number  = head.Number.Uint64()
      td      = pm.blockchain.GetTd(hash, number)
   )
   if err := p.Handshake(pm.networkID, td, hash, genesis.Hash()); err != nil {
      p.Log().Debug("Ethereum handshake failed", "err", err)
      return err
   }
   if rw, ok := p.rw.(*meteredMsgReadWriter); ok {
      rw.Init(p.version)
   }
   // Register the peer locally
   if err := pm.peers.Register(p); err != nil {
      p.Log().Error("Ethereum peer registration failed", "err", err)
      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)

   // If we're DAO hard-fork aware, validate any remote peer with regard to the hard-fork
   if daoBlock := pm.chainconfig.DAOForkBlock; daoBlock != nil {
      // Request the peer's DAO fork header for extra-data validation
      if err := p.RequestHeadersByNumber(daoBlock.Uint64(), 1, 0, false); err != nil {
         return err
      }
      // Start a timer to disconnect if the peer doesn't reply in time
      p.forkDrop = time.AfterFunc(daoChallengeTimeout, func() {
         p.Log().Debug("Timed out DAO fork-check, dropping")
         pm.removePeer(p.id)
      })
      // Make sure it's cleaned up if the peer dies off
      defer func() {
         if p.forkDrop != nil {
            p.forkDrop.Stop()
            p.forkDrop = nil
         }
      }()
   }
   // main loop. handle incoming messages.
   for {
      if err := pm.handleMsg(p); err != nil {
         p.Log().Debug("Ethereum message handling failed", "err", err)
         return err
      }
   }
}

重点在最后的loop循环里,用来处理取得到的消息,调用了handleMsg这个函数,这个函数老大了,紧跟在handle这个函数其后。
handleMsg:

// handleMsg is invoked whenever an inbound message is received from a remote
// peer. The remote connection is torn down upon returning any error.
func (pm *ProtocolManager) handleMsg(p *peer) error {
   // Read the next message from the remote peer, and ensure it's fully consumed
   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()

   // Handle the message depending on its contents
   switch {
   case msg.Code == StatusMsg:
      // Status messages should never arrive after the handshake
      return errResp(ErrExtraStatusMsg, "uncontrolled status message")

   // Block header query, collect the requested headers and reply
   case msg.Code == GetBlockHeadersMsg:
      // Decode the complex header query
      var query getBlockHeadersData
      if err := msg.Decode(&query); err != nil {
         return errResp(ErrDecode, "%v: %v", msg, err)
      }
      hashMode := query.Origin.Hash != (common.Hash{})
      first := true
      maxNonCanonical := uint64(100)

      // Gather headers until the fetch or network limits is reached
      var (
         bytes   common.StorageSize
         headers []*types.Header
         unknown bool
      )
      for !unknown && len(headers) < int(query.Amount) && bytes < softResponseLimit && len(headers) < downloader.MaxHeaderFetch {
         // Retrieve the next header satisfying the query
         var origin *types.Header
         if hashMode {
            if first {
               first = false
               origin = pm.blockchain.GetHeaderByHash(query.Origin.Hash)
               if origin != nil {
                  query.Origin.Number = origin.Number.Uint64()
               }
            } else {
               origin = pm.blockchain.GetHeader(query.Origin.Hash, query.Origin.Number)
            }
         } else {
            origin = pm.blockchain.GetHeaderByNumber(query.Origin.Number)
         }
         if origin == nil {
            break
         }
         headers = append(headers, origin)
         bytes += estHeaderRlpSize

         // Advance to the next header of the query
         switch {
         case hashMode && query.Reverse:
            // Hash based traversal towards the genesis block
            ancestor := query.Skip + 1
            if ancestor == 0 {
               unknown = true
            } else {
               query.Origin.Hash, query.Origin.Number = pm.blockchain.GetAncestor(query.Origin.Hash, query.Origin.Number, ancestor, &maxNonCanonical)
               unknown = (query.Origin.Hash == common.Hash{})
            }
         case hashMode && !query.Reverse:
            // Hash based traversal towards the leaf block
            var (
               current = origin.Number.Uint64()
               next    = current + query.Skip + 1
            )
            if next <= current {
               infos, _ := json.MarshalIndent(p.Peer.Info(), "", "  ")
               p.Log().Warn("GetBlockHeaders skip overflow attack", "current", current, "skip", query.Skip, "next", next, "attacker", infos)
               unknown = true
            } else {
               if header := pm.blockchain.GetHeaderByNumber(next); header != nil {
                  nextHash := header.Hash()
                  expOldHash, _ := pm.blockchain.GetAncestor(nextHash, next, query.Skip+1, &maxNonCanonical)
                  if expOldHash == query.Origin.Hash {
                     query.Origin.Hash, query.Origin.Number = nextHash, next
                  } else {
                     unknown = true
                  }
               } else {
                  unknown = true
               }
            }
         case query.Reverse:
            // Number based traversal towards the genesis block
            if query.Origin.Number >= query.Skip+1 {
               query.Origin.Number -= query.Skip + 1
            } else {
               unknown = true
            }

         case !query.Reverse:
            // Number based traversal towards the leaf block
            query.Origin.Number += query.Skip + 1
         }
      }
      return p.SendBlockHeaders(headers)

   case msg.Code == BlockHeadersMsg:
      // A batch of headers arrived to one of our previous requests
      var headers []*types.Header
      if err := msg.Decode(&headers); err != nil {
         return errResp(ErrDecode, "msg %v: %v", msg, err)
      }
      // If no headers were received, but we're expending a DAO fork check, maybe it's that
      if len(headers) == 0 && p.forkDrop != nil {
         // Possibly an empty reply to the fork header checks, sanity check TDs
         verifyDAO := true

         // If we already have a DAO header, we can check the peer's TD against it. If
         // the peer's ahead of this, it too must have a reply to the DAO check
         if daoHeader := pm.blockchain.GetHeaderByNumber(pm.chainconfig.DAOForkBlock.Uint64()); daoHeader != nil {
            if _, td := p.Head(); td.Cmp(pm.blockchain.GetTd(daoHeader.Hash(), daoHeader.Number.Uint64())) >= 0 {
               verifyDAO = false
            }
         }
         // If we're seemingly on the same chain, disable the drop timer
         if verifyDAO {
            p.Log().Debug("Seems to be on the same side of the DAO fork")
            p.forkDrop.Stop()
            p.forkDrop = nil
            return nil
         }
      }
      // Filter out any explicitly requested headers, deliver the rest to the downloader
      filter := len(headers) == 1
      if filter {
         // If it's a potential DAO fork check, validate against the rules
         if p.forkDrop != nil && pm.chainconfig.DAOForkBlock.Cmp(headers[0].Number) == 0 {
            // Disable the fork drop timer
            p.forkDrop.Stop()
            p.forkDrop = nil

            // Validate the header and either drop the peer or continue
            if err := misc.VerifyDAOHeaderExtraData(pm.chainconfig, headers[0]); err != nil {
               p.Log().Debug("Verified to be on the other side of the DAO fork, dropping")
               return err
            }
            p.Log().Debug("Verified to be on the same side of the DAO fork")
            return nil
         }
         // Irrelevant of the fork checks, send the header to the fetcher just in case
         headers = pm.fetcher.FilterHeaders(p.id, headers, time.Now())
      }
      if len(headers) > 0 || !filter {
         err := pm.downloader.DeliverHeaders(p.id, headers)
         if err != nil {
            log.Debug("Failed to deliver headers", "err", err)
         }
      }

   case msg.Code == GetBlockBodiesMsg:
      // Decode the retrieval message
      msgStream := rlp.NewStream(msg.Payload, uint64(msg.Size))
      if _, err := msgStream.List(); err != nil {
         return err
      }
      // Gather blocks until the fetch or network limits is reached
      var (
         hash   common.Hash
         bytes  int
         bodies []rlp.RawValue
      )
      for bytes < softResponseLimit && len(bodies) < downloader.MaxBlockFetch {
         // Retrieve the hash of the next block
         if err := msgStream.Decode(&hash); err == rlp.EOL {
            break
         } else if err != nil {
            return errResp(ErrDecode, "msg %v: %v", msg, err)
         }
         // Retrieve the requested block body, stopping if enough was found
         if data := pm.blockchain.GetBodyRLP(hash); len(data) != 0 {
            bodies = append(bodies, data)
            bytes += len(data)
         }
      }
      return p.SendBlockBodiesRLP(bodies)

   case msg.Code == BlockBodiesMsg:
      // A batch of block bodies arrived to one of our previous requests
      var request blockBodiesData
      if err := msg.Decode(&request); err != nil {
         return errResp(ErrDecode, "msg %v: %v", msg, err)
      }
      // Deliver them all to the downloader for queuing
      transactions := make([][]*types.Transaction, len(request))
      uncles := make([][]*types.Header, len(request))

      for i, body := range request {
         transactions[i] = body.Transactions
         uncles[i] = body.Uncles
      }
      // Filter out any explicitly requested bodies, deliver the rest to the downloader
      filter := len(transactions) > 0 || len(uncles) > 0
      if filter {
         transactions, uncles = pm.fetcher.FilterBodies(p.id, transactions, uncles, time.Now())
      }
      if len(transactions) > 0 || len(uncles) > 0 || !filter {
         err := pm.downloader.DeliverBodies(p.id, transactions, uncles)
         if err != nil {
            log.Debug("Failed to deliver bodies", "err", err)
         }
      }

   case p.version >= eth63 && msg.Code == GetNodeDataMsg:
      // Decode the retrieval message
      msgStream := rlp.NewStream(msg.Payload, uint64(msg.Size))
      if _, err := msgStream.List(); err != nil {
         return err
      }
      // Gather state data until the fetch or network limits is reached
      var (
         hash  common.Hash
         bytes int
         data  [][]byte
      )
      for bytes < softResponseLimit && len(data) < downloader.MaxStateFetch {
         // Retrieve the hash of the next state entry
         if err := msgStream.Decode(&hash); err == rlp.EOL {
            break
         } else if err != nil {
            return errResp(ErrDecode, "msg %v: %v", msg, err)
         }
         // Retrieve the requested state entry, stopping if enough was found
         if entry, err := pm.blockchain.TrieNode(hash); err == nil {
            data = append(data, entry)
            bytes += len(entry)
         }
      }
      return p.SendNodeData(data)

   case p.version >= eth63 && msg.Code == NodeDataMsg:

   case p.version >= eth63 && msg.Code == GetReceiptsMsg:
      return p.SendReceiptsRLP(receipts)

   case p.version >= eth63 && msg.Code == ReceiptsMsg:
        ……
   case msg.Code == NewBlockHashesMsg:
      var announces newBlockHashesData
      if err := msg.Decode(&announces); err != nil {
         return errResp(ErrDecode, "%v: %v", msg, err)
      }
      // Mark the hashes as present at the remote node
      for _, block := range announces {
         p.MarkBlock(block.Hash)
      }
      // Schedule all the unknown hashes for retrieval
      unknown := make(newBlockHashesData, 0, len(announces))
      for _, block := range announces {
         if !pm.blockchain.HasBlock(block.Hash, block.Number) {
            unknown = append(unknown, block)
         }
      }
      for _, block := range unknown {
         pm.fetcher.Notify(p.id, block.Hash, block.Number, time.Now(), p.RequestOneHeader, p.RequestBodies)
      }

   case msg.Code == NewBlockMsg:
      // Retrieve and decode the propagated block
      var request newBlockData
      if err := msg.Decode(&request); err != nil {
         return errResp(ErrDecode, "%v: %v", msg, err)
      }
      request.Block.ReceivedAt = msg.ReceivedAt
      request.Block.ReceivedFrom = p

      // Mark the peer as owning the block and schedule it for import
      p.MarkBlock(request.Block.Hash())
      pm.fetcher.Enqueue(p.id, request.Block)

      // Assuming the block is importable by the peer, but possibly not yet done so,
      // calculate the head hash and TD that the peer truly must have.
      var (
         trueHead = request.Block.ParentHash()
         trueTD   = new(big.Int).Sub(request.TD, request.Block.Difficulty())
      )
      // Update the peers total difficulty if better than the previous
      if _, td := p.Head(); trueTD.Cmp(td) > 0 {
         p.SetHead(trueHead, trueTD)

         // Schedule a sync if above ours. Note, this will not fire a sync for a gap of
         // a singe block (as the true TD is below the propagated block), however this
         // scenario should easily be covered by the fetcher.
         currentBlock := pm.blockchain.CurrentBlock()
         if trueTD.Cmp(pm.blockchain.GetTd(currentBlock.Hash(), currentBlock.NumberU64())) > 0 {
            go pm.synchronise(p)
         }
      }

   case msg.Code == TxMsg:
      // Transactions arrived, make sure we have a valid and fresh chain to handle them
      if atomic.LoadUint32(&pm.acceptTxs) == 0 {
         break
      }
      // Transactions can be processed, parse all of them and deliver to the pool
      var txs []*types.Transaction
      if err := msg.Decode(&txs); err != nil {
         return errResp(ErrDecode, "msg %v: %v", msg, err)
      }
      for i, tx := range txs {
         // Validate and mark the remote transaction
         if tx == nil {
            return errResp(ErrDecode, "transaction %d is nil", i)
         }
         p.MarkTransaction(tx.Hash())
      }
      pm.txpool.AddRemotes(txs)

   default:
      return errResp(ErrInvalidMsgCode, "%v", msg.Code)
   }
   return nil
}

在case NewBlockMsg中,优先处理fetcher,然后再处理downloader,因此得到的区块数据fetcher经常可以覆盖downloader。
不过需要注意的,由于以太坊中存在叔块,所以区块是进入队列,而不是直接面向处理的,同样,因为网络延时的存在,节点可能会落后,这样就有可能接收到一些超前的区块,举一个例子,一个节点突然因为网络延时高度比正常的低两个区块为100,正常为102,但其后网络速度变好,可能新的103被优先接收,为了安全也需要入队而不能直接进入到链操作中去。
也就是说,在接收区块和链处理之间增加一层缓冲,减少异常的发生是必要的。
而在case NewBlockHashesMsg中,节点需要去拉取新区块的头的信息,不过有些让人容易产生混淆的地方在于,这个消息是fetcher和 downloader共用的,所以,需要用filter来过滤一下,当然,fetcher仍然是比downloader有优先级的。
得到头然后再发送拉取 BODY的消息,从而得到整个区块的数据。

最终都要调用blockchain.go中的InsertCahin这个函数,调用这个函数有三种形式:
1、 本地出块 :updateprocFutureBlocks
2、 fetcher更新:loop->insert
3、 downloader更新:synchronise->importBlockResults
五、 本地矿工挖矿的新区块上链
主要的路径:updateprocFutureBlocks

func (bc *BlockChain) update() {
   futureTimer := time.NewTicker(5 * time.Second)
   defer futureTimer.Stop()
   for {
      select {
      case <-futureTimer.C:
         bc.procFutureBlocks()
      case <-bc.quit:
         return
      }
   }
}

func (bc *BlockChain) procFutureBlocks() {
   blocks := make([]*types.Block, 0, bc.futureBlocks.Len())
   for _, hash := range bc.futureBlocks.Keys() {
      if block, exist := bc.futureBlocks.Peek(hash); exist {
         blocks = append(blocks, block.(*types.Block))
      }
   }
   if len(blocks) > 0 {
      types.BlockBy(types.Number).Sort(blocks)

      // Insert one by one as chain insertion needs contiguous ancestry between blocks
      for i := range blocks {
         bc.InsertChain(blocks[i : i+1])
      }
   }
}

六、	fetcher新区块上链
loop->insert
func NewProtocolManager (config *params.ChainConfig, mode downloader.SyncMode, networkID uint64, mux *event.TypeMux, txpool txPool, engine consensus.Engine, blockchain *core.BlockChain, chaindb ethdb.Database) (*ProtocolManager, error) {
   // Create the protocol manager with the base fields
   manager := &ProtocolManager{
      networkID:   networkID,
      eventMux:    mux,
      txpool:      txpool,
      blockchain:  blockchain,
      chainconfig: config,
      peers:       newPeerSet(),
      newPeerCh:   make(chan *peer),
      noMorePeers: make(chan struct{}),
      txsyncCh:    make(chan *txsync),
      quitSync:    make(chan struct{}),
   }
  ……
   inserter := func(blocks types.Blocks) (int, error) {
      // If fast sync is running, deny importing weird blocks
      if atomic.LoadUint32(&manager.fastSync) == 1 {
         log.Warn("Discarded bad propagated block", "number", blocks[0].Number(), "hash", blocks[0].Hash())
         return 0, nil
      }
      atomic.StoreUint32(&manager.acceptTxs, 1) // Mark initial sync done on any fetcher import
      return manager.blockchain.InsertChain(blocks)
   }
   manager.fetcher = fetcher.New(blockchain.GetBlockByHash, validator, manager.BroadcastBlock, heighter, inserter, manager.removePeer)

   return manager, nil
}
它初始化了insertChain这个函数,会在fetcher.go中的loop中用到:
func (f *Fetcher) loop() {
   // Iterate the block fetching until a quit is requested
   fetchTimer := time.NewTimer(0)
   completeTimer := time.NewTimer(0)

   for {
      // Clean up any expired block fetches
      for hash, announce := range f.fetching {
         if time.Since(announce.time) > fetchTimeout {
            f.forgetHash(hash)
         }
      }
      // Import any queued blocks that could potentially fit
      height := f.chainHeight()
      for !f.queue.Empty() {
         op := f.queue.PopItem().(*inject)
         hash := op.block.Hash()
         if f.queueChangeHook != nil {
            f.queueChangeHook(hash, false)
         }
         // If too high up the chain or phase, continue later
         number := op.block.NumberU64()
         if number > height+1 {
            f.queue.Push(op, -float32(number))
            if f.queueChangeHook != nil {
               f.queueChangeHook(hash, true)
            }
            break
         }
         // Otherwise if fresh and still unknown, try and import
         if number+maxUncleDist < height || f.getBlock(hash) != nil {
            f.forgetBlock(hash)
            continue
         }

         f.insert(op.origin, op.block)
      }
……
}

再看一下insert这个函数:

func (f *Fetcher) insert(peer string, block *types.Block) {
   hash := block.Hash()

   // Run the import on a new thread
   log.Debug("Importing propagated block", "peer", peer, "number", block.Number(), "hash", hash)
   go func() {
      defer func() { f.done <- hash }()

      // If the parent's unknown, abort insertion
      parent := f.getBlock(block.ParentHash())
      if parent == nil {
         log.Debug("Unknown parent of propagated block", "peer", peer, "number", block.Number(), "hash", hash, "parent", block.ParentHash())
         return
      }
      // Quickly validate the header and propagate the block if it passes
      switch err := f.verifyHeader(block.Header()); err {
      case nil:
         // All ok, quickly propagate to our peers
         propBroadcastOutTimer.UpdateSince(block.ReceivedAt)
         go f.broadcastBlock(block, true)

      case consensus.ErrFutureBlock:
         // Weird future block, don't fail, but neither propagate

      default:
         // Something went very wrong, drop the peer
         log.Debug("Propagated block verification failed", "peer", peer, "number", block.Number(), "hash", hash, "err", err)
         f.dropPeer(peer)
         return
      }
      // Run the actual import and log any issues
//这里调用前面初始化的函数
      if _, err := f.insertChain(types.Blocks{block}); err != nil {
         log.Debug("Propagated block import failed", "peer", peer, "number", block.Number(), "hash", hash, "err", err)
         return
      }
      // If import succeeded, broadcast the block
      propAnnounceOutTimer.UpdateSince(block.ReceivedAt)
      go f.broadcastBlock(block, false)

      // Invoke the testing hook if needed
      if f.importedHook != nil {
         f.importedHook(block)
      }
   }()
}

这里分成两部分,前面提到过:
1、 新区块
2、 新区块哈希
七、 downloader区块上链
synchronise->importBlockResults
分为快速和全同步:

func (d *Downloader) synchronise(id string, hash common.Hash, td *big.Int, mode SyncMode) error {
   // Mock out the synchronisation if testing
   if d.synchroniseMock != nil {
      return d.synchroniseMock(id, hash)
   }
   ……
   // Retrieve the origin peer and initiate the downloading process
   p := d.peers.Peer(id)
   if p == nil {
      return errUnknownPeer
   }
   return d.syncWithPeer(p, hash, td)
}

下面这个函数分开快速和全同步:

func (d *Downloader) syncWithPeer(p *peerConnection, hash common.Hash, td *big.Int) (err error) {
   d.mux.Post(StartEvent{})
 ……
   if d.mode == FastSync {
      fetchers = append(fetchers, func() error { return d.processFastSyncContent(latest) })
   } else if d.mode == FullSync {
      fetchers = append(fetchers, d.processFullSyncContent)
   }
   return d.spawnSync(fetchers)
}

它们最终都会调用:

func (d *Downloader) importBlockResults(results []*fetchResult) error {
   // Check for any early termination requests
   if len(results) == 0 {
      return nil
   }
   select {
   case <-d.quitCh:
      return errCancelContentProcessing
   default:
   }
   // Retrieve the a batch of results to import
   first, last := results[0].Header, results[len(results)-1].Header
   log.Debug("Inserting downloaded chain", "items", len(results),
      "firstnum", first.Number, "firsthash", first.Hash(),
      "lastnum", last.Number, "lasthash", last.Hash(),
   )
   blocks := make([]*types.Block, len(results))
   for i, result := range results {
      blocks[i] = types.NewBlockWithHeader(result.Header).WithBody(result.Transactions, result.Uncles)
   }
   if index, err := d.blockchain.InsertChain(blocks); err != nil {
      log.Debug("Downloaded item processing failed", "number", results[index].Header.Number, "hash", results[index].Header.Hash(), "err", err)
      return errInvalidChain
   }
   return nil
}

八、 总结
仔细查看上面的几个同步代码发现,其实他们最终都会调用 blockchain.go中的这个函数:

func (bc *BlockChain) InsertChain(chain types.Blocks) (int, error) {
   n, events, logs, err := bc.insertChain(chain)
   bc.PostChainEvents(events, logs)
   return n, err
}

最终再调用私有的insertChain,经过一系列的操作,就把区块插入到本地链中了。
通过对同步数据的分析,可以清晰的看到以太坊对网络数据分发的整个流程,通过协议控制数据的生产、转移和发送,并最终接收验证后插入到本地链中。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值