LibraBFT SMR 学习 (1/2)

https://developers.libra.org/docs/assets/papers/libra-consensus-state-machine-replication-in-the-libra-blockchain.pdf
2019-10

1. Introduction

Libra区块链的核心是共识协议LibraBFT - 通过它来排序和落实交易(transaction)。LibraBFT将信任分散到参与共识的一组validator节点。LibraBFT保证诚实的(honest)validators就交易历史达成一致,即使在部分参与者是Byzantine(有错、恶意)时也保证安全。LibraBFT基于已被证明的分布式计算基础,并将扩展到互联网级别。

最初validator节点需要批准才能加入,未来只要拥有Libra币就可以加入。

Key technical approach. LibraBFT按轮次/round推进。每一轮从所有validator节点选出一个leader。Leader propose新block(包含transactions),发给其余validator投票(vote)批准。Leader搜集到多数vote时将结果发给其余validator。Leader不能propose或搜集到多数vote时,由timeout机制强制产生新的轮次,选出新的leader。最终当一个block满足LibraBFT的commit rule时,它被提交(commit),链由此得到延伸。

2. Overview and Definitions

2.1. State Machine Replication

SMR协议定义一个抽象的、分布式的状态机(state machine),在各网络节点之间复制(replicate)。

SMR协议开始于初始execution state。每个节点可以提交commands并观察到一系列commits。Commit -> 执行command -> 再次commit -> …。

假定command的执行是deterministic的,希望达到:

  • (safety) 所有诚实节点看到相同顺序的commit
  • (liveness) 提交comand能产生commit

注意节点以相同顺序但不要求同一时间 看到一组commit。

2.2. Epochs

现实中,参与协议的节点会随时间而变。LibraBFT通过epoch来支持它:

  • 每个epoch始于上个epoch的execution state
  • execution state里包含epoch_id 用来标志当前epoch
  • 当一个增加epoch_id的command commit时,当前epoch结束,新的epoch开始

2.3. Byzantine Fault Tolerance

假定每个epoch里有固定数量的恶意节点称为Byzantine node,其他节点是honest node。总的投票权(voting power)= N,每个节点α的投票权 V(α) ≥ 0。假定一个安全阈值(security threshold)f 满足 N > 3f(例如 f = (N - 1) / 3)。

所有分析均基于以下假定(BFT assumption):

  • (bft-assumption) 任一epoch 所有恶意节点的投票权之和 一定不超过 f

一组投票权之和M 满足 M ≥ N - f 的节点形成一个quorum

Lemma B1: 满足bft-assumption前提下,一个epoch里的任意2个quorum:一定存在一个honest node 同时存在于这2个quorum里

2.4. Cryptographic Assumptions

假定hash函数和数字签名方案安全。

假定没有hash 冲突,即 hash(m1) = hash(m2) 意味着 m1 = m2。

2.5. Networking Assumptions and Honest Crashes

BFT assumption保证了safety,但liveness还需要其他假定。首先假定网络状况时好(synchrony)时坏(asynchrony)。只有足够长时期的synchrony才能保证liveness。

Asynchrony期间,消息(message)可以丢失/任意延时、honest node可以宕机/重启。Synchrony期间假定honest node之间message延时存在一个上限 δ(此时honest node不会宕机)。

为了简化分析,分成2个时期:GST之前(before GST)和GST之后(after GST)(GST = global stabilization time)。第8章会推导GST之后系统产生一个commit所需的最大时间。

更正式地表述:

  • (eventually-synchronous-network) GST之后,honest node之间的所有message传递时间小于某未知值 δ(> 0)
  • (eventually-no-crash) GST之后,honest node会响应,不会宕机

注:目前没有考虑message处理所需时间(目前LibraBFT message不限大小)。

2.6. Leaders, Rounds, Blocks, Votes

LibraBFT是leader-based的:每一轮选出一个leader。Leader负责发起proposal、搜集其他节点的proposal(签名后称为vote)。Leader proposal 形成新的block(通过加密hash)添加到链上。

3. Integration with the Libra Blockchain

3.1. Consensus Protocol

