☰
Cosmos SDK x/epochs 模块完全指南:链上定时器、Epoch 钩子与跨模块周期调度
2026/10/12 1:25:10 网站建设 项目流程
  • 区块链

【免费下载链接】cosmos-sdk

Framework for building performant, customizable blockchains with native interoperability

项目地址:https://gitcode.com/gh_mirrors/co/cosmos-sdk
点击查看免费下载

导读

x/epochs是 Cosmos SDK 中负责提供**链上定时器(on-chain timer)**的模块:它允许任意模块声明"希望每经过一个固定时间周期收到一次信号",例如"从某个 UTC 时间起每周执行一次代码"。通过统一的 Epoch 接口(Identifier、开始时间、间隔时长、当前期号),其他模块只需注册钩子(Hooks),即可在定时器滴答(tick)的边界上被可靠地通知。读完本文,你将掌握 Epoch 的滴答机制与状态模型、四个核心 Keeper 函数、两种钩子回调(AfterEpochEnd/BeforeEpochStart)、手动与 depinject 两种接线方式、Panic 隔离语义,以及两条查询命令的实际用法。

模块定位:解决什么问题

在 SDK 中,经常需要"每隔一段时间运行某些代码"。x/epochs的设计目标(见 x/epochs/README.md)是:让其他模块声明自己希望在某个周期边界被信号触发。另一个模块可以指定"从 UTC 时间 x 开始,每周执行一次代码"。epochs为其他模块提供了一层通用的 Epoch 接口,使它们能轻松地在这些事件上被唤起,而无需各自维护一套定时逻辑。

核心概念:Epoch 如何运转

epochs模块定义了以固定时间间隔执行的链上定时器,其他 SDK 模块可以注册逻辑,在定时器滴答时执行。两个滴答之间的时间段称为一个epoch(纪元)。其关键语义如下:

  • 唯一标识:每个定时器都有一个唯一的identifier(如day、week、hourly)。
  • 起止时间:每个 epoch 都有start_time与end_time,其中end time = start time + timer interval。
  • 主网实践:主网上通常只使用一个标识符,时间间隔为一天。
  • 滴答判定:定时器会在第一个区块时间大于定时器结束时间的区块处滴答,并把新的开始时间设置为上一个定时器的结束时间(注意:不是当前区块时间!)。
  • 停机追赶:这意味着如果链宕机了一段时间,恢复后你会看到每个区块触发一次滴答,直到定时器追赶完毕。

源码印证:BeginBlocker 中的滴答逻辑

滴答逻辑实现在 x/epochs/keeper/abci.go 的BeginBlocker中,逐条遍历所有EpochInfo:

// 如果区块时间 < 初始 start_time,直接跳过 if blockTime.Before(epochInfo.StartTime) { return false, nil } // 若计数尚未开始,则标记为需要启动首个 epoch shouldInitialEpochStart := !epochInfo.EpochCountingStarted epochEndTime := epochInfo.CurrentEpochStartTime.Add(epochInfo.Duration) shouldEpochStart := (blockTime.After(epochEndTime)) || shouldInitialEpochStart

也就是说,滴答条件为:区块时间严格晚于当前 epoch 结束时间(blockTime.After(epochEndTime)),或者该定时器尚未启动(shouldInitialEpochStart)。这正是 README 所述"第一个区块时间大于 timer end time 才滴答"的实现。关于边界条件的精确语义,可参考 x/epochs/keeper/abci_test.go 中的TestEpochInfoBeginBlockChanges测试,它用"定时器间隔 + 1 纳秒"(eps)验证了滴答边界,并覆盖了停机追赶(Downtime recovery)场景。

状态模型:EpochInfo

epochs模块为每个标识符保存一条EpochInfo,它描述了对应定时器的当前状态,其字段在每次滴答时被更新。EpochInfo 在创世初始化或升级逻辑中创建,并且只会在 BeginBlocker 中被修改。

EpochInfo 字段详解

依据 proto 定义 proto/cosmos/epochs/v1beta1/genesis.proto,各字段含义如下:

字段类型说明
identifierstring该定时器的唯一引用
start_timeTimestamp定时器首次滴答的时间;若在未来,则 epoch 直到该时间才开始
durationDuration两次滴答之间的间隔;必须非零,且应大于链的预期出块时间
current_epochint64当前 epoch 编号,即定时器已滴答的次数;首次滴答(current_epoch=1)定义为第一个区块时间大于start_time的区块
current_epoch_start_timeTimestamp当前定时器区间的开始时间,区间为(start, start + duration];滴答时置为last_epoch_start_time + duration,同一标识符每块最多滴答一次。注意:该值可能与 epoch 实际开始的墙钟时间相差很大(停机追赶场景下尤其明显)
epoch_counting_startedbool该定时器是否已开始计数
current_epoch_start_heightint64当前 epoch 起始的区块高度(即定时器上次滴答的区块高度)

