Keeper
一个链下工作程序,每 24 小时一次,触发每个国库的转换,随后触发其空投。它不做任何决定。它默认每五分钟执行一轮,自 2026-10-06 起,它在每个空投时间窗口内只转换一次金库的 ETH,在时间窗口关闭后的第一轮进行:参见下文的“转换节奏”。它的空投步骤自 2026-10-05 起已经编写:参见下文。
它做什么
- 每 24 小时一次,在时间窗口关闭之后(默认在 13:00 UTC,全年都在美股开盘之前),在关闭后的第一轮找出持有至少达到转换门槛(默认为 0.1 ETH)的金库;届时低于门槛的金库会等待下一个时间窗口,即使当天稍后的交易使其越过门槛。循环中的买入在紧接着的交易时段内进行,而它的发送默认在该交易时段结束之后进行。金库仍持有的、来自更早循环的现金(USDC 或 USDG)会继续推进,无论是否达到门槛:自第六个审计循环(2026-10-06)起,金库的 USDC 在其每次 ETH 转换之后推进一次,其他情况下每个时间窗口最多一次,镜像金库的 USDG 则每一轮推进(参见“转换节奏”)
- 提议兑换路径,并根据最新报价计算最低数额;自 2026-10-05 起,金库会确保实际到达的数量不低于这些最低数额。自 2026-10-06 起,以太坊上的每个金库都在自己的路由器上报价,即创建它时所用的那个路由器
- 确定每一步的规模,使交易场所能在金库的界限之内成交(见下文)
- 调用转换、跨链批次,然后调用远端链上的买入;自 2026-10-05 起,只有它能发送跨链批次(
bridgeReady)。在官方跨链桥通道上,它按最新基础费用的两倍询问批次的费用(quoteBridgeAt),多付的部分会退还给它。自第九个审计循环起,只有当旧版枢纽本身回答它没有这种报价时,它才会退而采用旧版枢纽的报价,绝不在节点没有响应时这样做:没有响应时,批次会推迟一轮,在官方跨链桥通道上(其费用随基础费用变化)会发出警报,在 USDG 通道上则只发出一条警告。自第十个审计循环(2026-10-06)起,USDG 通道上的批次最多携带适配器的市场数上限(默认 17 个),Keeper 在每个批次时读取这一上限:等待最久的市场优先,其余市场把它们的 USDC 留在各自的金库中,等待它的下一轮,因此没有市场会永远等待(参见Robinhood 通道)。在此之前,每个就绪的市场都进入同一个批次,而一个大批次可能耗尽其固定的 compose gas - 在每个新市场的第一个批次之前,在 Robinhood Chain 上预先部署该市场的镜像金库(
predeploy),自 2026-10-05 起,只有它和 StockFun 的所有者能做到这一点。远端枢纽从批次中得知 Keeper,因此在第一个批次之前,由 StockFun 的所有者预先部署第一个市场的金库。只有当 Keeper 在 Robinhood Chain 上的密钥正是远端枢纽认作 Keeper 的那一个,或是远端枢纽管理员的密钥时,Keeper 才会进行预先部署。一个 Keeper 无法为其预先部署金库的市场(在第一个批次之前、在一次远端枢纽尚未得知的 Keeper 更换之后,或者在预先部署失败时)会被留在批次之外,并发出警报,其他市场照常跨链。一个 Keeper 当天无法读取其镜像金库的市场会等待下一次循环,因为一个被盲目发送的市场可能让整个批次失败 - 监控送达情况:尚未到达的转账、需要重放的票据、尚未部署的镜像金库。一笔在发出时它未能在 Robinhood Chain 上计量的转账,只由远端枢纽自身的事件确认,从不依据之后读取的余额;自 2026-10-06 起,在官方跨链桥通道上,每一笔转账都是如此:远端枢纽用汇集的现金支付其记录,因此余额可能因另一个批次的资金而增长。它每次读取这些事件的少数几个区块段,从每笔转账的查找停下的位置继续;自第七个审计循环(2026-10-06)起,只读取到链的最新区块之下几个区块处,因此最新区块中的事件会在之后的循环中读取,而不会因为一个落后一两个区块的节点而被漏掉。自第八个审计循环起,它只在包含跨链批次的区块已有这么多区块的深度(
KEEPER_LOG_LAG_BLOCKS)之后才跟踪该批次,并依据届时重新读取的回执:一个批次在 Robinhood Chain 上的标识符来自这个区块,而以太坊最新区块的一次挪动了该批次的重组,过去会让 Keeper 跟踪永远不会出现的标识符。自第十个审计循环起,在 USDG 通道上,它针对未按时计入的转账的警报会说明批次消息在 Robinhood Chain 端点上所处的状态:尚未送达那里;已 compose,计入随后进行;或已存储,其 USDG 停留在远端枢纽上、没有任何记录,直到有人以更多 gas 手动再次运行最后一步,警报会给出已存储消息的哈希。在此之前,它只给出远端枢纽记录的内容,而当一个 gas 不足的最后一步把现金留在枢纽上时,其中的零会被读作“什么也没有到达” - 立即把它看到的每一笔紧急转移转发到它的警报 webhook,涵盖每个金库、跨链桥枢纽、空投合约、远端枢纽和每个镜像金库。自第十个审计循环起,它把所监控的合约分组列出,每组一个日志请求,每个请求最多列出
KEEPER_LOG_MAX_SELECTORS个地址和事件值(默认 1,000),并且只有在一段区块的每一组都已读取之后才越过这段区块。在此之前,一个请求会列出全部合约:一旦市场注册表超过端点所接受的数量(Robinhood Chain 的节点为 2,000 个地址和事件值,Sepolia 的公共端点为九个地址),每个请求都会被拒绝,再也不会有任何紧急转移被转发 - 自第十个审计循环起,通过 Multicall3 读取每一轮所需的每个市场的数据(金库的 ETH、USDC 和时间窗口、欠款、最近一次空投),每次调用一百个市场,所用区块与每次单独读取时相同;没有返回的读取会像以前一样单独进行,绝不会被当作零。在此之前,每个曾经创建的市场在每一轮都要逐个读取,无论是否闲置,2,000 个闲置市场会让一轮持续的时间与两轮之间的间隔一样长
- 在官方跨链桥通道上,自第六个审计循环起,跟踪每个批次的两张票据,直到知道每一张都已被执行,无论其转账是否已被计入,每个循环都如此(无论是否处于交易时段),并重放仍然有效的票据。一张在其批次发出六小时后(默认值,
KEEPER_TICKET_ALERT_HOURS)仍不知道已被执行的票据会被警报一次;一张在其截止时间或之后消失且未见执行的票据,或创建失败的票据,也同样会被警报一次。在此之前,一旦一个批次的市场被计入,Keeper 就会停止跟踪该批次的票据,这可能让一笔错过自动执行的存款过期。自第七个审计循环起,它按照 Arbitrum SDK 的顺序读取每张票据:先读取其创建回执,然后是其自动执行,最后是它在一个不早于其创建的区块上是否仍然存在;自第八个审计循环起,这个区块位于最新区块之下几个区块处(KEEPER_REMOTE_LOG_LAG_BLOCKS),如果其创建区块更晚,则为创建区块,这是一个端点的每个节点都拥有的区块。一笔在两次读取之间创建的存款,或被相差一个区块的两个节点看到的存款,绝不会在仍然有效时被当作已执行 - 在一个金库反复失败之后发出警报,并把它在以太坊上的步骤和在 Robinhood Chain 上的步骤分开计数,因此一个每天都在其中一侧失败的金库会被报告出来。自第七个审计循环起,它等待其警报 webhook 最多十秒,并读取其响应:webhook 未接收的警报会保存在它的状态文件中(最多一百条),并在之后的循环中重新发送,因此它会迟到,但不会丢失。消息带有 Slack 和 Discord 各自读取的字段。自第八个审计循环起,每一条不同的警报只保留一条记录:在等待期间再次被触发的警报(例如每个循环都失败的金库)会被计数,而不会保存两次。消息会说明这条警报第一次和最后一次被触发的时间以及次数,而迟到送达的警报开头会说明这一点。自第九个审计循环起,保存的警报按最后一次被触发的顺序发送:再次被触发的警报会排到其他警报之后,因此关于一个对象最后说出的内容就是它的最新状态(一个在 webhook 不可用期间失败、恢复、再次失败的监控,最后送达的是“失败中”,而过去它最终停在“已恢复”上)。超过一百条时,最先被丢弃的只会是在其原因持续期间每个循环都会被触发的警报(例如一个转换失败的金库、失败的跨链批次),因此一次性的警报(例如一次紧急转移)以及每个异常期的最新消息绝不会被挤出去。自第十个审计循环起,错误的文本(在其日志、警报和状态文件中)只携带 RPC 地址的协议部分和主机,绝不携带其余部分,而服务商会把密钥放在其余部分中
- 自第九个审计循环起,在它自己的一笔交易被打包之后的一分钟内,它会在该交易所在的区块上、而不是在最新区块上,读取该交易所改变的内容,并在那里估算它接下来要发送的交易的 gas。测试网运行揭示了这样做的原因:在同一轮的转换之后立即发送的跨链批次,在一个负载均衡端点中比该转换落后一个区块的节点上进行模拟,没有看到任何可跨链的东西,并触发了一次虚假的警报。落后于该区块的节点现在会返回一个错误,批次会等待一轮,并发出一条警告。由于以太坊的 Glamsterdam 升级让其交易内部的调用变得更重,Keeper 的每一笔交易还会以其 gas 估算值乘以 1.25 发送
- 自第九个审计循环起,从链上读取的一个任何日期都无法容纳的时间(喂价的开始时间、时间窗口的结束时间、票据的截止时间)会读作“an unknown time”,而不是让遇到它的那次读取失败
- 自第七个审计循环起,在第一个循环之前检查每个 RPC 服务的是否是其配置所指定的链,否则拒绝启动。之后才发现的错误的链会让该链上的工作停止,并发出警报;链标识符无法读取的链会等待,另一条链上的工作照常进行。自第八个审计循环起,两个链标识符都必须设置(见下文),而为另一条链写入的状态文件会保持不动,直到 Keeper 读取到它的 RPC 服务于哪条链(参见“重启”)
- 自第八个审计循环起,读取 Robinhood Chain 上镜像金库预言机的两项价格喂价防护:当预言机因排序器而暂缓给出所有价格时,远端的买入会等待,并发出一次警报,而处于公司行动中的股票会单独等待(参见“当预言机暂缓给出价格时”)
- 在两种通道上,通过 sweep 支付在远端枢纽上等待的现金:代币已经到达的记录,以及枢纽暂停期间到达那里的资金。在 USDG 通道上,当远端枢纽在等待 StockFun 的所有者对一次紧急转移进行结算时,它会暂缓执行 sweep,并对这一情况只报告一次,在账目结清后再恢复。自 2026-10-05 起,一旦某个镜像金库重新接收现金,它还会 sweep 该金库曾拒收的份额;在官方跨链桥通道上,只有当这次 sweep 会支付一些东西时,它才会发送
- 触发
BuybackBurner,并把记在 Hook 上的回购余额计算在内,销毁合约会在买入之前拉取这笔余额 - 每个时间窗口一次(默认在美股交易时段结束之后),把每个市场的股票发送到空投合约,但从不设定分配:参见下文
- 自 2026-10-05 起,每个循环(无论处于哪个交易时段)都会支付 Hook 和流动性锁定合约为拒收的接收方所保留的款项:Hook 欠某个市场金库的款项(
payTreasury),以及锁定合约为某个金库或创建者保留的手续费收取份额(payOwed)。每一笔支付都先经过模拟,只有在确实会支付一些东西时才发送。这两个调用对任何人开放 - 自 2026-10-05 起,收取以 LP 手续费创建的池子的 LP 手续费(
collectFees,对任何人开放),每个池子每天最多一次。StockFun 的池子默认不收取 LP 手续费,因此默认情况下没有可收取的东西
转换节奏
创始人在 2026-09-27 决定,金库的 ETH 每个每日循环转换一次,就在空投之前,并且只有在该次检查时金库达到门槛才转换。Keeper 自 2026-10-06 起遵循这一点;在此之前,只要金库达到门槛,它就会在交易时段的每一轮转换金库的 ETH。
- 时间窗口。循环就是空投的时间窗口:它在空投合约指定的时刻关闭(默认是在 13:00 UTC 关闭的 24 小时),
$STOCKFUN市场也包括在内;当工厂合约尚未指定空投合约时,按照这一默认时间表 - 每个时间窗口检查一次。Keeper 仍然默认每五分钟执行一轮,每一轮都以一份循环报告结束。门槛在时间窗口关闭后、到达该金库的第一轮中检查。达到或超过门槛时,ETH 会转换,仅此一次:金库自身对其最近一次 ETH 转换的记录从链上读取,告知之后的各轮,因此重启不会改变任何东西。低于门槛时,ETH 会等待下一个时间窗口,即使当天稍后的交易使其越过门槛;Keeper 把这次检查保存在它的状态文件中。自 2026-10-06 起,它把这次检查保存为该检查所针对的时间窗口的结束时间,而不是它自己时钟上的时间——一个在关闭时刻前后略有偏差的时钟可能使后者偏离链上的时间窗口;由较旧版本的 Keeper 写入的检查不算数,因此在升级所在的时间窗口内,这样的金库会被重新检查。自第六个审计循环起,检查读取的是时间窗口关闭之后的金库余额:过去,一轮跨越关闭时刻运行时,会记录它在关闭之前读取的余额,而一个及时越过门槛的金库会因此跳过那个时间窗口
- 检查时无法进行的转换(ETH/USD 喂价没有更新、交易场所无法在金库的界限之内成交、交易失败、Keeper 无法读取时间窗口)会在同一时间窗口的之后几轮中重试。Keeper 无法读取的时间窗口算作 ETH 步骤的一次失败,在反复失败之后发出警报,而金库的 USDC 仍会推进
- USDC,自第六个审计循环起。金库中已有的 USDC 会按自己的节奏继续推进,无论是否达到门槛:它在以太坊上的买入,或它被释放进跨链批次,在金库每次 ETH 转换之后进行一次,其他情况下(赠送、退款、一个失败或被减半的步骤留下的部分)每个时间窗口最多一次。只有成功完成的步骤才算数;失败的步骤会在之后几轮中重试。2026-09-27 创始人的决定就此扩展到 USDC:在此之前,每一轮之前发送到金库的几个单位的 USDC,会让 Keeper 在每一轮都发送一笔买入,或一个带有其费用的完整跨链批次
- 每一轮都会进行的:Robinhood Chain 上的买入。镜像金库的余额无法区分送达和赠送,而每次买入的上限有意把一笔大额送达分摊到多轮中。一次被减半的转换留下的 ETH(交易场所无法在界限之内成交全部余额)会等待下一个时间窗口
- 本地和测试网运行把
KEEPER_CONVERT_ONCE_PER_WINDOW设为 false,于是每一轮都会转换、买入和跨链;在其他所有地方,它保持开启,这是它的默认值
在本页其他地方,“每个循环”指 Keeper 的每一轮,与循环报告中的含义相同;只有空投的每日循环才是时间窗口。
失败各自独立
自 2026-10-05 起,合约允许一段买入、一只股票、一个市场或一次送达失败,而不会让其他部分停止,并通过事件报告失败的内容。Keeper 读取这些事件,并对持续存在的失败发出警报;它自己的工作也以同样的方式拆分。
- 一段买入。每只股票的那一段都单独规划:无法规划的段会被跳过,其他段照常进行。金库报告为失败的段(
LegFailed)会让其现金继续为该股票预留;Keeper 从不把它算作已转换,并在同一金库中该股票连续失败三次之后发出警报(KEEPER_ALERT_AFTER_FAILURES)。当一个金库的任何一段都无法规划时,该金库整体失败,与之前一样 - 跨链批次中的一个市场。被跨链桥枢纽排除在批次之外的市场(
MarketSkipped)会把它的 USDC 留在金库中,等待之后的批次。连续三个批次被排除在外时(KEEPER_BRIDGE_ALERT_AFTER_SKIPS),它会被警报一次,并附上解码后的原因及其修复方法。自第十个审计循环起,被适配器的上限排除在批次之外的市场会等待下一轮(日志中会注明),并排在那个批次的最前面;被适配器拒绝的批次(超过其上限,或适配器升级后没有设置其批次设置)会被警报,并注明原因和该批次自己的市场 - 一次被拒收的送达。现金代币拒绝转给某个镜像金库的份额(
DeliveryRefused)会被警报一次,并附上修复方法,直到远端枢纽不再欠该市场这笔份额的任何部分 - 一个无法读取的金库。自身的视图函数不响应的金库(例如在一次有问题的升级之后)会被排除在该循环之外;其他金库照常转换。连续三个循环之后,它会被警报一次(
KEEPER_ALERT_AFTER_FAILURES) - 一笔持续存在的欠款。只要某个金库(或者,就创建者的份额而言,Hook)仍然拒收它应得的 ETH,Keeper 就会在每个异常期内警报一次,说明该池在等待 StockFun 的所有者(一次修复性升级),并在欠款被支付之后(无论由 Keeper 还是由其他任何人支付)记录该异常期的结束
只有 StockFun 的所有者才能解决的问题属于“等待管理员”:每个异常期一次警报,从不计为失败,其余部分在此期间照常进行。
循环报告新增了以下内容:已支付和仍欠的欠款、失败的段、已收取 LP 手续费的池子,以及空投步骤方面已开启的循环、已运行的搁置股票查找、已发送的金库和仍在途中的送达。自 2026-10-06 起,发往没有合格持仓的时间窗口、被空投合约搁置的发送会单独计数(airdropHeldAside),不再计入已发送的金库;在官方跨链桥通道上,它会统计已发送的票据重放(redeemed)和仍在跟踪的票据(ticketsWatched)。自第七个审计循环起,它还会统计空投发送正在等待其价值抵得上成本的市场(airdropBelowCost)和为 webhook 保存的警报(alertsUndelivered),并在某条链的标识符尚未检查时注明(deferred: chain-unverified)。
当预言机暂缓给出价格时
自 2026-10-06 起,镜像金库的预言机可以暂缓给出价格(参见Robinhood 通道),而自第八个审计循环起,Keeper 在给出任何报价之前都会先读取它:
- 排序器。每个循环它都会询问一次预言机,其排序器检查的结果是什么。当排序器宕机、恢复不超过宽限期,或者其在线状态喂价无法读取时,任何股票都无法定价:金库的买入会等待,不给出任何报价,也不会有任何东西计为失败。一次警报开启这个异常期,并保存在状态文件中,因此重启不会再次触发它,另一次警报说明买入何时恢复。在 Chainlink 为 Robinhood Chain 发布在线状态喂价之前,这项检查在该链上处于关闭状态,因此如今这一切都不会发生
- 公司行动。其代币暂停了预言机的股票会被预先排除在买入之外,并在日志中注明,其 USDG 为它保留;它绝不计入失败警报,篮子中的其他股票照常买入。在 Keeper 的规划与其买入之间,金库因这一原因报告为失败的段属于预期之内,同样不计入
- 说明原因。当一段的界限因为一项防护暂缓给出其价格而无法读取时,Keeper 会在日志中记录这一原因,在两条链上都是如此,而不是记录一个无法为该段定价的金库。它无法读取的防护(来自引入防护之前的预言机)不会暂缓任何东西:由每一段自己的界限决定,与之前一样。自第九个审计循环起,预言机的其他错误也会被解码:过期的喂价在日志中和失败段的原因中读作
StalePrice,而过去读作一个未解码的代码
空投步骤
合约一侧自 2026-10-04 起已写进代码,Keeper 的这一步骤自 2026-10-05 起已写进代码。每个循环中,在检查交易时段之前,Keeper 首先跟踪已经发出的送达。然后,只要工厂合约指定了空投合约,它就依次处理每个市场,包括 $STOCKFUN 市场:
- 每个时间窗口一次。当一个金库最近一次发送的时间(
lastAirdropAt)等于或晚于此刻的一次发送将落入的那个时间窗口的结束时间(currentCycleEnd)时,该金库在这个时间窗口内的发送即告完成。两者都从链上读取,因此重启不会改变任何东西。默认情况下,Keeper 只在当天的美股交易时段结束之后才发送,这样当天的买入会在一次发送中进入当天结束的那个时间窗口,而且只在金库持有股票时才发送;自第七个审计循环起,只发送价值抵得上发送成本的股票(见下文)。自第十个审计循环起,在这一默认设置下,一个在交易时段结束后被读取、没有任何东西可发送的金库,只会在下一个交易时段开始或时间窗口变化时被再次读取,这适用于买入遵循纽约交易时段的通道(Ondo 的通道除外):它持有的东西来自它的买入,而买入是在交易时段内进行的。但当它自己的一笔买入仍在等待回执时(回执可能在晚间到达),情况并非如此:这样的金库在每一轮都会被读取,而这笔买入带来的部分进入当天结束的那个时间窗口 - 先处理搁置的股票,即使没有任何东西可发送。如果该市场持有搁置的股票,就调用
assignUnassigned,每个循环最多调用五次,直到这些股票被归入(这会开启接收它们的时间窗口的循环),或者再没有已结束的时间窗口需要检查。自 2026-10-06 起,无论金库是否有东西可发送,也无论金库是否暂停,Keeper 都会执行这一查找:在此之前,它只在发送之前执行,因此一个交易过一次之后就沉寂的市场,会让它被搁置的第一次空投一直处于持有者够不到的状态,直到出现新的交易,或有人手动执行这一查找。尚未完成的查找会把向没有合格持仓的时间窗口的发送推迟到下一个循环。自第六个审计循环起,当金库没有任何东西可发送时,一次不会归入任何东西的查找(还没有时间窗口能接收这些股票)只有在该市场的查找落后最新时间窗口maxWindowsPerAssign个时间窗口(默认为 30)时才会发送:一个月一次查找,而不是永远每天一次。能够归入这些股票的查找总会发送。自第七个审计循环起,“没有任何东西可发送”指的是按与发送相同的规则,没有任何值得发送的东西:在跨链桥通道上,OFT 无法携带、每次发送都会留在镜像金库中的粉尘不算数,因此这一限制在那里也成立 - 循环,只在发送之前。
openCycle,使每一次送达都只是向该循环追加;绝不为不会发送任何东西的时间窗口开启,而自第七个审计循环起,只为价值抵得上成本(包括开启成本)的发送开启。没有合格持仓的时间窗口不算错误:这时股票会被搁置 - 发送。在本地通道上,于该市场的
TreasuryVault上调用sendToAirdrop。在跨链桥通道上,于镜像金库上调用sendToAirdrop,支付其报价的 LayerZero 费用(quoteSendToAirdrop)外加一笔余量,默认为 10 %;金库会退还多付的部分。自第九个审计循环起,这次发送及其每一次报价都带有其送达在以太坊上获得的 gas,由 Keeper 选择(见下文)。在这次发送之前,Keeper 会检查:远端枢纽发送的目标是工厂合约所指定的空投合约,每只股票都有适配器且该适配器有响应,并且该股票在以太坊上的 OFT 已在空投合约上注册。自第七个审计循环起,这次发送只列出价值抵得上其成本的股票,并为它们重新报价费用 - 送达。在跨链桥通道上,每只股票的送达都会被跟踪,直到 LayerZero 在以太坊上的端点运行完最后一步,即把股票计入空投合约。自第九个审计循环起,卡在该端点上的送达由 Keeper 自己再次执行(见下文)。一次在 60 分钟(默认值)内未被计入的送达会触发一次警报,警报附带手动运行其各个步骤的命令;任何人都可以运行它们
每只股票都单独进行:余额无法读取的股票(发行方冻结)、没有适配器或其 OFT 未注册的股票、适配器没有响应的股票(peers():其地址上没有合约,或不是 LayerZero 应用;自 2026-10-06 起,在此之前它会让该市场的整个发送失败)、自第八个审计循环起其适配器无法为其 LayerZero 费用报价的股票(见下文),以及金库报告无法发送的股票(AirdropSendFailed)会被排除在外,并发出一次警报,其他股票照常发送。每个市场也单独进行:持续失败的市场会在连续失败三次之后被警报一次,下一个市场照常进行。自 2026-10-06 起,持续失败的搁置股票查找有自己的警报,并给出修复方法:任何人都可以为该市场调用 assignUnassigned。
在这里,“等待管理员”包括:已暂停的空投合约、空投合约在一次紧急转移之后短缺的股票(该股票在金库中等待,其他股票照常发送),发送到另一个空投合约、而非工厂合约所指定空投合约的远端枢纽,以及自第九个审计循环起,其送达 gas 范围尚未设置、或其上限低于一次送达所需的远端枢纽。
什么值得发送
自第七个审计循环(2026-10-06)起,Keeper 只发送价值抵得上发送成本的东西。在此之前,任何高于零的股票都会发出:向一个无人交易的市场的金库赠送一笔略高于粉尘的股票,就会让 Keeper 每天开启该时间窗口的循环,并支付一条 LayerZero 消息或一次以太坊发送。
- 一次发送携带什么。余额可以读取且大于零的股票;在跨链桥通道上,它还必须有适配器,以及一个大于零的自身报价。对于 OFT 无法携带的粉尘和其适配器无法为其发送报价的股票,金库的报价同样都是零,因此自第八个审计循环起,Keeper 会就金库将要构建的那次发送直接询问该股票的适配器:粉尘绝不会被发送,也绝不会触发警报;报价失败、因而自身发送也会失败的股票会被排除在外,并发出一次说明原因的警报;Keeper 无法询问的股票照常发出,由它的发送来决定。在此之前,这样的股票会被当作粉尘,悄无声息地从每一次发送中被丢弃
- 它的价值。每只股票都按其金库自己的预言机、以其价格喂价的最新报价估值,无论是否过期,因为这只是一个估算,绝不是界限;以 ETH 支付的成本则用同一个预言机的 ETH/USD 喂价换算成美元
- 它自己的成本。每只股票的价值必须至少抵得上它自己的成本:在跨链桥通道上,是它的 LayerZero 费用,因为每只股票都是一条单独的消息;在本地通道上,是它在这次发送的 gas 中所占的份额。一笔抵不上自身消息成本的赠送,绝不会搭上一次真正的发送
- 整次发送。通过检验的股票合计必须抵得上整次发送的成本,在时间窗口的循环尚未开启时,还要加上开启它(
openCycle)的成本。在本地通道上,自第八个审计循环起,开启成本只计算一次:发送自身的估算是在循环关闭时得出的,已经包含了它,因此只加上开启交易的基础成本;过去计算两次时,一次价值略高于其成本的发送会被推迟好几天。否则任何股票都不会发出,循环也不会开启 - 等待的部分。没有发出的部分留在金库中,连同之后累积的部分在之后的时间窗口中发出。发送正在等待的市场会计入循环报告,并在每个时间窗口记录一次日志
- 无法读取的部分。Keeper 无法读取的价格、费用或 gas 成本会让发送照常进行,与这一规则出现之前一样:一次失败的读取绝不会阻止一次真正的发送
KEEPER_AIRDROP_MIN_VALUE_BPS 设定倍数:默认为 10,000,即成本的一倍,因此一次价值至少抵得上其成本的发送就会发出,无论它超出成本多少;更高的数值会推迟较小的发送,直到它们抵得上这个倍数,而 0 会发送所携带的一切。同一规则也决定上文的搁置股票查找中金库是否有东西可发送。审计把它作为其推荐的默认方案采纳;它只决定一只股票何时发出,从不决定数额、对象或去向。
送达在以太坊上的 gas
2026-10-06,Sepolia 启用了以太坊的 Glamsterdam 升级,这让一个新的存储槽的 gas 成本约为原来的五倍,而 LayerZero 测试网运行第一个循环中的每一次空投送达都在 LayerZero 的执行器处耗尽了 gas:一次发送只为送达的最后一步支付了 gas,而其第一步的 gas 就是该股票 LayerZero 适配器的所有者(在主网上即发行方)强制设定的数值。自第九个审计循环起,根据创始人同一天的决定:
- Keeper 在发送之前在以太坊上模拟每一次送达:接收它的股票代币合约,以及将其计入的空投合约,都按送达时将会遇到的状态进行模拟。它请求最重那只股票所需的 gas 乘以 1.25,再减去该股票的 LayerZero 适配器已经为第一步强制设定的 gas
- 当模拟无法运行时,它采用该市场最近几次送达在以太坊上所使用的 gas,然后是远端枢纽的默认值
- 远端枢纽为它设定范围。StockFun 的所有者为两个步骤中的每一个设定一个默认值、一个下限和一个上限(参见Robinhood 通道);Keeper 请求的任何数值都会被保持在它们之间。被上限削减到低于一次送达所需数值的请求属于“等待管理员”:发送仍会进行,而卡住的送达会被再次执行(下一节)
- 对持有者而言,没有任何变化。Keeper 只决定给一次送达多少 gas,从不决定发送多少、发往哪里或发给谁
- 只在需要时重新选择。根据每只股票的模拟选出的一对数值,在股票、时间窗口循环的状态以及送达是否可能在下一个时间窗口结束之后到达都保持不变时,会在该时间窗口内保留;模拟无法给出的一对数值会在每一轮重新选择。自第十个审计循环起,只携带跨链桥无法携带的粉尘的发送不会读取送达环境的任何内容,而在时间窗口的循环已开启之后模拟出的一对数值(这一状态绝不会倒退)会被重复使用,无需再次读取该状态
卡在以太坊上的送达
gas 不足的送达不会损失任何东西:它留在 LayerZero 在以太坊上的端点上,要么第一步已验证但未运行,要么最后一步已存储但未运行,直到有人用更多 gas 再次执行它。自第九个审计循环起,Keeper 会自己这样做:
- 它在每个循环都读取每一次送达在端点上的步骤。一旦执行器的回合结束(它的失败,且只相信来自 LayerZero 自己执行器的失败,或者十分钟),它就用自己在以太坊上的密钥再次执行卡住的步骤,使用模拟所需的 gas 乘以 1.25,并且绝不超过
KEEPER_AIRDROP_REEXECUTION_MAX_GAS,默认为 4,000,000 - 它无法发送的步骤(模拟失败、所需 gas 超过上限、其密钥缺少 ETH)会被警报一次,附带手动执行它的命令,并在每个循环中重试
- 在链上再次失败、步骤仍然卡住的情况,就是第二次失败:发出附带命令的警报,Keeper 绝不再发送它。它只依据一次有响应的步骤读取作出这一判定,并且只在失败运行所在的区块已有几个区块的深度(
KEEPER_LOG_LAG_BLOCKS)之后才作出判定,因此无论是落后一个区块的节点,还是该区块的一次重组,都无法让它放弃 - 这些执行由 Keeper 在以太坊上的密钥支付:在 Glamsterdam 下,开启一个循环的最后一步约需 100 万 gas
Keeper 选择时机,以及在所有者设定的范围之内一次送达的 gas,别无其他。在下一个关闭时刻之后到达的发送,按次日的时间窗口计量。
这一步骤由七项设置驱动:KEEPER_AIRDROP(默认开启)、KEEPER_AIRDROP_AFTER_SESSION(默认开启;在不考虑交易时段的测试网上关闭)、KEEPER_AIRDROP_FEE_MARGIN_BPS(1,000)、KEEPER_AIRDROP_TRANSIT_MINUTES(60),自第七个审计循环起的 KEEPER_AIRDROP_MIN_VALUE_BPS(10,000),以及自第九个审计循环起的 KEEPER_AIRDROP_REEXECUTION_MAX_GAS(4,000,000)和 KEEPER_AIRDROP_LZ_EXECUTOR(为空:Keeper 所在链上 LayerZero 的执行器,这是 Keeper 唯一相信其失败警报的调用者)。
重启:状态文件
自 2026-10-05 起,Keeper 把它在一个循环与下一个循环之间跟踪的内容写入一个状态文件(KEEPER_STATE_DIR):在途的空投送达和跨链转账、等待回执的交易、它的警报异常期和连续失败计数;自第六个审计循环起,还包括它跟踪的官方跨链桥通道票据、每笔转账的查找停下的位置,以及每个金库的 USDC 最近一次推进的时间;自第七个审计循环起,还包括其 webhook 尚未接收的警报。较旧版本的 Keeper 写入的文件会按原样加载。它在每个循环和每次发送之后写入该文件,并在启动时读取一次。重启不会遗忘其中任何内容:一次在重启之后最后一步失败的送达仍会被警报,重启之前发出的交易绝不会再次发送,已经警报过的异常期也不会被警报第二次。为另一个工厂合约写入的文件会被移到一边,不予使用,Keeper 以空状态启动,并给出警告。自第八个审计循环起,为另一条链写入的文件首先保持原样,既不读取也不覆盖,直到 Keeper 读取到它的 RPC 服务于哪条链:如果它服务于所配置的链,这个文件就属于另一条链,此时才被移到一边;如果不是,错的是所配置的标识符,Keeper 拒绝启动,而一旦标识符被更正,Keeper 就会取回它的文件,包括它正在跟踪的存款。在此之前,一个输错的标识符会在 Keeper 拒绝启动之前就让这个文件丢失。自同一个审计循环起,保存的警报对每一条不同的警报只保留一条;较旧版本的 Keeper 写入的文件会按原样加载。自第九个审计循环起,该文件还保存每一次空投送达的数据包及 Keeper 对它的各次运行、该市场最近几次送达所使用的 gas,以及 Keeper 对每张官方跨链桥票据的最近一次重放;较旧版本的 Keeper 写入的文件仍会按原样加载。
自 2026-10-06 起:
- 每次发送在等待之前就已落盘。每一笔交易都使用链在发送前一刻给 Keeper 地址的 nonce,并在哈希返回后立即连同哈希和 nonce 写入状态文件,然后才等待回执:在等待期间被终止的 Keeper 重启时会凭哈希读取它,而不是再次发送
- 丢失的交易会被放弃。一笔在发送后经过四倍
KEEPER_RECEIPT_ALERT_MS(默认两小时)仍没有任何节点知道的交易,会被放弃并发出一次警报,从而不再阻碍同类发送;它的 nonce 仍然空闲,Keeper 的下一笔交易会使用它。针对仍未被打包的交易的警报会说明如何替换它 - 完整写入。磁盘满到无法写入该文件是一种错误,会发出警报,最后一个完好的文件保持不变;在此之前,一个被截断的副本可能取代它
- 每个文件夹一个 Keeper。Keeper 会在它的状态文件夹中加一把锁(
keeper.lock):同一文件夹上的第二个 Keeper 会拒绝启动,并指出第一个 Keeper。一把其 Keeper 已不在运行的锁会被接管;由另一台主机上的 Keeper 留下的锁则不会,运营者在那个 Keeper 不再运行之后删除它。试运行不加锁
确定每一步的规模
自 2026-10-01 起,金库转换的是 Keeper 指定的数额,而不是其全部余额。Keeper 这样确定数额:
| 步骤 | 数额 |
|---|---|
| ETH → USDC | 全部余额;只要报价达不到金库的界限就减半,但从不低于转换门槛;自 2026-10-06 起,剩余部分等待下一个时间窗口 |
| 以太坊上 Uniswap 池中的股票 | 该股票的预留额,最多减半四次;仍然达不到界限的那一段等待下一次循环,其预留额保持不变 |
| 可选 Ondo 通道上的股票 | 该股票的预留额,以该交易时段的名义金额上限为限。自 2026-10-06 起,Ondo 报价低于金库界限的那一段绝不会被发送:免费报价会在任何证明之前读取,而一旦某份证明返回的价格低于界限,在下一个时间窗口之前不会再为该股票请求证明 |
| Robinhood Chain 上的股票 | 该股票的预留额,单独受一个固定上限约束,并且在其池子可以计量时,还受该池子深度的一定比例约束 |
未花费的部分留在金库中等待下一次循环(对 ETH 而言是下一个时间窗口);一只股票的现金始终为该股票预留。
自 2026-10-01 的安全流水线起,在每一种买入股票的通道上,每次买入都会在篮子的最后一只股票上留出 n−1 个单位,n 为股票的数量。金库把权重分配的舍入余数归给最后一只股票,因此在 Keeper 读取数额之后、其交易执行之前到达的几个单位的现金,可能让这一项预留额最多减少 n−2 个单位;花费读取到的数额会让整个调用回滚,而任何人都可以用一笔粉尘转账造成这种情况。留出的部分仍为下一次调用预留。其他任何调用金库的程序都应保持同样的余量。合约层面的修复会改变文档所记录的分配规则,该修复有待所有者决定。自 2026-10-06 起,一个只持有这几个单位 USDC 的金库(最多四个,因为一个篮子最多包含五只股票)不会再为它们而被处理:它们会等待其 ETH 带来的下一笔 USDC。
$STOCKFUN 的销毁也以同样的方式确定规模:价格影响超过 Keeper 上限的一批会被减半,但从不低于 Keeper 的门槛,其余部分在之后的循环中销毁。自 2026-10-01 的安全流水线起,$STOCKFUN 池无法完整成交的一批也会被减半;在此之前,那一次循环的销毁会失败。其他任何失败都不会重试。
它不能做什么
选择篮子之外的资产。把一只股票的预留现金转给另一只股票。放宽预言机界限。将资产发送到合约所指定目的地之外的任何地方。提取任何东西。选择谁获得空投、空投包含多少股票,或为自己分配份额。
触发权限只授予它,是为了防止三明治攻击,而不是因为 Keeper 受到信任:它发出的每一个调用都在金库的界限之内执行。
循环
NYSE 日历} C -->|否| W[等待] C -->|是| P{合约是否已暂停?} P -->|是| W P -->|否| A{本时间窗口内对金库的
首次检查:≥ 0.1 ETH?} A -->|否,ETH 等待
下一个时间窗口| N[下一个市场] A -->|是| E[ETH → USDC,50 bps 界限] E --> B[USDC → USDG → 跨链桥] B --> R{是否已到达远端链?} R -->|否| M[监控,重放票据] R -->|是| K[买入股票,200 bps 界限] K --> AD[发送到空投合约] AD --> N
图中的时刻、门槛和界限是默认设置。Keeper 每五分钟执行一轮;金库的 ETH 每个时间窗口检查一次,在关闭后的第一轮进行,而它已持有的 USDC 在每次转换之后推进一次,其他情况下每个时间窗口最多一次。向空投合约的发送每个时间窗口运行一次,默认在交易时段之后;欠款、LP 手续费、送达,以及自第六个审计循环起的官方跨链桥通道票据,则在每个循环中处理,无论处于哪个交易时段。
它的配置
一切都通过环境变量完成:两条链的 RPC、Keeper 的私钥、合约地址、运行间隔,以及安全开关——试运行、只运行一次、要求市场开盘。自 2026-10-05 起还包括:上文所述空投步骤的各项设置;LP 手续费的收取,KEEPER_COLLECT_FEES(默认开启)、KEEPER_COLLECT_FEES_HOURS(24)和 KEEPER_COLLECT_FEES_MIN_WEI(0);反复跳过之后的警报,KEEPER_BRIDGE_ALERT_AFTER_SKIPS(3);以及状态文件所在的文件夹,KEEPER_STATE_DIR。自 2026-10-06 起,还有 KEEPER_CONVERT_ONCE_PER_WINDOW(默认开启):金库的 ETH 每个时间窗口转换一次,并且自第六个审计循环起,它的 USDC 在每次转换之后推进一次,其他情况下每个时间窗口最多一次;本地或测试网运行会把它关闭,并在每一轮都进行转换、买入和跨链。自第六个审计循环起,还有 KEEPER_TICKET_ALERT_HOURS(6):在官方跨链桥通道上,一张不知道已被执行的票据在多少小时之后会被警报。KEEPER_STOCK_ROUTER 是工厂合约为新金库指定的路由器,它只决定由哪个市场的交易时段来控制一个循环;每个金库都在自己的路由器上报价。
自第七个审计循环起,链标识符 KEEPER_CHAIN_ID 和 KEEPER_REMOTE_CHAIN_ID 会在第一个循环之前与 RPC 进行核对:主网上为 1 和 4663,测试网上为 11155111 和 46630。自第八个审计循环起,两者都必须设置:KEEPER_CHAIN_ID 始终需要,使用跨链桥时还需要 KEEPER_REMOTE_CHAIN_ID,否则 Keeper 拒绝启动(在此之前,如果省略,KEEPER_CHAIN_ID 会读作 11155111,KEEPER_REMOTE_CHAIN_ID 会读作 4663)。自第十个审计循环起,KEEPER_LOG_MAX_SELECTORS(默认 1,000,至少为 5)限定一个日志请求所列出的地址和事件值的数量,按 Robinhood Chain 的节点的计数方式计算;在拒绝十个及以上地址的 Sepolia 公共端点之后,它被设为 10。两项设置说明其日志查找在最新区块之下多少个区块处停止:KEEPER_LOG_LAG_BLOCKS(2,在以太坊上)和 KEEPER_REMOTE_LOG_LAG_BLOCKS(12,在 Robinhood Chain 上);自第八个审计循环起,前者也决定跨链批次所在的区块必须有多深 Keeper 才会跟踪它,后者则决定在最新区块之下多少个区块处读取票据。预言机的防护不需要任何设置:Keeper 在每个镜像金库的预言机上读取它们。留空的整数设置采用其默认值,超出其范围的设置会让 Keeper 在启动时停止。
Keeper 的密钥是热密钥,只存入用于 gas 的资金,并且与部署者的密钥不同。
它的局限
- 在 USDG 通道上,现金代币在 Keeper 启动之前或其停止期间拒收的送达,只会在它执行 sweep 时显示为一条警告;官方跨链桥通道则能在远端枢纽自身的状态中找到这样的份额
- 在第九个审计循环之前,Keeper 不会自己运行失败送达的最后一步(
lzCompose);从那以后,它会在一个上限之内再次执行卡住的步骤,而第二次失败则交由人来处理,并附带命令 - 紧急监控在重启之后不会保留其进度:Keeper 停止期间发出的事件不会被扫描
- 在一次发送与其写入状态文件之间的几毫秒内被终止的 Keeper,可能会在重启时再次发送那笔交易;合约自身的检查会让大多数这样的重复交易失败,代价是它们的 gas
- 对一笔转账到达情况的查找从不会读取同一个区块两次:一次把到达移到已读取区块中的重组,会让这笔转账交由在途警报处理。自第七个审计循环起,每一次日志查找都在最新区块之下几个区块处停止,这会让一次计入及其在途警报推迟这几个区块
- 自第八个审计循环起,跨链批次在其区块已有几个区块的深度时才被跟踪,比之前晚一个循环,而下一个批次会等待它;比这更深的重组不在覆盖范围之内,与日志查找一样。一张在最近几个区块中被执行的票据,在那里仍会读作有效,直到最新区块比它的执行晚了这么多个区块:在繁忙的链上是下一个循环,在冷清的链上则需要更多个循环。自第九个审计循环起,被 Keeper 自己的重放删除的票据在此期间既不会被再次重放,也不会触发警报,重启之后也是如此;在这些区块中被其他人重放的票据会被再次尝试,在模拟中失败,并且在警报的时限过后仍可能被当作有效而发出警报
- Keeper 在其最后一笔交易所在的区块上读取的那一分钟,覆盖不了比其他节点落后一分钟以上的节点
- 在本地通道上,时间窗口的开启只计算一次,不计入第二笔交易重复的部分(约为成本的 5 %):一次发送在价值比其成本低至多这么多时仍可能发出
- 自第十个审计循环起,在交易时段结束后、在任何买入之外转入金库的股票,会随下一个交易时段的发送进入之后的某个时间窗口:Keeper 对没有任何东西可发送的金库,只会在下一个交易时段再次读取。Keeper 在交易时段最后一轮中读取到回执的一笔买入,也可能在交易时段结束后的第一轮中被一个落后于它的节点读作空,这需要两轮之间的间隔短于该节点的落后时间(在 Sepolia 上约五秒),而默认的五分钟则绝不会出现这种情况
预检
在任何真实运行之前,一条预检命令会在不写入任何一笔交易的情况下检查:网络是否响应、地址上是否有代码、USDG OFT 的 LayerZero 对端是否匹配、USDG 是否未被其发行方 Paxos 暂停,以及篮子权重是否符合应有的设定。
自第八个审计循环起,它还会检查预言机的防护。它的清单必须说明排序器检查如何设置,两种情况之一:没有在线状态喂价、也没有宽限期(检查关闭,如今就是这样),或者有喂价和宽限期。有喂价时它会读取该喂价,并读取每只股票代币的预言机暂停:处于公司行动中的代币是一条警告,不会让检查失败;没有这一信号的代币则会失败。部署之后,它会检查已部署的预言机与清单一致、其排序器状态允许价格通过,以及每只股票的暂停检查都已开启。Keeper 的 inspect 命令会打印同样的防护:排序器检查及其状态,以及每只股票的暂停检查和其代币当前是否暂停。
自第九个审计循环起,它也可以在 LayerZero 测试网运行的部署(Sepolia 和 Robinhood Chain 的测试网)上运行,依据这次运行的两个文件,并使用为这一对链命名的 RPC,因此绝不会向主网 RPC 询问测试网的情况;任何其他的链组合(包括混合组合)都会被拒绝。在测试网上,它不读取 Uniswap v3 路由器,因为那条链上没有,并按这次运行的预言机所设定的心跳间隔检查 ETH/USD 喂价。在两组链上,它都会检查远端路由器所指定的 v3 路由器与清单所说的一致。在测试网部署上运行:68 项检查,全部通过。
如果没有 RPC,它会拒绝运行,而不是报告一个虚假的成功。