期望LibraBFT用在Libra区块链中:

  • Validator节点参与LibraBFT,以安全地复制区块链的状态(这部分代码称为SMR模块)
  • 从SMR模块的角度,command和execution state都是不透明的。Command被完全委托给执行模块来执行
  • 重要地,epoch内 投票权的计算 也被SMR模块委托给系统的其他部分(通过与管理epoch相同的回调)
  • 每次一个command被执行时,执行模块会得到一个时间值 - 该值在所有执行该command的节点间保证一致
  • SMR模块看到的execution state未必是实际的区块链数据(可能是轻量的例如hash值)。每条commit的command要在每个validator节点本地至少执行一次。未来LibraBFT可能增加在节点间同步本地存储的最近execution state的机制

3.2. Libra Clients

LibraBFT的设计通常不关心 validator节点如何与Libra client交互。但可以观察到:

  • client提交的transaction先由validator节点通过mempool 协议共享。Leader在需要propose时从mempool拉取transaction
  • 为方便client验证链的状态,节点用签名来证明特定execution state正被提交 - 此签名可以LibraBFT协议无关的方式来验证,只要client知道对应的validator keys(4.1节描述commitments如何与共识数据一起创建)
  • 由上,通过与validator的交互,client应该可以获取验证所需的validator keys。未来会提供专门的安全协议来获取

3.3. Security

额外的安全考虑:

  • 参与者需要能限制资源的消耗(因为恶意节点的存在)。第4章会描述数据交换层如何让接收者控制数据量
  • 节点的经济激励须与SMR协议的安全和性能相关
  • Leader可能遭受DoS攻击。Pacemaker(第9章)介绍一种随机选取leader的方式(VRF)。未来需要能更多地选择更安全的节点成为leader

Note on Fairness. 除了safety和liveness,SMR系统也经常讨论fairness。传统定义是honest node提交的每个有效command 最终都要被commit。但这在Libra中通常不那么重要,因为transaction首先进入mempool并按交易费来拍卖。

4. Consensus Data and Networking

2.6节描述了LibraBFT的核心概念,第5章会更具体地描述。这里首先定义数据结构 record - 节点之间交换、以及存储的对象。同时也讨论同步record的通讯框架。

4.1. Records

LibraBFT节点核心状态包括一组record。Record在本地存储,在网络上不停交换。有4种record:

  • blocks,round leader用来propose 待执行的command
  • votes,节点用来选block及其execution state
  • quorum certificates (QCs),所有投票里的一个quorum(for 一个block及其execution state),也可包含client使用的commitment
  • timeout,节点用来表示其当前round已经超时

所有record都由其author 签名。Block是链式的:包含前一个block的QC的hash值(除了epoch首个block 使用一个固定值hinit)。Vote和QC包含block及其execution state的hash。按照epoch的投票权,针对同一execution state投票形成quorum时(2.3节)创建QC。QC包含了所选vote的签名。

Data structures. 使用Rust语法描述record。有以下原始数据类型:

  • State(execution state, e.g., a reference to local storage)
  • Command(e.g., a sequence of transactions)
  • EpochId(int)
  • Round(int)
  • NodeTime(system time of a node)
  • BlockHash、QuorumCertificateHash(hash值)
  • Author(标志一个节点)
  • Signature(数字签名)

Record Store. network records - 刚从网络接收的record;verified records - 按4.2节规则详细校验过的record。

节点在一个epoch存储的所有verified records称为一个record store。如果一个record存在于节点当前epoch的record store里,称为节点认识该record。

epoch的所有record组成一棵树(除了timeout - timeout不链接)。5.7节描述何时从record store清理record。

/// A record read from the network.
enum Record {
    /// Proposed block, containing a command, e.g. a set of Libra transactions.
    Block(Block),
    /// A single vote on a proposed block and its execution state.
    Vote(Vote),
    /// A quorum of votes related to a given block and execution state.
    QuorumCertificate(QuorumCertificate),
    /// A signal that a particular round of an epoch has reached a timeout.
    Timeout(Timeout),
}

struct Block {
    /// User-defined command to execute in the state machine.
    command: Command,
    /// Time proposed for command execution.
    time: NodeTime,
    /// Hash of the quorum certificate of the previous block.
    previous_quorum_certificate_hash: QuorumCertificateHash,
    /// Number used to identify repeated attempts to propose a block.
    round: Round,
    /// Creator of the block.
    author: Author,
    /// Signs the hash of the block, that is, all the fields above.
    signature: Signature,
}

