Cosmos SDK - slashing模块剖析

Cosmos SDK - slashing模块剖析

longcpp @ 20190701

基于PoS的共识机制中的投票操作消耗的资源可忽略不计,因此对于系统中的投票节点来说最佳的投票策略是对所有可能的区块进行投票,这样无论最终哪个区块成为主链,投票节点都能获得收益.这也就是基于PoS的共识机制必须要处理的Nothing-at-Stake攻击问题.引入质押(Deposit)和惩罚(Slash)机制后,每一个投票权背后都绑定着一笔资金,如果投票行为偏离共识协议的规定,则扣除抵押资金中的一部分以示惩罚.通过惩罚机制可以敦促投票节点严格遵循共识协议的规定进行合理投票.在PoS机制中,通常也会通过通胀(Inflation)的形式对合理的行为进行奖励,以表彰节点在维护区块链网络方面做出的贡献.

Cosmos SDK中质押操作由Staking模块实现,而惩罚操作由slashing模块执行.slashing模块中处理双签主动作恶与可用性太差被动作恶两种情形:

  1. 双签:在同一个区块高度违反共识规则对两个区块进行投票
  2. 可用性差:在一定的时间窗口内,验证者签署的区块个数低某个阈值

对双签和可用性差两种过错的惩罚都会扣除验证者抵押的代币的一定比例作为惩罚,被扣除的代币会被燃烧掉(Burnded).在罚钱之外相应的验证者还会被关监狱(Jail),关监狱期间验证者参与共识过程的权利被剥夺.值得注意的是,由于双签错误是对共识规则的公然违反,犯下双签错误的验证者,会被从验证者集合中永久移除,相应的验证者地址也永久作废.

对于双签错误关监狱时长为永远(系统支持的最大GMT时间9999年12月31日23时59分59秒);对于可用性差过错Slashing模块的实现中默认关监狱的时间为600秒.服刑期满后,验证者可以发送UnJail消息请求系统将自己从监狱中释放出来.

对两种错误的判定和处理的入口点在slashing模块的BeginBlocker函数中.BeginBlocker函数从输入参数abci.RequestBeginBlock中抽取LastCommitInfoEvidence信息.

type RequestBeginBlock struct {
	Hash                 []byte  
	Header               Header         
	LastCommitInfo       LastCommitInfo
	ByzantineValidators  []Evidence 
}

Tendermint协议的区块中可以包含验证者作恶的证据(Evidence)信息,该信息可以通过ABCI接口abci.RequestBeginBlock传递给上层应用程序,基于该信息上层应用可以惩罚作恶行为。 Evidence结构包含证明的类型,目前仅支持ABCIEvidenceTypeDuplicateVote,也即对双签的举证.Evidence包含的信息有被举报的验证者,区块高度以及构造该证明的时间.用于举证的证据存在时效约束,需要满足evidence.Timestamp >= block.Timestamp - MAX_EVIDENCE_AGE,其中evidence.Timestamp是包含该证据的区块中的时间戳,而block.Timestamp是当前高度区块中的时间戳,MAX_EVIDENCE_AGE则是系统配置的证据的最大有效期,slashing模块默认设定是120秒.如果证据有效,则扣除相应验证者在作恶时的stake的一部分(而不是恶意行为被揭发时候的stake).根据Evidence实施惩罚的逻辑实现在slashing模块的Keeper中定义的函数handleDoubleSign具体处理.

type Evidence struct {
	Type                 string
	Validator            Validator 
	Height               int64    
	Time                 time.Time
	TotalVotingPower     int64  
}

另外需要注意的是,验证者的stake中可能包含别人委派给该验证者的,因为这部分的stake同样给予了验证者作恶的权利,因此通常要被惩罚.注意的是,在作恶时没有undelegate/redelegate的stake,而在被举报之前进行了undelegate或者redelegate的stake依然会被惩罚,因为在作恶的时间节点,这些stake同样给予了节点作恶的权利.而在作恶发生行为之前就已经进行了undelegate/redelegate的stake份额不会受罚,因为这部分在作恶发生的时间节点,并不算作相应验证者的stake,参见下图.这部分的逻辑判断实现在staking模块的slash.go文件中的函数slashUnbondingDelegation以及slashRedelegation.