proto 注释中还给出了一个停机追赶的推演示例:假设current_epoch_start_time = 10、duration = 5,链在t=14下线、t=30恢复,则t=30块启动区间(10, 15]、t=31启动(15, 20]……直到t=36才启动(35, 40],每个块恰好追赶一个区间。

存储与创世状态

在存储层面,Keeper 使用cosmossdk.io/collections维护一张以字符串identifier为键的EpochInfo映射(前缀KeyPrefixEpoch,定义于 x/epochs/types/keys.go)。默认创世状态(见 x/epochs/types/genesis.go)按字母序包含四个定时器:

epochs := []EpochInfo{ NewGenesisEpochInfo("day", time.Hour*24), // alphabetical order NewGenesisEpochInfo("hour", time.Hour), NewGenesisEpochInfo("minute", time.Minute), NewGenesisEpochInfo("week", time.Hour*24*7), }

校验规则(Validate())包括:identifier不能为空、duration不能为 0、CurrentEpoch与CurrentEpochStartHeight必须非负;创世状态还要求所有 epoch 标识符唯一。创世时InitGenesis会逐个调用AddEpochInfo(见 x/epochs/keeper/genesis.go),导出则通过AllEpochInfos回读全部状态。

事件(Events)

epochs模块在 BeginBlocker 中通过EmitTypedEvent发出两类事件(消息体定义于 proto/cosmos/epochs/v1beta1/events.proto):

BeginBlocker 阶段

TypeAttribute KeyAttribute Value
epoch_startepoch_number{epoch_number}
epoch_startstart_time{start_time}

EndBlocker 阶段

TypeAttribute KeyAttribute Value
epoch_endepoch_number{epoch_number}

从源码看,EventEpochEnd在旧 epoch 结束时(钩子执行前)发出,EventEpochStart在新 epoch 启动时发出,且其EpochStartTime取CurrentEpochStartTime.Unix(),均为 TypedEvent,便于索引器与链下服务消费。

Keeper 函数

epochskeeper 提供以下函数来管理 epoch(完整实现见 x/epochs/keeper/epoch.go):

// GetEpochInfo returns epoch info by identifier. func (k *Keeper) GetEpochInfo(ctx sdk.Context, identifier string) (types.EpochInfo, error) // AddEpochInfo adds a new epoch info. Will return an error if the epoch fails validation, // or re-uses an existing identifier. This method also sets the start time if left unset, // and sets the epoch start height. func (k *Keeper) AddEpochInfo(ctx sdk.Context, epoch types.EpochInfo) error // AllEpochInfos iterate through epochs to return all epochs info. func (k *Keeper) AllEpochInfos(ctx sdk.Context) ([]types.EpochInfo, error) // NumBlocksSinceEpochStart returns the number of blocks since the epoch started. // If the epoch started on block N, then calling this during block N (after BeforeEpochStart) // would return 0. Calling it any point in block N+1 (assuming the epoch doesn't increment) // would return 1. func (k *Keeper) NumBlocksSinceEpochStart(ctx sdk.Context, identifier string) (int64, error)

实现要点

  • AddEpochInfo会先调用epoch.Validate(),并检查标识符是否已存在(已存在则返回错误);若StartTime为零值则取当前区块时间;若CurrentEpochStartHeight == 0且StartTime不晚于当前区块时间,则将其设为当前区块高度。
  • NumBlocksSinceEpochStart返回ctx.BlockHeight() - epoch.CurrentEpochStartHeight;若区块时间早于StartTime会返回"尚未开始"的错误。它可用于实现"距 epoch 开始已过多少块"这类治理/激励逻辑。
  • NewKeeper只接受store.KVStoreService与codec.BinaryCodec两个依赖(见 x/epochs/keeper/keeper.go),并通过collections.NewSchemaBuilder构建 schema;SetHooks只允许调用一次,重复调用会 panic。

Hooks:模块如何接收周期信号

x/epochs通过钩子接口向其他模块广播 epoch 边界事件,接口定义于 x/epochs/types/hooks.go:

// the first block whose timestamp is after the duration is counted as the end of the epoch AfterEpochEnd(ctx context.Context, epochIdentifier string, epochNumber int64) error // new epoch is next block of epoch end block BeforeEpochStart(ctx context.Context, epochIdentifier string, epochNumber int64) error
  • AfterEpochEnd:在区块时间超过 duration 后的第一个区块处,表示该 epoch 已结束。
  • BeforeEpochStart:在 epoch 结束块的下一块调用,表示新 epoch 开始。

钩子接收方必须过滤 identifier

其他模块的钩子接收函数需要过滤epochIdentifier,只对特定标识符执行逻辑。过滤所用的标识符可以放在该模块的Params中,以便通过治理修改。标准开发范式如下:

func (k MyModuleKeeper) AfterEpochEnd(ctx context.Context, epochIdentifier string, epochNumber int64) error { params := k.GetParams(ctx) if epochIdentifier == params.DistrEpochIdentifier { // my logic } return nil }

手动接线(Manual Wiring)

在应用app.go中手动接入epochs模块的完整步骤(与 simapp/app.go 的真实写法一致):

  1. 导入相关包:
import ( // ... "github.com/cosmos/cosmos-sdk/x/epochs" epochskeeper "github.com/cosmos/cosmos-sdk/x/epochs/keeper" epochstypes "github.com/cosmos/cosmos-sdk/x/epochs/types" )
  1. 在应用结构体中添加 epochs keeper:
EpochsKeeper *epochskeeper.Keeper
  1. 添加存储键:
keys := storetypes.NewKVStoreKeys( // ... epochstypes.StoreKey, )
  1. 实例化 keeper:
epochsKeeper := epochskeeper.NewKeeper( runtime.NewKVStoreService(keys[epochstypes.StoreKey]), appCodec, ) app.EpochsKeeper = &epochsKeeper
  1. 为 epochs keeper 设置钩子(在 simapp 中此处为待插入的空MultiEpochHooks):
app.EpochsKeeper.SetHooks( epochstypes.NewMultiEpochHooks( // insert epoch hooks receivers here app.SomeOtherModule ), )
  1. 将 epochs 模块加入模块管理器:
app.ModuleManager = module.NewManager( // ... epochs.NewAppModule(appCodec, app.EpochsKeeper), )
  1. 配置SetOrderBeginBlockers与SetOrderInitGenesis(simapp 中 epochs 均位于列表内,见 simapp/app.go 与 simapp/app.go):
app.ModuleManager.SetOrderBeginBlockers( // ... epochstypes.ModuleName, )
app.ModuleManager.SetOrderInitGenesis( // ... epochstypes.ModuleName, )

DI 接线(depinject 自动注入)

第一步:设置 keeper。

导入 keeper 并添加到应用结构体与 depinject 系统:

epochskeeper "github.com/cosmos/cosmos-sdk/x/epochs/keeper"
EpochsKeeper *epochskeeper.Keeper
depinject.Inject( appConfig, &appBuilder, &app.appCodec, &app.legacyAmino, &app.txConfig, &app.interfaceRegistry, // ... other modules &app.EpochsKeeper, // NEW MODULE! )

第二步:设置模块配置。

导入相关包(注意_副作用导入会触发 x/epochs/depinject.go 中的appconfig.RegisterModule):

import ( epochsmodulev1 "cosmossdk.io/api/cosmos/epochs/module/v1" _ "github.com/cosmos/cosmos-sdk/x/epochs" // import for side-effects epochstypes "github.com/cosmos/cosmos-sdk/x/epochs/types" )

在 app config 中为 BeginBlockers 与 InitGenesis 添加条目:

BeginBlockers: []string{ // ... epochstypes.ModuleName, },
InitGenesis: []string{ // ... epochstypes.ModuleName, },

在 ModuleConfig 中为 epochs 添加配置项(模块配置对象cosmos.epochs.module.v1.Module定义于 proto/cosmos/epochs/module/v1/module.proto):

{ Name: epochstypes.ModuleName, Config: appconfig.WrapAny(&epochsmodulev1.Module{}), },

第三步:通过 EpochHooksWrapper 自动注入钩子。

depinject 可以自动把你的钩子添加到 epochsKeeper,前提是模块输出一个epochtypes.EpochHooksWrapper类型的依赖:

type TestInputs struct { depinject.In } type TestOutputs struct { depinject.Out Hooks types.EpochHooksWrapper } func DummyProvider(in TestInputs) TestOutputs { return TestOutputs{ Hooks: types.EpochHooksWrapper{ EpochHooks: testEpochHooks{}, }, } }

其背后的实现是InvokeSetHooks(x/epochs/depinject.go):它收集所有模块提供的EpochHooksWrapper,按模块名字典序排序后组装成MultiEpochHooks并调用keeper.SetHooks。完整可运行示例见 x/epochs/depinject_test.go,其中TestInvokeSetHooks验证了钩子按字典序(moduleA、moduleB)挂载,TestDepinject则验证了 depinject 注入的 keeper 与模块内部 keeper 指向同一实例。

Panic 隔离

如果某个 epoch 钩子发生 panic,它的状态更新会被回滚,但模块会继续执行剩余的钩子。这允许使用更复杂的 epoch 逻辑,而不必担心状态机停机或阻塞后续模块。实现上,BeginBlocker通过ctx.CacheContext()为每个钩子创建缓存上下文,钩子执行成功才writeFn()提交(见 x/epochs/keeper/abci.go 与 x/epochs/keeper/abci.go)。

设计警示:这也意味着——如果你依赖前一个 epoch 钩子的行为,而该钩子被回滚了,你的钩子也可能出问题。所以在设计新 epoch 钩子的安全检查时,务必考虑"前一个钩子没执行会怎样"。

测试覆盖见 x/epochs/types/hooks_test.go 的TestHooksPanicRecovery:它构造一个"报错钩子 + 正常钩子"的组合,验证报错钩子失败后正常钩子仍被调用(expectedCounterValues中正常钩子计数递增)。

Queries:查询模块状态

epochs模块提供以下 gRPC 查询(proto 定义见 proto/cosmos/epochs/v1beta1/query.proto,REST 路径分别为GET /cosmos/epochs/v1beta1/epochs与GET /cosmos/epochs/v1beta1/current_epoch;实现见 x/epochs/keeper/grpc_query.go):

service Query { // EpochInfos provide running epochInfos rpc EpochInfos(QueryEpochsInfoRequest) returns (QueryEpochsInfoResponse) {} // CurrentEpoch provide current epoch of specified identifier rpc CurrentEpoch(QueryCurrentEpochRequest) returns (QueryCurrentEpochResponse) {} }

Epoch Infos:查询所有运行中的 epoch

<appd> query epochs epoch-infos

示例输出(一个真实链上同时维护day与week两个定时器):

epochs: - current_epoch: "183" current_epoch_start_height: "2438409" current_epoch_start_time: "2021-12-18T17:16:09.898160996Z" duration: 86400s epoch_counting_started: true identifier: day start_time: "2021-06-18T17:00:00Z" - current_epoch: "26" current_epoch_start_height: "2424854" current_epoch_start_time: "2021-12-17T17:02:07.229632445Z" duration: 604800s epoch_counting_started: true identifier: week start_time: "2021-06-18T17:00:00Z"

注意:duration: 86400s即一天(24 小时),604800s即一周(7 天)。若请求为空(req == nil),gRPC 层会返回InvalidArgument;该查询内部直接调用AllEpochInfos。

Current Epoch:按标识符查询当前 epoch

<appd> query epochs current-epoch [identifier]

例如查询当前dayepoch:

<appd> query epochs current-epoch day

输出:

current_epoch: "183"

若传入的identifier为空,会返回InvalidArgument错误;若标识符不存在,则返回 "identifier not available"。

在真实应用中的位置

x/epochs已在simapp(Cosmos SDK 的参考应用)中完整集成,可作为手动接线的活范例:keeper 在 simapp/app.go 实例化并挂载钩子,AppModule 在 simapp/app.go 加入模块管理器,随后被配置进 BeginBlockers 与 InitGenesis 顺序。此外,该模块实现了module.AppModuleSimulation接口,模拟环境下会随机生成 1~10 个 epoch、每个时长 1 小时到 1 周(见 x/epochs/simulation/genesis.go),方便在仿真测试中验证依赖 epoch 的其他模块逻辑。

总结

x/epochs以极小的 API 面(一条EpochInfo记录、四个 Keeper 函数、两个钩子回调、两条查询)提供了通用的周期调度能力:

  • 确定性:滴答只依赖区块时间与current_epoch_start_time + duration的数学关系,停机追赶时每块一个滴答,状态始终收敛;
  • 可组合性:其他模块通过AfterEpochEnd/BeforeEpochStart+ 标识符过滤接入,标识符可放入模块 Params 由治理调整;
  • 容错性:钩子 panic 只回滚自身状态,不中断其余钩子,也绝不使链停机;
  • 两种接线方式:手动接线(适合传统app.go结构)与 depinject 自动注入(通过EpochHooksWrapper输出,钩子按模块名字典序排列)。

如需深入源码,建议按以下路径阅读:概念与用法见 x/epochs/README.md,滴答实现见 x/epochs/keeper/abci.go,钩子机制见 x/epochs/types/hooks.go,创世默认值与校验见 x/epochs/types/genesis.go,边界行为测试见 x/epochs/keeper/abci_test.go 与 x/epochs/types/hooks_test.go。

  • 区块链

【免费下载链接】cosmos-sdk

Framework for building performant, customizable blockchains with native interoperability

项目地址:https://gitcode.com/gh_mirrors/co/cosmos-sdk
点击查看免费下载

相关推荐

上一篇:Umi-OCR插件TesseractOCR排版解析方案技术解析
下一篇:Pythonz常见问题解决:安装失败、版本冲突的终极解决方案 🐍

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询