struct Vote {
    /// The current epoch.
    epoch_id: EpochId,
    /// The round of the voted block.
    round: Round,
    /// Hash of the certified block.
    certified_block_hash: BlockHash,
    /// Execution state.
    state: State,
    /// Execution state of the ancestor block (if any) that will match
    /// the commit rule when a QC is formed at this round.
    committed_state: Option<State>,
    /// Creator of the vote.
    author: Author,
    /// Signs the hash of the vote, that is, all the fields above.
    signature: Signature,
}

struct QuorumCertificate {
    /// The current epoch.
    epoch_id: EpochId,
    /// The round of the certified block.
    round: Round,
    /// Hash of the certified block.
    certified_block_hash: BlockHash,
    /// Execution state
    state: State,
    /// Execution state of the ancestor block (if any) that matches
    /// the commit rule thanks to this QC.
    committed_state: Option<State>,
    /// A collections of votes sharing the fields above.
    votes: Vec<(Author, Signature)>,
    /// The leader who proposed the certified block should also sign the QC.
    author: Author,
    /// Signs the hash of the QC, that is, all the fields above.
    signature: Signature,
}

struct Timeout {
    /// The current epoch.
    epoch_id: EpochId,
    /// The round that has timed out.
    round: Round,
    /// Round of the highest block with a quorum certificate.
    highest_certified_block_round: Round,
    /// Creator of the timeout object.
    author: Author,
    /// Signs the hash of the timeout, that is, all the fields above.
    signature: Signature,
}

Commitments for Libra clients. 节点对block B投票时,如果形成B的QC会导致新的commit(见5.3节 commit rule),则必须设置vote.committed_state字段值。例如B的前置B 将要commit,则committed_state要设置 = 链一直到B(含)的execution state。注意此execution state其实已经包含在B的QC,将它再包含在B的QC.committed_state字段里 给client提供了一个Bcommit
certificate
- 简短地证明B commit后的execution state。

4.2. Verification of Network Records

Epoch开始时,各节点就hinit 的初值(类型QuorumCertificateHash)达成一致(例如 = hash(seed || epoch_id),seed是某固定值)。

在插入record store之前,节点必须按顺序校验所有收到的record:

  • 所有签名必须是从当前epoch的节点发出
  • BlockHash值 = 验证过的blocks
  • QuorumCertificateHash值 = 验证过的QC(或hinit初值)
  • 在block和QC链里 round值必须是增加的。epoch round 1 发起的proposal block的round=1
  • QC.author = 前一block的author
  • timeouts、votes、QC的epoch_id 匹配 当前的epoch
  • votes、QC的round 匹配待校验的block
  • timeout必须包含一个不大于目前已校验的highest QC round 的highest certified block round
  • vote、QC的committed_state 须与commit rule(5.3节)一致
  • 未校验通过的network record 要丢弃

按照block和QC的hash的约束,除了timeout,节点校验过的record组成一棵树,树的根=hinit(Lemma S1,6.1节)

4.3. Communication Framework(通讯框架)

用于在节点间交换record,含3种操作:

  1. send,发送record给其他一组节点
  2. broadcast,广播/推送更新给其他所有节点
  3. query-all,向其他所有节点请求拉取更新

在GST之前或发送节点不诚实时,broadcast的数据未必能在延时δ 内到达所有接收节点。所以当很久没有收到新的commit、或当前round在timeout后仍不变时,节点会定期触发query-all操作(5.6,7.10,7.11节)。非leader节点的all-to-all通讯仅限用于broadcast timeout和如上2种query-all,这样希望在乐观情况下(leader诚实、网速足够快),只需要线性数量的point-to-point通讯就能产生一个commit。

4.4. Data Synchronization

考虑到节点可能宕机、或有新增节点,希望数据同步是灵活的,例如发送者发起通讯但接收者可以忽略自己已有的数据。LibraBFT提供称为data synchronization(数据同步)的交换协议。

使用sendbroadcast的数据同步的步骤:

  • 发送者通过datasynchronization service 发布新数据
  • 然后发送notification消息给接收者:point-to-point时的一个接收者、或broadcast时的所有接收者
  • 接收者也可以向发送者发送request消息,然后通过response消息获取数据

query-all只用在上面的第3步:接收者请求其他所有节点。

