一次程序升级,能静默搞坏每一个用它的机器人

字段偏移挪位、错误重新编号、账户要求变化 —— 三者都不报错。什么必须保持稳定,以及怎么把变更说清楚。

BoltTx Team··15 min read
solana程序升级兼容性程序开发anchor交易上链

你发布了一次升级。测试通过、程序正常,而一小时之内,机器人开始读到错误的值、并对着永远不会解除的失败反复重试。

★什么都没报错。而这正是问题所在 —— 最具破坏性的升级改动,产出的是"看似合理的错误答案",而不是失败。★

三种静默的破坏

改动 它怎么失败
★在结构体中间插入字段★ ★客户端读错字节,拿到看似合理的数字★
★插入错误变体★ ★重试逻辑对着错误的条件行动★
★重排指令账户★ ★程序操作到错误的账户★

三者都能编译、能部署、能运行。 而且没有一个会发出"有东西变了"的信号。

★共同点是位置耦合。★ 客户端依赖字节偏移、错误编号和账户索引 —— 这些都不出现在类型签名里,而且你在它们上面插入任何东西,它们全都会挪位。

字段偏移会把下面的全部挪位

// v1
pub struct Pool {
    pub authority: Pubkey,     // 偏移 8
    pub reserve_a: u64,        // ★偏移 40★
    pub reserve_b: u64,        // 偏移 48
}

// v2 —— 中间加了一个字段
pub struct Pool {
    pub authority: Pubkey,     // 偏移 8
    pub fee_bps: u16,          // ★偏移 40 —— 插入的★
    pub reserve_a: u64,        // ★现在是 42★
    pub reserve_b: u64,        // ★现在是 50★
}

★一个在偏移 40 处读 reserve_a 的客户端,现在读到的是两字节手续费加六字节储备。★ 结果是一个数字 —— 差了好几个数量级,但它是个数字 —— 而机器人会拿它给一笔交易定价。

字段追加到末尾。 这不花任何成本,而另一种做法会静默污染每一个记住了偏移的客户端。

★如果你改变了已有字节的含义,不管怎样都需要一次迁移★ —— 但追加至少让已有的读取者对它们已经知道的字段保持正确。

错误重新编号

Anchor 按声明顺序从 6000 开始编号:

// v1                          // v2 —— 插入了一个变体
SlippageExceeded,   // 6000    SlippageExceeded,     // 6000
PoolPaused,         // 6001    ★NewValidation,       // 6001★
Unauthorized,       // 6002    PoolPaused,           // ★现在是 6002★
                               Unauthorized,         // ★现在是 6003★

★一个写着"6001 = 池子暂停,稍后重试"的机器人,现在会对着一个永远不会解除的校验错误重试。★ 它会一直烧手续费直到 blockhash 过期,然后重建,再来一遍。

错误变体一律追加。永远不要插入、不要重排、不要复用已移除的编号。 ★留空位就好 —— 一个未知的码,远比一个"含义已经不同"的码安全。★

如果你的升级是兼容的、交易还是会错过,免费领个 BoltTx key 你的用户改一行就能测测提交路径。

账户顺序就是你的接口

// v1                                v2 —— 中间插入
pub struct Swap<'info> {            pub struct Swap<'info> {
    pub pool: ...,      // 0            pub pool: ...,        // 0
    pub source: ...,    // 1            ★pub oracle: ...,     // 1★
    pub dest: ...,      // 2            pub source: ...,      // ★现在是 2★
}                                       pub dest: ...,        // ★现在是 3★
                                    }

★程序按位置读账户。★ 一个按 v1 构建的客户端,现在把它的来源账户传到了程序期待 oracle 的位置,把目标账户传到了程序期待来源的位置。

如果类型不同,它会 revert —— 那是好结果。★如果类型恰好兼容,它会在错误的账户上执行并成功。★ 那才是值得为之做设计的失败:一笔金额正确、方向错误的转账。

新账户追加到末尾,并且把真正可选的标成可选,而不是插到"读起来更自然"的位置。

增加一个必需账户就是破坏性变更

即便追加在末尾,一个必需的新账户也会打断每一个已有客户端:

pub struct Swap<'info> {
    // ... 已有账户 ...
    pub new_config: Account<'info, Config>,   // ★客户端不会传这个★
}

他们会拿到 NotEnoughAccountKeys —— 至少它是大声失败的,但它在你部署的那一刻,对所有人同时失败。

★两条不打断调用方的路:★

新增一条指令。 让旧的继续可用,加一个带额外账户的 swap_v2,让客户端按自己的节奏迁移。

做成可选账户。 在框架支持的地方,把"缺席"当成一个有文档的默认值,而不是一个错误。

升级权限是一份信任声明

