直接读 Solana 池子状态,而不是信一个报价

报价是某个 API 在一个已经过去的 slot 上的意见。读储备,才知道这个池子会对你的交易做什么。

BoltTx Team··13 min read
solana池子amm储备价格冲击交易机器人

大多数机器人问聚合器"这笔交易值多少",然后照着答案行动。这能用,直到它不能用为止 —— 而它失败时,你没法分辨是报价错了,还是行情动了。

★报价是一个来自已经过去的 slot 的数字。而池子的储备,才是你的交易将要真正执行的对象。★

恒定乘积池在做什么

你交易的几乎每一个 AMM 都持有两份储备,并保持它们的乘积不变:

x · y = k

你的交易沿着这条曲线移动,而输出直接由储备推出:

function amountOut(amountIn: bigint, reserveIn: bigint, reserveOut: bigint, feeBps: bigint) {
  const inAfterFee = amountIn * (10_000n - feeBps) / 10_000n;
  return (inAfterFee * reserveOut) / (reserveIn + inAfterFee);
}

★这就是恒定乘积池的全部定价模型。★ 给定两份储备和费率,你自己就能算出输出 —— 不需要报价 API、不需要往返、除了你读取的那个 slot 之外没有任何陈旧。

由此立刻推出两件事:

你拿到的价格取决于你自己的规模,而不是某个独立于你存在的市场价。

★你能算出程序将要算出的那个一模一样的数字★ —— 这意味着你可以校验一个报价,而不是信任它

读取储备

储备是由池子拥有的代币账户,所以读它们就是一次标准的余额读取:

const [vaultA, vaultB] = await connection.getMultipleAccountsInfo([
  poolVaultA, poolVaultB,
]);

const reserveA = vaultA.data.readBigUInt64LE(64);   // ★amount 在偏移 64★
const reserveB = vaultB.data.readBigUInt64LE(64);

★一次 getMultipleAccountsInfo 把两个都拿到,而且来自同一个 slot。★ 分两次调用可能跨过 slot 边界,给你一对不匹配的储备 —— 两个从未同时存在过的数字,算出一个从未真实存在过的价格

金库地址来自池子账户,解码一次然后缓存。它的布局因程序而异,可靠的做法是用程序的 IDL,而不是背一个偏移量

如果你的定价是对的、交易还是会错过,免费领个 BoltTx key 改一行就能测测提交路径。

对机器人来说,这为什么胜过报价

没有往返。 一次报价 API 调用是你热路径里的网络时间。而你本来就在流式接收的储备,在决策时刻不花任何时间。

没有你无法测量的陈旧。 ★报价不告诉你它来自哪个 slot。★ 而储备读取会告诉你,通过 getAccountInfoAndContext

const { context, value } = await connection.getAccountInfoAndContext(poolVaultA);
console.log("储备来自 slot", context.slot);

你可以校验。 拿你算出的输出和报价比对,正是你发现聚合器把你路由到一个比它声称的更差的地方的方式

API 不可用时它照样能用。 限流、故障、不认识的新代币,这些都会让依赖报价的机器人停摆,而读储备的那个还在跑

★老实的例外:聚合器在跨很多场所的多跳路由上确实有价值。★ 自己读储备,是给一个你已经选定的池子用的更好工具;它不是"在几十个场所里发现路由"的替代品。

用流式订阅,不要轮询

池子账户每有人交易一次就变一次,这让它非常不适合轮询:

connection.onAccountChange(poolVaultA, (info, ctx) => {
  reserves.a = info.data.readBigUInt64LE(64);
  reserves.slot = ctx.slot;
}, "processed");

★这里用 processed 是对的 —— 你在做决策,不是在记账。★ 一次被回滚的储备读取,最坏结果是浪费一次尝试;而等 confirmed 意味着你在用一个老了至少一个 slot 的状态做决定。

把 slot 和数值一起记下来。 一个没有 slot 的储备数字没法检查陈旧程度 —— 而陈旧正是你直接读取的全部理由。

一个值得知道的失败模式

★读储备保护不了你免于"读取和执行之间池子发生变化"。★ 它做不到 —— 什么都做不到。

slot N     你读到储备,算出输出
slot N+1   别人交易了,储备变了
slot N+2   ★你的交易对着新储备执行★