交换完成时,接收者需要马上校验收到的数据(4.2节),然后将所有校验过及相关数据准备好以供将来交换(相关数据详见7.12节)。

4.5. Runtime Environment

把节点所处的环境(进程、网络、定时器、操作系统等)抽象为runtime environment。LibraBFT节点 视为 local state + 一组handlers。Handler操作local state,返回结果值。 Runtime environment负责调用handler,解析返回的值。

4.6. Data-Synchronization Handlers

当消息收到或被返回时,有3个对应的handler。create_notificationcreate_request方法被节点的main handler(5.6节)用来请求runtime
environment发起4.3节里的一个操作。

trait DataSyncNode {
    /// Sender role: what to send to initiate a data-synchronization exchange with a receiver.
    fn create_notification(&self) -> DataSyncNotification;
    /// Query role: what to send to initiate a query exchange and obtain data from a sender.
    fn create_request(&self) -> DataSyncRequest;
    /// Sender role: handle a request from a receiver.
    fn handle_request(&self, request: DataSyncRequest) -> DataSyncResponse;
    /// Receiver role: accept or refuse a notification.
    fn handle_notification(
        &mut self,
        notification: DataSyncNotification,
        context: &mut SMRContext,
    ) -> Option<DataSyncRequest>;
    /// Receiver role: receive data.
    fn handle_response(&mut self, response: DataSyncResponse, context: &mut SMRContext, clock: NodeTime);
}

Data-synchronization handlers不停地查询和更新节点record store,和第5章的main handler互相独立。

4.7. Mathematical Notations

由于未通过校验的record会被丢弃,从这里开始record一般指已校验的record。用α 代表一个节点。record_store(α) 是α 在某时刻的record store。|| 用来连接bit串。

Record的相关标记:

  • B - block;C - quorum certificate;V - vote;T - timeout;R - block或certificate
  • h,h1,… - QuorumCertificateHash/BlockHash hash值。n,n1,… - round值
  • round(B) - block的round字段。更一般地,foo( R ) - record R的foo字段
  • 如果ℎ = certified_block_hash( C ),记为 ℎ ← C。对于单个vote V,记为 h ← V。如果ℎ = previous_quorum_certificate_hash(B),记为 h ← B
  • 更一般地,用 ← 标记hash|block|vote|quorum certificate之间的关系。写 B ← C 而非 hash(B) ← C,B ← V 而非 hash(B) ← V,C ← B 而非 hash( C ) ← B
  • R0 ←* Rn 代表 R0 ← R1 … ← Rn,n ≥ 0

5. The LibraBFT Protocol

5.1. Overview of the Protocol

节点对当前epoch维护一棵record树,即record_store(α)。树的根节点hinit 在建立epoch时确立。树的每个分支是一条record链:hinit ← B1 ← C1 … ← Bn[← Cn]。

Figure 3: Overview of the LibraBFT protocol
当节点成为leader,它必须propose一个新的block(transaction Bn+1),通常是在树当前最长的分支上延续(图里1)。假定proposal Bn+1 广播成功,诚实节点会校验数据、执行这个新的block、向leader发回一个vote(图里2)。在没有执行bug的情况下,诚实节点应该会同意执行Bn+1 后的execution state。当收到足够多的vote 赞同此execution state时,leader会创建一个quorum certificate Cn+1 并广播它(图里3)。链的长度现在加1:hinit ← B1 ← C1 … ← Bn+1 ← Cn+1。当前leader完成,等待下一个leader继续推进。

由于存在网络延时或恶意节点,诚实节点们不一定总是能就延伸哪条分支和给哪个block投票 达成一致。按照 BFT assumption(2.3节),诚实节点遵守的voting constraint(投票限制)保证了 只要包含block B的某分支延伸得足够长并满足commit rule时,B和它的前置再也不能被冲突的proposal挑战。这些区块于是可以commit,状态机的复制过程得以推进。

为保证即使存在恶意节点或leader失去响应 复制过程也能推进,每个proposal包含一个round数值。轮次/round会超时。到下一轮活跃时,新的leader预期会propose一个block。Pacemaker(7.3节)用来使各诚实节点就一个唯一的、活跃的轮次达成足够长时间的一致。

