Solana DeFi 协议的交易可靠性

当你的协议代表用户提交交易时,失败就变成了工单。keeper、crank 和结算该怎么建。

BoltTx Team··16 min read
solanadefi协议keeper交易上链可靠性

一个交易机器人漏掉一笔交易,损失的是一笔交易。协议漏掉一笔交易,换来的是用户来问"我的仓位为什么没被平掉"。

区别不在技术上,而在于结果由你负责 —— 这改变了你能容忍哪些失败。

协议在哪些地方提交交易

大多数 Solana 协议最终都会有一个发送交易的后端,哪怕设计之初是完全由用户驱动的:

Keeper。 清算、到期、阈值触发 —— 这些必须在没有用户在场时发生。

Crank。 撮合、奖励分发、状态推进 —— 任何需要有人替下一步付钱的事。

结算。 批量结果、epoch 轮换、依赖预言机的更新。

协助用户的操作。 用户签名,你的后端提交并重试。

★这四种有一个共同属性:没有人在实时盯着。★ swap 失败了用户会自己重试;而一个悄悄停掉的 keeper,产出的是一笔坏账 —— 在它上链之前没人会注意到。

最要命的那种失败

对协议来说,昂贵的失败不是"一笔慢的交易",而是一笔没落块、并且没被发现的交易

// ★这个 bug 长这样。★
async function runKeeper() {
  const positions = await findLiquidatable();
  for (const p of positions) {
    const tx = buildLiquidation(p);
    await connection.sendRawTransaction(tx.serialize());
    // 返回了签名。没有任何地方检查它到底有没有落块。
  }
}

sendRawTransaction 返回签名,只说明 RPC 接收了这些字节。它不说明任何东西落了块。这样写的 keeper 会报告"全部成功",而仓位仍然开着。

修法是结构性的,不是什么巧妙技巧:每一次提交都必须有一个终态。

const outcome = await submitAndResolve(tx, lastValidBlockHeight);

switch (outcome.status) {
  case "success":   await markDone(p, outcome.slot); break;
  case "reverted":  await recordRevert(p, outcome.err); break;   // ★逻辑问题★
  case "expired":   await requeue(p); break;                     // ★时间问题★
}

★三个终态,而且需要不同的处理。★ revert 意味着你的指令对它撞上的那个状态是错的,所以把同一笔交易重新排队还是会 revert。过期意味着它从没执行过,那么换一个新 blockhash 重新排队恰恰是对的。

如果你的 keeper 写对了、过期率仍然是你的问题,免费领个 BoltTx key 改一行就能拿来对比。

幂等不是可选项

被重试的 keeper 动作绝不能重复执行。在 Solana 上,这件事你白拿一半,另一半得自己建

白拿的: 相同的已签名字节产生相同的签名,而一个签名最多被收录一次。重发同一笔交易是安全的。

不白拿的: 重新构建一笔交易会产生不同的签名。如果第一笔落块了而你没观察到,重建的那笔可能会执行第二次。

// ★重建之前先把旧签名的状态确定下来。★
const prior = await connection.getSignatureStatus(lastSig,
  { searchTransactionHistory: true });   // ★may be older than the status cache★
if (prior.value && !prior.value.err) return;   // 已经做完了

const rebuilt = await buildWithFreshBlockhash(action);

更强的版本是把这道守卫放到链上:由程序自己拒绝第二次执行 —— 检查一个状态标志、一个序号,或者一个按 epoch 的标记。★客户端检查会和你自己的重试竞争,链上检查不会。★

★凡是动用户资金的动作,链上守卫值得多花那一个账户。★

Keeper 之间的竞争

如果清算是无许可的,那你就在和其它 keeper 赛跑,而输掉这场比赛不是中性的 —— 机会没了,而手续费你花了。

// 同一个目标,好几个 keeper。要假设你有时候会输。
const result = await submitAndResolve(tx, lastValidBlockHeight);

if (result.status === "reverted" && isAlreadyLiquidated(result.err)) {
  // ★别人赢了。这是预期之内,不是事故。★
  metrics.lostRace.inc();
  return;
}

★在监控里把"输掉比赛"和"我们的 keeper 坏了"分开。★ 输掉比赛的比例上升,说明竞争者落得更快;过期率上升,说明问题在你的提交路径上。把两者合并成一个错误计数,恰好掩盖了你到底是哪一种。

自动化提交的手续费策略

用户只选一次手续费。keeper 一天要选几千次,所以策略是会复利的。

不要写死。 固定的优先费,要么在平静时浪费,要么在真正关键的那些时刻不够用。

从被争用的账户推导。 手续费压力是按账户的,不是全局的:

const fees = await connection.getRecentPrioritizationFees({
  lockedWritableAccounts: writableAccounts,
});
const sorted = fees.map((f) => f.prioritizationFee).sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)] ?? 0;

按涉及的价值缩放。 保护一个大仓位的清算,值得比一次例行 crank 花更多。★一个对两者付同样手续费的协议,在其中一边多付了,在另一边少付了。★

设上限。 在手续费尖峰里,一个没有上界的策略就是一笔没有上界的开销。设个界,并且在撞到这个界时告警,而不是默默地付

能回答对问题的可观测性

协议的看板经常在追踪提交次数,而这几乎什么都说明不了。

★该追踪的是这些:★