你算出的输出对 slot N 是正确的,对 slot N+2 是无关的。 ★这正是最小输出检查存在的意义★,也是"读储备不能替代设置它"的原因。

两者是配合工作的:★储备告诉你该期待什么、该下多大;链上的最小值在期待和现实分岔时保护你。★

值得跑的几项检查

if (reserveA === 0n || reserveB === 0n) return null;    // ★被抽干或未初始化★

const impact = Number(spotOut - expectedOut) / Number(spotOut);
if (Math.abs(impact) > MAX_IMPACT) return null;         // ★你的规模太大★

零储备在新代币上很常见,而它会产出一个除法 —— 取决于你的代码路径,要么报错、要么给出一个荒谬的价格。

★冲击检查是最常被跳过的那一项,也正是它能抓住"这个池子对你想要的规模来说太薄"。★ 一笔会把价格推动百分之二十、对你不利的交易照样会执行 —— 程序对"这样做明不明智"没有意见。

上链应该是什么水平

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

★从读到储备到执行之间的每一个 slot,都是别人抢先交易的机会。★ 直接读取移除了报价的那次往返;落得更快则收窄剩下的那个窗口。

BoltTx 在这里的位置

我们负责提交。读池子、定价、算规模,全都留在你的代码里

提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前、传输途中观察不到。你的读取继续用现在的端点 —— 两条路径互相独立

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

免费领 API key,没有月费:

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

常见问题

怎么读 Solana 流动性池的储备? 取池子的两个金库代币账户,读每个账户里偏移 64 处的 amount。用 getMultipleAccountsInfo,让两个来自同一个 slot。

为什么该读储备而不是用报价 API? 热路径里没有往返、有一个可以检查陈旧程度的 slot 号、能校验报价,以及在 API 被限流或不认识新代币时照样能工作。

恒定乘积 swap 的输出怎么算? 先对输入套用费率,再乘以输出储备、除以"输入储备加上扣费后的输入"。这和程序执行的是同一套算术。

池子的金库地址在哪找? 在池子账户的数据里,用程序的 IDL 解码。缓存它们,因为在池子的生命周期内它们不变。

为什么两份储备必须来自同一个 slot? 因为分开读可能跨过 slot 边界,给你一对从未同时存在过的数字 —— 算出一个从未真实存在过的价格。

储备读取该用哪个 commitment? 做决策时用 processed,因为回滚的最坏结果只是浪费一次尝试。只有要在链下记录的值才用更高的级别。

池子账户该轮询还是订阅? 订阅。池子账户每笔交易都变,所以轮询要么漏更新、要么浪费请求。onAccountChange 会在变化发生时推给你。

读储备能保护我不受价格波动影响吗? 不能。池子会在你读取和执行之间变化。 储备告诉你该期待什么、该下多大,而链上的最小输出量在现实分岔时保护你

价格冲击怎么算? 拿你算出的输出,和一笔无穷小的交易能拿到的输出比。差额就是冲击 —— 而它是你自己的规模造成的,不是市场给的。

零储备意味着什么? 要么池子未初始化,要么它被抽干了。这在新代币上很常见,必须检查,否则会产出一个错误或一个荒谬的价格。

读了储备还需要最小输出量吗? 永远需要。你的计算对你读取的那个 slot 正确,而交易在之后才执行。 链上检查是唯一强制你那份期待的东西。

Solana 的池子都是恒定乘积吗? 不是。集中流动性和稳定币池用的是不同曲线,所以上面的算术专门适用于恒定乘积池。假设之前先确认类型。

该完全不用聚合器吗? 不该。它们在跨很多场所发现路由上有真实价值。 读储备更适合一个你已经选定的池子,不是"找到路由"的替代品

怎么发现一个池子对我的交易来说太薄? 按你打算的规模算价格冲击,超过阈值就拒绝。程序会乐意执行一笔把价格重重推向不利于你的交易。

聚合器的报价有多陈旧? 不知道 —— 而这正是问题所在。它不报告自己是在哪个 slot 算的,所以你没法测量陈旧程度。直接读取会告诉你,通过 context slot。

池子里什么该缓存? 金库地址、费率、decimals,这些都是稳定的。★永远不要缓存储备本身★,因为它们每笔交易都变。

延伸阅读

返回博客列表