这里会产生一个有趣的边界问题,假设一个验证者作恶了,而在该恶意行为被举证惩罚之前,有系统中的用户给该验证者delegate一笔新的stake,则在该验证者被惩罚的时候,这笔新的delegation是否应该被惩罚.按照前述的逻辑,由于这笔新的delegation是在作恶之后才发生的,并没有给予验证者作恶的权利,理应不被惩罚.但是当前的slashing实现中,这个新的delegation也会被惩罚.在基于Cosmos SDK进行开发时,我们意识到了这个问题,并认为这是Cosmos SDK实现上一个欠考虑的地方.随后的调研发现,Cosmos SDK团队自身也意识到了这个问题,参见Slashing: differentiating between stake bonded at the time of infraction and since the time of infraction #1440. 相应的应对方案也被提出,但是最后并没有被部署,来自@jaekwon的下述发言可以看出原因,delegators在delegate之前有义务判断自己没有delegate一个犯错的验证者,而为了修改这一边界问题会增大代码实现的复杂度.也因此,在delegate之前,记得对验证者做更多审查,以保证自己资金的安全.

Delegators are responsible for ensuring that validators didn’t commit a prior infraction, but I think that’s minor compared to their existing responsibility of ensuring future security… and leaving as is appears to keep the logic simpler.

由于分布式共识算法的本质属性,在恶意行为发生当时以及被相关证据上链时刻之间会存在时延, 这也是unbonding period存在的主要原因. slashing模块也针对这种时延现象的存在,提出了tombstone以及tombstone cap的概念. 由于双签错误可能是由于初始配置错误等原因而导致的,当前slashing模块为每个验证者实现了tombstone cap功能, 对来自同一个验证者的一系列双签错误,只针对第一个双签错误进行惩罚并立即将该验证者送入坟墓(tombstoned), 也即从验证者集合中剔除该验证者. 虽然只针对第一个错误进行惩罚,但是代价也比较高,所以可以期望验证者也会尽量避免该情况发生.19年6月30日,Cosmos Hub主网上发生了第一起Slash事件,5%的ATOM代币(22189个)代币被系统燃烧掉,约合人民币价值90万元,根据Twitter评论,导致该事件的原因是节点的服务器出现了运行问题,节点的备用服务器和主服务器同时运行并同时提交区块,从而引发双签错误和惩罚. Cosmos SDK目前的实现中只有针对双签这种恶意行为的tombstone,后续如果想扩展支持类似双签这种有时延属性的拜占庭恶意行为,需要考虑是否要tombstone相应的验证者.如果不支持tombstone,则不会有处罚的上限,对于一系列错误依次进行惩罚,而不是只针对第一次错误.

LastCommitInfo结构体中包含对上一个区块的投票信息Votes []VoteInfo,结构体VoteInfo则包含了验证者信息以及该验证者是否签署了上一个区块的信息.根据这一信息以及系统所追踪的历史区块的信息,可以判定一个验证者是否错过了太多区块的签署,进而决定是否要实施可用性过错惩罚,具体实现在slashing模块的Keeper中定义的函数handleValidatorSignature中.

type LastCommitInfo struct {
	Round                int32      
	Votes                []VoteInfo 
}
type VoteInfo struct {
	Validator            Validator 
	SignedLastBlock      bool      
}

针对可用性过错没有设置上限(cap),因为对同一个验证者而言可用性过错不会叠加. 当检测到某个验证者的可用性过错时,会执行惩罚,并且该验证者会被立即投入监狱(Put in Jail). 被投入监狱后,该验证者也就不再拥有区块投票权利,除非发送UnJail消息被从监狱中释放出来之后才可能重新犯下可用性过错.

验证者活动的信息通过ValidatorSigningInfo结构体进行跟踪

  • StartHeight:表示该验证者成为活跃的验证者时的区块高度
  • IndexOffset:表示成为bonded验证者之后总共经历过的区块个数
  • JailedUntil:表示该验证者入狱服刑的截止时间
  • Tombstoned:该布尔变量用来表示该验证者是否被永久移出验证者集合,(该验证者犯了双签错误被惩罚)
  • MissedBlockCounter:用来记录该验证者在一个时间窗口内错过的区块个数,满足关系 MissedBlocksBitArray.Sum() == MissedBlocksCounter.在每次处理新的区块时,会更新该字段,这样可以避免每次都需要扫描整个的比特数组.
