accounting-oracle
概述
Lido 协议 oracle 是一个复杂的状态机,主要由三个核心模块组成:HashConsensus、BaseOracle、AccountingOracle 组成。oracle committee 成员将报告上传到 HashConsensus,对达成共识,且在规定时间范围内提交报告,BaseOracle 负责记录和管理当前已达成共识的报告, AccountingOracle负责处理报告,为 Lido 合约提供状态更新的数据参数。
HashConsensus:管理 frame 和 hash 共识
oracle committee member 成员
quorum(最少多少成员支持同一个 hash 才算达成共识)
frame 的时间切分
每个 frame 对应的refSlot和deadline
成员在某个 frame 上提交的 report hash
所以 HashConsensus 合约不处理业务数据本身,也不做 rebase。它只做一件事:为每个 frame 选出一份达成共识的 report hash。
BaseOracle:接收 consensus report,并管理 processing 状态
BaseOracle 合约是一个“异步处理基类“。它负责:
接收 HashConsensus 推送过来的
(hash, refSlot, deadline)
记录当前已共识但尚未处理的 report
在真正开始处理前,允许共识被替换或丢失
在开始处理后,锁定当前refSlot
所以它是共识结果的缓冲层和 processing 状态机。它本身不需要理解 report data 的业务含义。
AccountingOracle:提交完整数据并执行业务
AccountingOracle 在 BaseOracle 之上增加了真正的业务语义。它负责:
接收完整
ReportData
计算keccak256(abi.encode(data))
校验它是否与当前共识 hash 一致
调用Lido.handleOracleReport()
同步 legacy oracle / staking router / withdrawal queue
初始化 extraData 状态
分批处理 extraData item
所以 HashConsensus 解决哪份 hash 被认可,BaseOracle 解决什么时候开始处理,AccountingOracle 解决“怎么处理完整业务数据”。
HashConsensus
-> 管理委员会 member、frame、quorum、report hash 共识
BaseOracle
-> 接收共识后的 hash,记录当前 frame 可处理的 consensus report
-> 负责 processing 状态机(什么时候开始处理、是否已开始)
AccountingOracle
-> 提交完整 ReportData
-> 校验完整数据的 hash 是否等于已共识的 hash
-> 执行主报告(rebase / vault / withdrawal / module exited validators)
-> 初始化并分批处理 extraData
整条链路是先对 hash 达成共识,再提交完整 ReportData,再开始 processing,最后再处理 extraData。这样的好处是,完整报告很大,先共识 hash 更便宜,只有与共识 hash 匹配的完整报告才能被处理,extraData 可以拆成多批提交,降低 gas 压力。
1. 时间模型:report 提交如何划分时间周期
在每次 member 提交报告,在合约中有特定限制的时间窗口slot、epoch、frame。顾名思义,报告提交是分阶段提交的,且每个阶段最终达成共识被处理的报告只有一个。那么,这个以时间窗口为基础的阶段是如何划分?
Oracle 的时间模型建立在 Beacon Chain 的 slot / epoch 基础上,其中包括:
1.1 slot
slot 是最小时间单位
timestamp = genesisTime + slot * secondsPerSlot
1.2 epoch
一个 epoch 由固定数量的 slot 组成
epoch = slot / slotsPerEpoch
例如:
slotsPerEpoch = 32
slot 0 ~ 31 -> epoch 0
slot 32 ~ 63 -> epoch 1
slot 64 ~ 95 -> epoch 2
1.3 frame
frame 是 HashConsensus 合约用来组织 oracle 报告的时间窗口,一个 frame 包含固定数量的 epoch。
例如:
epochsPerFrame = 225
那么每个 frame 就是 225 个 epoch。如果每个 epoch 都有 32 个 slot,那么每个 frame 有 225 * 32 = 7200 个 slot。
1.4 reference slot 简称:refSlot
一个 frame 并不是对 frame 内每个 slot 都分别出报告,而是只围绕一个固定的 refSlot 出一份报告。
refSlot是上一个frame的最后一个slot,作为当前frame的观察基准processing deadline是该frame的结束时间(按时间计算,而非具体 slot)- member 可以在当前
frame的 reporting 窗口内提交 hash(fast lane + slow lane)
prev frame current frame
|..............|.................................|
refSlot deadline
// refSlot = last slot of prev frame
// deadline = end timestamp of current frame
这样设计的意义在于:
- 当前
frame的 report 基于refSlot对应的 Beacon Chain 状态快照,保证与共识层一致 - 当前
frame的处理结果,会在下一个frame的refSlot被观测到,形成闭环 - 不同
frame之间通过refSlot隔离,避免状态观测与处理相互干扰
2. Hash 机制:report hash 如何产生、上报、达成共识
HashConsensus 合约并不知道完整报告长什么样,它只处理 bytes32 reportHash 。对于 AccountingOracle 合约来说,这个 hash 来自 keccak256(abi.encode(ReportData))。也就是说,report member 在链下都应该基于同一份完整 ReportData 计算出同一个 hash。
ReportData 里包括:
consensusVersionrefSlotnumValidatorsclBalanceGweistakingModuleIdsWithNewlyExitedValidatorsnumExitedValidatorsByStakingModulewithdrawalVaultBalanceelRewardsVaultBalancesharesRequestedToBurnwithdrawalFinalizationBatchessimulatedShareRateisBunkerModeextraDataFormatextraDataHashextraDataItemsCount
所以 HashConsensus 的共识本质是,member 对一整份 ReportData 的 ABI 编码 hash 达成一致,因为其中的参数将在 Lido 合约中作为入参修改账本状态。
2.1 member 如何上传 hash
成员会在当前 frame 内,对同一个 refSlot 调用:HashConsensus.submitReport(refSlot, reportHash, consensusVersion)。
HashConsensus 合约会检查:
- 当前是否在允许提交的 frame 窗口内(快速通道/普通通道)
consensusVersion是否等于 processor 当前要求的版本refSlot是否是当前 frame 的 reference slot- 提交者是否是 committee member
如果通过,就把该成员对该 frame 的投票记下来。
2.2 quorum 共识阈值
quorum 是对某个 hash 投票统计的阈值,某个 hash 投票数大于该值则表示对此 hash 达成共识,其规则如下:
quorum必须严格大于成员数量的一半(大于totalMembers/ 2)- 最小
quorum = floor(totalMembers / 2) + 1
比如:
committee = 5 人
quorum = 3
那如果 3 个成员都提交了相同的 reportHash = H1,就说明 H1 达成共识。
member1 -> H1
member2 -> H1
member3 -> H1
member4 -> H2
member5 -> 未提交
此时:H1 票数为 3票,且大于或等于 quorum(3),H1 成为当前 frame 的 consensus report。
2.3 提交共识报告
一旦某个 hash 达到 quorum,HashConsensus 会调用 report processor 合约中的
submitConsensusReport(reportHash, refSlot, deadline)。这个动作不是立刻开始处理业务,而是把已达成共识的 (hash, refSlot, deadline) 提交到 BaseOracle 合约,记录为当前 frame 的待处理共识结果。开篇提到过 BaseOracle 合约只负责接收 consensus report 和管理 processing 状态,它不负责处理报告中的数据。
2.4 共识作废和恢复
quorum 作为是否达成共识的阈值,而这个值管理员可以实时修改,那么每次修改都可能对已提交到 BaseOracle 合约中的共识报到存在影响(作废/恢复),举个例子:
当前
quorum = 3
成员支持情况:
H1 = 3 票
所以 H1 原本已共识。
如果管理员把 quorum 提高到:
quorum = 4
那原来那 3 票就不够了,于是:
H1 不再是共识报告
如果这个 H1 还没开始 processing,HashConsensus 就可以通知 processor:
discardConsensusReport(refSlot)
这意味,之前提交给 BaseOracle 的共识报告 H1 被作废了。
如果后来又有成员补投 H1,或 quorum 又被管理员设置降回到 3,或 member 发生变化后,这个 H1 再次达到 quorum。那么 HashConsensus 会再次调用:
submitConsensusReport(reportHash, refSlot, deadline)
也就是说,在开始 processing 之前,共识是可变的。同一个 frame 里,当前共识 hash 可以被替换、丢失、再恢复。一旦某个 refSlot 进入 processing,即:共识阶段结束,进入执行阶段。当前 frame 的共识 hash 被锁定:
- 当前
frame的共识 hash 被锁定HashConsensus不再接受该refSlot的任何新投票- 无法再触发
discardConsensusReport作废已提供的共识报告- 无法再替换为新的共识 hash
submitReportData只能执行一次
接下来,我们来看一下共识报告如何进入 processing 阶段。
3. Report hash 处理:主报告如何开始 processing
在主报告开始 processing 之前,在 HashConsensus 合约向 BaseOracle 合约上传完达成共识的报告之后,其实还有一个步骤。即:submitReportData ,它是在 BaseOracle 合约中触发的,整个顺序如下:
- members 提交
report hashHashConsensus达成quorumHashConsensus调用BaseOracle.submitConsensusReport()BaseOracle存下_storageConsensusReport- 之后才由管理员
SUBMIT_DATA_ROLE调用submitReportData(fullReport)
所以必须先有共识 hash,才能提交完整的 ReportData。
前面我们提到 HashConsensus 合约会调用 BaseOracle 合约的 submitConsensusReport 接口,将达成共识报告上传到 BaseOracle 中,所以 BaseOracle 合约中会保存一个当前共识报告,包括:hash、refSlot、processingDeadlineTime。
这个状态代表,当前 frame 已经有一份达成共识、可以进入后续处理的主报告候选。直到管理员调用AccountingOracle合约中的 submitReportData(data, version)才允许主报告开始进入 processing 流程。但在开始处理报告前,submitReportData 函数会对管理员上传的报告数据与 BaseOracle 中存储的共识报告进行校验:
- 当前是否确实有一个
consensus report data.refSlot是否等于共识的refSlotconsensusVersion是否匹配keccak256(abi.encode(data))是否等于共识 hash
只有都通过,才允许继续处理。所以ConsensusReport 的作用不是参与业务计算,而是作为完整 ReportData 的准入校验门槛。
一旦通过共识校验,主报告就会进入 processing 阶段:
_startProcessing()
-> 标记当前 refSlot 开始 processing
-> 更新 lastProcessingRefSlot
-> emit ProcessingStarted(refSlot, ...)
此时,当前 frame 的主报告正式锁定,并开始处理。从这之后不能再次对同一个 refSlot 调 submitReportData,HashConsensus 也不能再替换这一帧的共识 hash(前面提到过)。
从而实现:
1 frame -> 1 refSlot -> 1 main report processing
4. ReportData 数据处理
前面我们讲到了 submitReportData 函数中调用 _startProcessing() 接口,主报告进入 processing 阶段。接下来,我们要继续讲 submitReportData 函数中 _handleConsensusReportData 函数是如何处理主报告中的数据,这也是整个 Oracle 机制改写不同合约账本的触发点。
因为涉及到的链路比较长,我们将其拆成 6 步进行介绍:
4.1 检查 extraData 头部是否合法
如果:
extraDataFormat = EMPTY
那么要求:
extraDataHash == 0extraDataItemsCount == 0
表示没有 extraData 需要被处理,extraData数据是用来更新特定 module 下 node operator 数据的。
如果:
extraDataFormat = LIST
那么要求:
extraDataHash != 0extraDataItemsCount > 0
表示有 extraData 需要在后续被处理,及更新 module 下 node operator 中的相关数据。
4.2 同步 CL 汇总数据到 legacy oracle
AccountingOracle 合约会调用 legacy oracle 合约的兼容接口,把:
refSlotclBalanceGwei * 1e9numValidators
同步过去,这一步主要是迁移兼容层逻辑。
4.3 更新 module 级 exited validators
主报告里有两组汇总级字段:
stakingModuleIdsWithNewlyExitedValidatorsnumExitedValidatorsByStakingModule
AccountingOracle 合约会把它们同步到 StakingRouter 合约中,这里是 module 级别的汇总,不是 node operator 级明细。
4.4 通知 WithdrawalQueue 新的 report 到了
调用:
withdrawalQueue.onOracleReport(
isBunkerMode,
prevReportTimestamp,
currentReportTimestamp
)
作用是更新 report 时间边界,同步 bunker mode 状态。
4.5 调 Lido 合约中 handleOracleReport()接口
这是主报告最核心的业务动作。
这里会把:
report timestamptime elapsednumValidatorsclBalancewithdrawalVaultBalanceelRewardsVaultBalancesharesRequestedToBurnwithdrawalFinalizationBatchessimulatedShareRate
交给 Lido,从而推动 Lido 合约中完成:
- rebase
- 提现 finalization
- vault 余额归集
- burn 处理
4.6 初始化 extraData processing state
主报告最后不会直接处理 extraData,而是把:
refSlotdataFormatdataHashitemsCountitemsProcessed = 0lastSortingKey = 0submitted = false
写入 AccountingOracle 合约中 的ExtraDataProcessingState。这表示:extraData 的任务已经被登记,但还没开始处理。
讲到这里可能大家会疑惑:到底什么是 Report Data,extraData又是什么,它们分别负责什么,它们之间有什么关联?
其实我们在前面已经简单介绍了,Report Data 是汇总级的数据:
主报告里处理的是:
- 总 validator 数
- 总 CL balance
- vault 余额
- module 级 exited validators
- withdrawal finalization 决策
- simulatedShareRate
也就是先把协议核心状态更新掉。
既然核心状态更新了,那么还剩下明细级的数据没有更新,所以 extraData 主要负责处理:
node operator 级别的 exited validators 明细状态更新
也就是把:
module A total exited = 50
进一步拆成:
module A:
operator id 10 -> exited validator num 25
operator id 11 -> exited validator num 20
operator id 12 -> exited validator num 5
所以顺序一定是:
先 submitReportData
-> 先 rebase / 先处理主报告
再 submitReportExtraDataList
-> 再补 node operator 级明细
另外大家可能会疑问:为什么不将 Report Data 和 extra Data 都放在主报告中?这样统一上传共识 hash,统一验证 hash 的合法性,并且也能统一处理不是更好吗?
其实,主要是因为 node operator 明细需要更新的数据量非常大,因为在 Lido 生态中 staking module 非常多,每个 module 下有大量的 node operator,每个 node operator 下的 exited validators 计数都要同步。(可以详细查阅《Module 生命周期与状态管理》章节)。所以,如果全部塞进主报告:calldata 会非常大,单笔交易可能超 gas limit。
从而最终将其分离设计为:
主报告:汇总 + extraData 的 hash / count / format
extraData:后续分批异步处理
直到这里,我们介绍完了主报告 ReportData 的处理流程,接下来我们将详细介绍 extraData 如何分批异步处理的,以及通过何种形式做到了高效的 gas saving。
5. extraData 数据处理
5.1 extraData 数据分类
extraData 是更新 node operator 明细级的数据,主要是特定 module 下 node operator 的 exit validator 数量。extraData 的数据有两种模式:EMPTY 和 LIST。
EMPTY 表示:这轮 report 没有 extraData 明细。
要求:
extraDataHash = 0extraDataItemsCount = 0
后续不走 batch 处理,而是直接走:
_submitReportExtraDataEmpty()
作用是:
- 标记
submitted = true - 调
stakingRouter.onValidatorsCountsByNodeOperatorReportingFinished() - 发
ExtraDataSubmitted(refSlot, 0, 0)
也就是说:即使没有 extraData,也要显式把状态机走完。
LIST 表示:这轮 report 有 extraData 明细,后续要分批提交。
要求:
extraDataHash != 0extraDataItemsCount > 0
后续通过:
submitReportExtraDataList(bytes data)
来一批一批处理。
5.2 extraData 数据分批
在处理 extraData 时,将其分为了 batch 和 item。其实看到这两个词,大家第一感觉是分批次处理每组数据。
batch 表示:一次提交的一整批 bytes
submitReportExtraDataList(bytes data) 里的 data 不是一个 item,而是一整个 batch:
| nextHash (32 bytes) | item0 | item1 | item2 | ... |
其中:
- 前 32 字节
nextHash是下一批的 hash - 后面跟着多个 item
item 表示:一个业务单元
每个 item 的头部格式是:
| 3 bytes itemIndex | 2 bytes itemType | itemPayload |
当前版本只支持:
itemType = EXITED_VALIDATORS
STUCK_VALIDATORS 在 Triggerable Withdrawals 更新后已废弃。
其中extraData数据中需要处理的值,存储在 ==itemPayload== 中。payload 格式是:
| 3 bytes | 8 bytes | nodeOpsCount * 8 bytes | nodeOpsCount * 16 bytes |
| moduleId | count | nodeOperatorIds | validatorsCounts |
它的含义是:
某个 module 下,一组 node operators 的 exited validators 计数
例如:
moduleId = 2
nodeOpsCount = 3
nodeOperatorIds = [10, 15, 20]
validatorsCounts = [5, 8, 12]
表示:
module 2:
operator id 10 -> exited = 5
operator id 15 -> exited = 8
operator id 20 -> exited = 12
5.3 extraData 数据处理
extraData 的处理可以理解为,它是一个“按批次提交 + 每批包含多个 item + hash 链保证顺序”的处理流程。
在理解代码之前,我们先看一个完整例子 🌰:
假设当前 report 中:
extraDataItemsCount = 5
表示总共有 5 个 item:
item0, item1, item2, item3, item4
📦 我们分成两批提交:
batch1(前 3 个 item)
batch1:
[ nextHash = H2 ]
[ item0 ]
[ item1 ]
[ item2 ]
其中 H2 = keccak256(batch2)
batch2(后 2 个 item)
batch2:
[ nextHash = 0 ]
[ item3 ]
[ item4 ]
这样两个 batch 的 hash 链关系为:
ReportData.extraDataHash = H1 = keccak256(batch1)
batch1 → H2 → batch2 → 0
整体流程如下:
submitReportData
↓
procState.dataHash = H1
submit batch1
↓
keccak256(batch1) == H1 ✔
↓
procState.dataHash = H2
submit batch2
↓
keccak256(batch2) == H2 ✔
↓
nextHash = 0 → 结束
首先我们来看下 batch 是如何被处理的,然后我们再看 batch 下的 item 是如何被处理的。
batch 的处理逻辑
_submitReportExtraDataList()这个函数是 extraData 分批处理的总调度器,它会先拿 ExtraDataProcessingState,知道当前:
- 期待的
dataHash - 总 item 数
- 已处理 item 数
- 上一个排序位置
检查当前这批 data 的 hash 是否正确,合约先做:
keccak256(data) == procState.dataHash
如果不相等,直接 revert。这保证了现在提交的正是当前应该提交的这一批,而不是别的批次。
需要注意的是这里的 procState.dataHash 不是之前我们讨论的主报告 reportData 达成共识的 hash 值。它是主报告中的 dataHash,或后续链式 hash。
- HashConsensus 共识的是主报告整体 hash ↓
- 主报告里包含 extraDataHash ↓
- submitReportData 成功后 ↓
- extraDataHash 被写入 procState.dataHash ↓
- submitReportExtraDataList 用 keccak256(data) 去匹配它
接着,读取 batch 开头的 nextHash(data 的前 32 字节就是:nextHash)
assembly {
dataHash := calldataload(data.offset)
}
// 拿前 32 字节:nextHash
判断,如果:
nextHash == 0
说明这是最后一批,否则说明后面还有下一批。
item 的处理逻辑
从 offset=32 开始解析 item(因为前 32 字节是 nextHash)。然后调用 _processExtraDataItems(data, iter) 逐个处理 item:item0 → item1 → item2 → ...。
每个 item 中的数据是需要更新哪个 module id下的哪个 node operator id 中的 exitedValidatorsCount 值。
然后调用:
stakingRouter.reportStakingModuleExitedValidatorsCountByNodeOperator()
处理完本批后:
-
如果
nextHash == 0:最后一批- 要求
itemsProcessed == itemsCount - 标记
submitted = true - 调
stakingRouter.onValidatorsCountsByNodeOperatorReportingFinished()
- 要求
-
如果
nextHash != 0:还有下一批- 要求
itemsProcessed < itemsCount - 把
procState.dataHash = nextHash - 等下一批提交
- 要求
所以整个机制本质上是哈希链式分批提交。
最后,我们来看下单个 item 是如何被处理的。具体的逻辑在 _processExtraDataItem()函数中,这个函数负责解析一个 item 的 payload,并真正把数据上报到 StakingRouter合约。
具体流程如下:
第一步:先解析 payload,从当前
dataOffset读取
moduleIdnodeOpsCountnodeOpIds字节切片valuesCounts字节切片然后根据
nodeOpsCount算出这个 item 的总长度,更新dataOffset到下一个 item 开始处。第二步:排序检查
系统要求全局顺序是按下面这个 key 严格递增:
(itemType, moduleId, nodeOperatorId)所以会做两层检查:
- 当前
item的第一个(type, moduleId, nodeOpId)必须大于上一个item的最后一个 key- 当前
item内部的nodeOperatorIds也必须严格递增这样可以保证不重复、不乱序、不跳跃。
第三步:调用 StakingRouter 落地业务
解析和校验都通过后,会调用:
reportStakingModuleExitedValidatorsCountByNodeOperator(moduleId, nodeOpIds, valuesCounts)这一步才是真正把
node operator级exited validators明细同步到StakingRouter。
5.4 extraData packed 高效 gas 机制
看到这里,或许有的人会有疑问,既然最终是调用 reportStakingModuleExitedValidatorsCountByNodeOperator()接口来更新 node operator级的数据,为什么不直接传数组?前面通过打包和解包的方式是不是多余?
实际上不多余。因为这套协议额外解决了:
1. calldata 压缩
uint64 不再按 32 bytes 编码
uint128 也不再按 32 bytes 编码
2. 多 item 合并成一个 batch
一个 batch 可以处理多个 module / 多个 item
3. 多批次链式续传
一批处理不完,可以继续下一批
通过 nextHash 串起来
4. 全局顺序与完整性
不能乱序
不能替换中间某一批
不能跳过某些 item
所以 packed batch/item 解决的是“大规模、可校验、可续传的输入协议。
我们回到前面 5.3 开头讲的例子,来展示整个
extraData被处理的过程假如这轮 report 有 5 个
item:
item0, item1, item2, item3, item4这时发现太大了,拆成两批(
batch 或者叫 chunk)
第二批 batch2
batch2 = | 0x00..00 | item3 | item4 |
hash2 = keccak256(batch2)
因为这是最后一批,所以 nextHash = 0。
第一批 batch1
batch1 = | hash2 | item0 | item1 | item2 |
hash1 = keccak256(batch1)
主报告里记录:
extraDataFormat = LIST
extraDataHash = hash1
extraDataItemsCount = 5
处理时:
submitReportData(...)
-> 只初始化 extraData state
-> itemsProcessed = 0
-> dataHash = hash1
submitReportExtraDataList(batch1)
-> keccak256(batch1) == hash1
-> _processExtraDataItems(...)
-> 遍历 item0, item1, item2
-> 对每个 item 调用:
stakingRouter.reportStakingModuleExitedValidatorsCountByNodeOperator(...)
-> itemsProcessed = 3
-> 下一批要求的 hash = hash2
submitReportExtraDataList(batch2)
-> keccak256(batch2) == hash2
-> _processExtraDataItems(...)
-> 遍历 item3, item4
-> 对每个 item 调用:
stakingRouter.reportStakingModuleExitedValidatorsCountByNodeOperator(...)
-> itemsProcessed = 5
-> nextHash = 0
-> submitted = true
-> stakingRouter.onValidatorsCountsByNodeOperatorReportingFinished()
6. 状态机对外暴露
AccountingOracle合约对外提供了 getProcessingState() 的查询接口,用来回答:
当前 frame:
1. 有没有 consensus hash?
2. 主报告是否已提交?
3. extraData 是否已经开始 / 完成?
4. 已处理了多少 item?
它把三层状态拼在一起:
HashConsensus:当前 frame 是否已有共识 hashBaseOracle:当前 refSlot 是否已经开始 processingAccountingOracle:extraData 处理进度
Summary
1. 时间切分
slot -> epoch -> frame
每个 frame 只有一个 refSlot 和一个 processing deadline
2. HashConsensus
members 在当前 frame 内对同一个 refSlot 的 report hash 投票
某个 hash 达到 quorum -> 成为 consensus report
3. BaseOracle
接收 submitConsensusReport(hash, refSlot, deadline)
记录当前 frame 已共识但尚未处理的报告
4. submitReportData
提交完整 ReportData
重新计算 keccak256(abi.encode(data))
必须等于当前 consensus hash
_startProcessing() 后,当前 refSlot 锁定
5. 主报告处理
_handleConsensusReportData(...)
-> 校验 extraData 头部
-> legacy oracle
-> module 级 exited validators
-> withdrawal queue
-> Lido.handleOracleReport(...)
-> 初始化 extraData processing state
6. extraData
EMPTY -> 直接收尾
LIST -> 分批 submitReportExtraDataList(bytes data)
7. batch / item
batch = | nextHash | item0 | item1 | ... |
item = | index | type | payload |
当前版本主要处理 EXITED_VALIDATORS
8. item payload
moduleId + nodeOperatorIds + validatorsCounts
最终调用 StakingRouter.reportStakingModuleExitedValidatorsCountByNodeOperator(...)