现在可以重新描述一下LibraBFT协议的目标:

  • (safety) 新的commit总是延伸一条包含以前所有commit的链
  • (liveness) 如果网络synchronous足够长时间,总能产生一个新的commit

5.2. Chains

一条k-chain就是一系列的k blocks和k QCs:

B0 ← C0 ← … ← Bk-1 ← Ck-1

B0称为头/head,Ck-1称为尾/tail。

复习一下,链上的轮次必须增加:round(Bi) < round(Bi+1)。

当轮次刚好只增加1时 – round(Bi) + 1 = round(Bi+1) – 称链的轮次连续(contiguous rounds)。

实践上,链的轮次不一定会连续。例如不诚实的leader propose了一个非法的block、或leader由于网络原因不能按时搜集到足够多vote形成quorum。当一个轮次不能生成QC时,更高轮次的leader会propose 而破坏连续性。

5.3. Commit Rule

当且仅当block B0是一条轮次连续的3-chain的head时,称为B0满足LibraBFT的commit rule。即存在:

B0 ← C0 ← B1 ← C1 ← B2 ← C2
且:
round(B2) = round(B1) + 1 = round(B0) + 2

当发生此情况时,B0及其之前的block 变成已提交(committed)。

按照前面讨论的commitments(4.1节),一个合法的C2 quorum certificate担当commit certificate:它的committed_state字段值必须非空,以证明execution state state(C0) 在当前epoch提交了。注:如果committed_state字段值为空,则C2 不合法 要被丢弃(4.2节)。

5.4. 投票限制1: Increasing Round

Commit rule的safety 依赖于2条投票限制。限制1与轮次相关:

  • (increasing-round) 一个诚实节点vote B之后,只能vote B 如果 round(B) < round(B)

这条限制对于QC来说很重要(第6章)。节点α 用变量latest_voted_round(α) 来记录其最近投票的轮次,只会vote B 如果round(B) > latest_voted_round(α)。

5.5. 投票限制2: Locked Round

定义block B的previous round(上一轮)如下:如果存在B和C 满足 B ← C ← B,那么 previous_round(B) = round(B);否则 previous_round(B) = 0。

进一步,定义block B的second-previous round(上上轮)如下:如果存在 B’’ ← C’’ ← B ← C ← B,那么 second_previous_round(B) =
round(B’’);否则second_previous_round(B) = 0。

节点α 的locked round,写作locked_round(α),是节点α 所有投过票的block B里最高的上上轮 second_previous_round(B),如果不存在则是0。换句话说,epoch开始时locked_round(α) = 0,随后每次vote block B时更新为:max(原值,second_previous_round(B))。

限制2:

  • (locked-round) 一个诚实节点α 只能vote B 如果:previous_round(B) ≥ locked_round(α)

这条限制是从最新的HotStuff改编而来,LibraBFT将其简化为一个单子句的条件。

5.6. Local State of a Consensus Node and Main Handler API

现在按local state和handler(4.5节)来描述协议。

Local state. 见4.2节,节点α 的state 主要包括其record store,写作record_store(α),包含当前epoch所有已验证的record。

节点state 也包括一组与轮次同步(即leader选举)相关的变量。这些变量合并到一个名为pacemaker 的对象里(7.3节)。

其他的state 变量:

  • 当前epoch标志:epoch_id(α),用来侦测当前epoch的结束
  • author标志:local_author(α)
  • 最近vote的block 轮次:latest_voted_round(α) (初值0)
  • locked_round(α) (初值0)
  • 最近query-all操作的系统时间:latest_query_all_time(α) (初值epoch的开始时间)

上面latest_query_all_time(α) 变量与liveness 有关。当获取新的commit和新的轮次没有足够进展时,此变量用来触发query-all操作(4.3节、第7章)。

节点state 还包括一个commit tracker对象,用来追踪main handler已经处理的commit、并判断是否需要执行query-all来恢复错过的commit(7.11节)。

最后,节点state 还包括所有以前epoch的record state。这些epoch已经停止,不能再新增record。

Main handler API. LibraBFT里节点的main handler 仅含一个方法update_node,runtime environment在3种情况下必须调用它:

  • 当节点启动或 宕机后重启时
  • 当数据同步完成时(4.6节)
  • handler的上次执行 约定一个时间再次执行