// Signing info for a validator
type ValidatorSigningInfo struct {
	StartHeight         int64     // height at which validator was first a candidate OR was unjailed
    IndexOffset         int64     // index offset into signed block bit array
    JailedUntil         time.Time // timestamp validator cannot be unjailed until
    Tombstoned          bool      // whether or not a validator has been tombstoned (killed out of validator set)
    MissedBlocksCounter int64     // missed blocks counter (to avoid scanning the array every time)
}

新验证者第一次bonded之后(第一次成为活跃的验证者)会为该验证者创建一个ValidatorSigningInfo结构体,这部分逻辑实现在slashing模块AfterValidatorBonded.

func (k Keeper) AfterValidatorBonded(ctx sdk.Context, address sdk.ConsAddress, _ sdk.ValAddress) {
	// Update the signing info start height or create a new signing info
	_, found := k.getValidatorSigningInfo(ctx, address)
	if !found {
		signingInfo := ValidatorSigningInfo{
			StartHeight:         ctx.BlockHeight(),
			IndexOffset:         0,
			JailedUntil:         time.Unix(0, 0),
			Tombstoned:          false,
			MissedBlocksCounter: 0,
		}
		k.SetValidatorSigningInfo(ctx, address, signingInfo)
	}
}

ValidatorSigningInfoMissedBlocksBitArray两类信息在store中存储时候的索引为:

  • SigningInfo: 0x01 | ValTendermintAddr -> amino(valSigningInfo)
  • MissedBlocksBitArray: 0x02 | ValTendermintAddr | LittleEndianUint64(signArrayIndex) -> VarInt(didMiss)

第一个映射表允许根据验证者地址快速查找对应的验证者近期的签名信息;第二个映射表允许根据一个大小为SIGNED_BLOCKS_WINDOW的比特数组判定该验证者是否错过了一个区块.第二个映射表的值为0或者1,0表示验证者没有错过相应的区块(也即对区块进行了签名),1表示验证者确实错过了相应的区块(也即没有对区块进行签名).

追踪一个验证者在一个时间窗口内签署的区块时,一个自然的做法是预先分配一个大的比特数组,比特数组中的每一位对应一个区块,并用该比特位的值标记该验证者是否签署了这个区块.然而slashing模块中没有采用这种方法,对于一个新进的bonded的验证者,在SIGNED_BLOCKS_WINDOW时间窗口内,会根据实际的区块签署情况,逐步为该验证者在第二个映射表中添加相应表项,一个区块对应第二个映射表中的一条记录.

函数handleValidatorSignature中涉及到ValidatorSigningInfo中不容易理解的IndexOffsetMissedBlockCounter以及对MissedBlocksBitArray的操作.函数handleValidatorSignature的部分代码摘录如下,为了方便描述,下面采用slashing模块DefaultSignedBlocksWindow的默认值100来指代SIGNED_BLOCKS_WINDOW参数,而每个验证者在100个区块的时间窗口里要签署的比例为DefaultMinSignedPerWindow为0.5,也即在每个时间窗口里至少需要签署95个区块才能避免因为可用性过错被惩罚.或者换个说法,如果在一个时间窗口内,验证者错过的区块个数大于5个,该验证者就要被惩罚.