★每一个筛查你程序的机器人,都在检查它会不会在他们脚下被换掉。★

const info = await connection.getAccountInfo(programId);
// ★升级权限还活着,就意味着有人能把整个程序换掉。★

可升级意味着持有权限的人能换掉逻辑 —— 包括换成拿走用户资金的逻辑。成熟的调用方会检查这一点,而它是"集成方要不要基于你来做"的真实因素。

按信任度排序的选项:

★权限设为 null★ —— 不可变,信任度最高,零灵活性。

多签权限 —— 变更需要多方参与,这是常见的中间地带。

★单一密钥★ —— 方便,而从调用方视角看是一个实质风险。

不管你选哪个,都要公开说明。 ★一个查不到你升级政策的调用方,会按最坏情况假设。★

显式地给接口加版本

#[account]
pub struct Pool {
    pub version: u8,           // ★discriminator 之后的第一个字段★
    // ...
}

★一个版本字节,让客户端能"检测到一个自己看不懂的布局",而不是把它读错。★ 它花一个字节,把一次静默的数据污染变成一次干净的拒绝。

再配一份说明兼容性影响、而不只是功能的更新日志:

v2  新增 fee_bps(追加)          ★客户端:无需改动★
v3  新增 oracle 账户(必需)      ★客户端:必须更新★
v4  新增错误 6005(追加)         ★客户端:无需改动★

★第三行是集成方在你部署之前、而不是之后需要的那句话。★

上链应该是什么水平

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

★一次兼容性破坏产出的,是"落块并 revert"的交易 —— 或者更糟,"落块并错误地成功"。★ 两者都不是提交问题 —— 这就是为什么接口稳定性是你的调用方在自己那边解决不了的那一件事。

BoltTx 在这里的位置

我们为调用你程序的那些人处理提交。接口稳定性完全由你决定。

提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool —— 交易在落块之前、传输途中观察不到。

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

免费领 API key,没有月费:

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

常见问题

升级一个 Solana 程序会破坏什么? 最危险的是插入结构体字段、插入错误变体、重排账户。三者都静默失败,产出错误的值而不是错误。

在结构体中间插入字段为什么会破坏客户端? 因为客户端按字节偏移读字段。插入会把它下面的一切挪位,于是客户端读到两个字段的混合,拿到一个看似合理但错误的数字。

该怎么给账户结构体加字段? 追加到末尾。 已有客户端对它们已经在读的字段保持正确,而新客户端不用迁移就能读到新增的。

插入 Anchor 错误变体为什么会搞坏机器人? 错误按声明顺序从 6000 编号。插入会把它之后的全部重新编号,于是一个对 6001 重试的机器人,现在对着一个永远不会解除的条件重试。

移除变体之后能复用它的错误码吗? 不能。留空位。 一个未知的码比一个"含义已经不同"的码安全,因为调用方可能把旧含义写死了。

升级时账户顺序为什么要紧? 程序按位置读账户。 重排意味着客户端把账户传进了错误的槽位,而如果类型恰好兼容,它会错误地成功。

增加一个必需账户算破坏性变更吗? 算,即便是追加的。已有客户端不会传它,会拿到 NotEnoughAccountKeys。改成新增一条指令,或者把账户做成可选的。

怎么加功能又不打断调用方? 在旧指令旁边新增一条。 调用方按自己的节奏迁移,而你避免了一次让所有集成同时失效的部署。

我的程序该保留升级权限吗? 这是一个信任取舍。null 意味着不可变、信任度最高;多签是常见的中间地带;单一密钥方便,但从调用方视角看是实质风险。

集成方为什么要检查升级权限? 因为权限持有者随时能把可升级的程序换成另一套逻辑,包括拿走资金的那种。一个查不到你政策的调用方,会按最坏情况假设。

版本字段是干什么的? 让客户端能检测到一个自己看不懂的布局,而不是把它读错。 一个字节,把一次静默污染变成一次干净的拒绝。

程序更新日志该写什么? 兼容性影响,而不只是功能。 集成方需要在你部署之前知道"要不要更新",而不是事后才发现。

客户端怎么发现程序变了? 通常发现不了,而这正是核心问题。 版本字段加上公开的更新日志,是让它"可被检测"而不是"意外撞上"的两个机制。

改变已有字段的含义安全吗? 不安全,这是最糟的情况。客户端会继续成功地读它、并错误地解读它 —— 产出的是自信的错误行为,而不是一个错误。

该拿真实客户端测升级吗? 该,而且要用按上一个版本构建的客户端测。那是唯一能抓到静默破坏的测试 —— 你自己更新过的客户端按构造一定会通过。

最安全的升级政策是什么? 只追加、永不插入或重排、用新指令代替改动已有签名,并在部署之前公布兼容性影响。

延伸阅读

← 返回博客列表