Main handler响应record store的变化,或返回一组action items 由runtime environment来执行。具体:

  • main handler可能要求在未来某时间再次调用update_node
  • 可能要求发送notification给特定节点们
  • 可能要求广播
  • 可能要求query-all

Main handler的实现是LibraBFT协议的核心(5.7节)。

Rust definitions. Local state:

struct NodeState {
    /// Module dedicated to storing records for the current epoch.
    record_store: RecordStoreState,
    /// Module dedicated to leader election.
    pacemaker: PacemakerState,
    /// Current epoch.
    epoch_id: EpochId,
    /// Identity of this node.
    local_author: Author,
    /// Highest round voted so far.
    latest_voted_round: Round,
    /// Current locked round.
    locked_round: Round,
    /// Time of the latest query-all operation.
    latest_query_all_time: NodeTime,
    /// Track data to which the main handler has already reacted.
    tracker: CommitTracker,
    /// Record stores from previous epochs.
    past_record_stores: HashMap<EpochId, RecordStoreState>,
}

Main handler API:

trait ConsensusNode {
    fn update_node(&mut self, clock: NodeTime, context: &mut SMRContext) -> NodeUpdateActions;
}

’context’ 参数用于一些SMR操作例如执行command。

注:ConsensusNode trait与DataSyncNode(4.6节)并存,后者用于数据同步。

struct NodeUpdateActions {
    /// Time at which to call `update_node` again, at the latest.
    next_scheduled_update: NodeTime,
    /// Whether we need to send a notification to a subset of nodes.
    should_send: Vec<Author>,
    /// Whether we need to send a notification to all other nodes.
    should_broadcast: bool,
    /// Whether we need to request data from all other nodes.
    should_query_all: bool,
}

5.7. Main Handler Implementation

大致看,update_node方法实现:

  • 运行pacemaker,执行要求的操作如创建timeout、propose block
  • 投票,遵照限制1(5.4节)和限制2(5.5节)
  • 如果节点已经propose block,而且刚刚收到对相同state的vote quorum,创建QC,触发广播,并预约立即再次执行update_node
  • 调用process_commits 处理新发现的commit(见下)
  • 运行commit tracker更新state并判断是否需执行query-all
  • 更新最新的query-all操作时间

Main handler使用后续章节描述的、liveness相关的接口:

  • Pacemaker trait的方法update_pacemaker 控制leader选举、timeout、proposal;返回action items由process_pacemaker_actions方法处理。RecordStore的proposed_block方法也使用pacemaker来选择可以vote的active proposal(7.3节)
  • CommitTracker 提供最新已处理的commit、及方法update_tracker用来更新最新epoch和commit 以及返回需要的操作例如query-all(7.11节)

最后,process_commits用于:

  • 传递commits给SMR context
  • 检查commit是否终结当前epoch
  • 需要时开始一个新的epoch

此方法在收到数据时使用节点的record store来计算已提交block的最高轮次highest_committed_round。该值与 commit tracker 相关字段比较来判断是否必须发送新的commit。方法committed_states_after的参数是轮次m和record store。假定record store包含一条链 B1 ← C1 ← … ← Bk ← Ck 满足:round(B1) > m (至少) 并且 Bk是最高已提交block,方法committed_states_after 返回一个序列 (round(B1), state(C1), …, round(Bk), state(Ck))。Record store的highest_commit_certificate 定义为最高已提交block的commit rule的最后一个QC。

… 代码略 …

Epoch changes. 一旦节点发送了一个结束当前epoch的commit QC,它停止发送该epoch的commit、储存当前record store、为新的epoch新建record store。

之前描述的safety规则在节点只参与(如vote)新epoch 且从不返回旧epoch 时足以保证safety。创建新epoch的参数必须由导致它创建的已提交execution state唯一决定。

为保证liveness,节点必须保留以前epoch的record store:在数据同步时,节点必须能跟随任意发送者的commit链且执行command,直到该发送者最新epoch的最新commit rule。技术上这意味着在数据同步期间方法process_commits可能被调用、旧epoch可能被停止、新epoch可能启动(另见3.1、7.12节)。

由于网络原因,从包含epoch change的block 到触发commit rule的block 之间的commit链 可能任意长。为避免持久化将来可能不commit的数据,可能会要求当分支发现epoch change时,leader只能propose空的command。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值