... // 省略部分代码	
// this is a relative index, so it counts blocks the validator *should* have signed
	// will use the 0-value default signing info if not present, except for start height
	index := signInfo.IndexOffset % k.SignedBlocksWindow(ctx)
	signInfo.IndexOffset++

	// Update signed block bit array & counter
	// This counter just tracks the sum of the bit array
	// That way we avoid needing to read/write the whole array each time
	previous := k.getValidatorMissedBlockBitArray(ctx, consAddr, index)
	missed := !signed
	switch {
	case !previous && missed:
		// Array value has changed from not missed to missed, increment counter
		k.setValidatorMissedBlockBitArray(ctx, consAddr, index, true)
		signInfo.MissedBlocksCounter++
	case previous && !missed:
		// Array value has changed from missed to not missed, decrement counter
		k.setValidatorMissedBlockBitArray(ctx, consAddr, index, false)
		signInfo.MissedBlocksCounter--
	default:
		// Array value at this index has not changed, no need to update counter
	}

	if missed {
		logger.Info(fmt.Sprintf("Absent validator %s (%v) at height %d, %d missed, threshold %d", addr, pubkey, height, signInfo.MissedBlocksCounter, k.MinSignedPerWindow(ctx)))
	}

	minHeight := signInfo.StartHeight + k.SignedBlocksWindow(ctx)
	maxMissed := k.SignedBlocksWindow(ctx) - k.MinSignedPerWindow(ctx)

	// if we are past the minimum height and the validator has missed too many blocks, punish them
	if height > minHeight && signInfo.MissedBlocksCounter > maxMissed {
    ... // 省略部分代码

handleValidatorSignature的上述摘录部分的主要目的是根据正在处理的区块中的投票信息,来判断一个验证者是否在100个区块的时间窗口里错过的区块个数是否大于5个. IndexOffset实际上记录了自从该验证者变成bonded状态之后的经历的区块个数.假设当前区块高度为n+100,则index := signInfo.IndexOffset % k.SignedBlocksWindow(ctx)计算得到的index=n,而在更新MissedBlocksCounter之前可以认为MissedBlocksCounter记录了在区块高度[n+0,n+99]之间验证者错过的区块个数,前述的第二个映射表则通过某种方式存储了错过的区块的具体信息.处理当前区块时,需要考虑在区块高度[n+1,n+100]之间验证者错过的区块个数,可以看到如果用是否错过了区块高度n+100去更新区块高度n+0所对应的信息,则只需要计算一个差值就可以根据[n+0,n+99]的MissedBlocksCounter快速计算出[n+1,n+100]的MissedBlocksCounter

  • 如果没有错过n+0高度的区块,而错过了n+100高度的区块,则MissedBlocksCounter加1
  • 如果错过了n+0高度的区块,而没有错过n+100高度的区块,则MissedBlocksCounter减1
  • 如果同时错过了或者都没有错过n+0和n+100高度的区块,则无需改动MissedBlocksCounter也无需更改底层数据库的值.此时MissedBlocksCounter和前述第二个映射表的具体值不变,但表示的具体含义已经发生了变化.

如果错过的区块数大约5个,则会执行相应的惩罚逻辑. 需要指出的是getValidatorMissedBlockBitArray函数在相应的键不存在的情况下,会返回false值,表示该验证者没有错过相应高度的区块.而一个新的验证者开始时没有错过任何区块,也即前述第二个映射表为空.只有当该验证者开始错过一个区块之后,才会往底层数据库的第二个映射表中添加一条记录,并且插入的记录总数不会超过100.正常情况下,一个正常运行的验证者应当基本不会区块,所以正常情况下可以预期,对应一个验证者的第二个映射表基本为空.

非法行为进行处罚时,需要找到签署区块时的stake distribution的信息,在函数handleDoubleSign中会发现以下代码

    distributionHeight := infractionHeight - sdk.ValidatorUpdateDelay

同一文件中的函数handleValidatorSignature则含有以下代码:

    distributionHeight := height - sdk.ValidatorUpdateDelay - 1

其中infractionHeight是非法行为发生的区块高度,从中减去ValidatorUpdateDelay可得到distributionHeight的值在handleValidatorSignature中额外减1是因为height字段对应的是LastCommit信息所处的区块高度,而Tendermint共识协议中,一个区块的投票信息是包含在下一个区块中的.这里晦涩的地方在于ValidatorUpdateDelay字段.

	// Delay, in blocks, between when validator updates are returned to Tendermint and when they are applied.
	// For example, if this is 0, the validator set at the end of a block will sign the next block, or
	// if this is 1, the validator set at the end of a block will sign the block after the next.
	// Constant as this should not change without a hard fork.
	// TODO: Link to some Tendermint docs, this is very unobvious.
	ValidatorUpdateDelay int64 = 1

在文件cosmos-sdk/types/staking.go中给出了ValidatorUpdateDelay的定义并在注释中说明了该字段的含义。Tendermint Core项目支持验证者集合的更新,但是更新后的验证者集合并不一定立即在下个区块的验证中发挥作用(签署区块),
而是有一定的时延,ValidatorUpdateDelay字段以区块数为基本单位定义了该时延。该字段值为0意味着无需等待,新的验证者集合用来签署下一个区块。
该字段值为1意味着:假如验证者集合在区块高度H完成更新,则等待一个区块之后,新的验证者集合用来签署高度为H+2的区块。注意代码注释中同样强调,对字段ValidatorUpdateDelay的改动应当是一次协议的硬分叉升级.据此可以在计算distributionHeight时都要减掉ValidatorUpdateDelay的原因了.

本文由 CoinEx Chain团队 longcpp 写作,转载无需授权

2 Likes