指标 为什么
按动作类型的 落块数/尝试数 真实的成功率
过期率 单独隔离提交路径
按错误码的 revert 率 单独隔离指令逻辑
★slot 距离,按分布看★ ★尾巴在哪里★
输掉比赛的比例 竞争性的,不是故障
每个动作的手续费开销 策略是否合理

★要分布,不要平均值。★ 一个中位数两个 slot 落块、尾部十二个 slot 的 keeper,有一个被平均值完全掩盖掉的真实问题 —— 而尾部恰恰出现在网络拥堵时,也恰恰是清算到来的时候。

降级模式

协议需要为"提交大面积失败"这种情况准备好答案,而且应该提前决定:

不要无限重试。 过了 lastValidBlockHeight 就重建。一个没有出口的重试循环,是在为一笔根本落不了块的交易持续花手续费。

要排优先级。 高负载下,风险最大的仓位排在最前。★拥堵时的 FIFO 队列,会先处理一笔灰尘级清算,再处理一笔威胁偿付能力的。★

按比例告警,不要按单次。 单次失败是正常的,上升的比例才是信号

留一条人工通道。 当自动化落不了块时,得有人能直接提交。

上链应该是什么水平

我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。

★对协议来说,价值在于它可预测,而不是它多快。★ 只有可预测,你才能设定超时、估算 keeper 集群规模、并且告诉用户该期待什么。

BoltTx 在这里的位置

我们负责提交。不托管、不管执行逻辑、不管你的 keeper 怎么设计。

提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 一笔清算在落块之前、传输途中观察不到,而当其它 keeper 正盯着这类交易时,这一点很重要

你的 keeper 用它自己的密钥在本地签名。我们不托管资金、不代签、不修改交易内容。tip 带在交易里、从 keeper 钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。

免费领 API key,没有月费:

const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");

常见问题

DeFi 协议该怎么处理交易失败? 把每一次提交都归结到三个终态之一:成功落块、落块并 revert、过期。revert 指向指令逻辑,过期指向提交路径,两者需要不同的应对。

我的 keeper 为什么什么都没发生却报告成功? 因为 sendRawTransaction 返回签名,只意味着 RPC 接收了字节,跟落块没有关系。要轮询 getSignatureStatuses 直到交易有结果、或者 blockhash 过期。

Solana 上怎么让 keeper 动作幂等? 重发相同字节是安全的,因为一个签名最多被收录一次。重建则不安全,所以要先检查上一个签名 —— 而凡是动用户资金的动作,应该把守卫做进程序本身。

Solana keeper 该付多少手续费? 从它将要写入的那些账户的近期费用推导,按涉及价值缩放,并设上限。写死的费用在平静时浪费,而在清算到来的拥堵时刻又不够。

怎么判断我的 keeper 是输了比赛还是坏了? 分开统计。表示"该动作已被执行"的 revert 属于输掉比赛;而过期率上升意味着你的交易根本没落块,那是提交问题。

协议该重试一笔 revert 的交易吗? 不要原样重试。revert 意味着某个程序检查针对它撞上的状态拒绝了它,同一笔交易还会 revert。要按当前状态重建,或者记录下来然后继续。

keeper 该重试多久才放弃? 到 blockhash 过期为止 —— 用 getBlockHeight()lastValidBlockHeight 比较。过了这个点交易就永久无效了,继续重发是在为落不了块的东西花钱

协议该监控哪些交易健康指标? 按动作类型的落块数对尝试数、过期率、按错误码的 revert 率、按分布看的 slot 距离,以及手续费开销。光看提交次数,对结果一无所知。

拥堵时怎么给 keeper 动作排优先级? 按风险价值排,而不是按到达顺序。高负载下的 FIFO 队列会把琐碎动作排在威胁偿付能力的动作前面 —— 在容量稀缺时这恰好是反的。

协议能批量发送 keeper 交易吗? 当这些动作彼此独立时可以。任何有顺序要求的都不能批量,因为分别提交的交易没有顺序保证,即便来自同一个签名者。

为什么波动期清算失败更多? 因为那正是网络拥堵、并且其它所有 keeper 都在提交同一个动作的时候。你的竞争和你的延迟在同一时刻一起恶化。

keeper 交易该用 skipPreflight 吗? 一般该。preflight 要花一次往返,而且模拟的是当前 slot 而不是你将要落块的那个。开发阶段以及验证新指令路径时再做模拟。

怎么防止重复清算? 在链上强制执行。程序应该通过检查状态来拒绝对同一仓位的第二次执行,因为客户端去重既要和你自己的重试竞争,也要和其它 keeper 竞争

keeper 失败的告警阈值该怎么设? 按上升的比例告警,而不是按单次失败。有些失败是正常的 —— 输掉比赛、以及针对已变状态的 revert —— 所以按单次告警只会训练人去忽略它们。

协议需要自建 RPC 基础设施吗? 不一定,但它需要一条自己了解其拥堵表现的提交路径。共享的公共端点恰恰在 keeper 动作变得紧急时劣化。

上主网前怎么测 keeper 的可靠性? 测 slot 分布,而不是"通过/不通过"这个结果,并且在多个 keeper 竞争的模拟争用下测。一个没人跟它抢时能用的 keeper,说明不了它在清算级联里的表现。

延伸阅读

返回博客列表