一个交易机器人漏掉一笔交易,损失的是一笔交易。协议漏掉一笔交易,换来的是用户来问"我的仓位为什么没被平掉"。
区别不在技术上,而在于结果由你负责 —— 这改变了你能容忍哪些失败。
协议在哪些地方提交交易
大多数 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,说明不了它在清算级联里的表现。
延伸阅读
- Solana 清算机器人基础设施
- Solana 机器人监控与告警
- Solana 交易重试模式
- Solana compute unit 计价
- Solana 交易上链完全指南