You ship an upgrade. The tests pass, the program works, and within an hour bots are reading wrong values and retrying failures that will never clear.
★Nothing errored. That is the problem — the most damaging upgrade changes are the ones that produce plausible wrong answers instead of failures.★
The Three Silent Breakages
| Change | How it fails |
|---|---|
| ★Inserting a struct field★ | ★Clients read wrong bytes, get plausible numbers★ |
| ★Inserting an error variant★ | ★Retry logic acts on the wrong condition★ |
| ★Reordering instruction accounts★ | ★Program operates on the wrong account★ |
All three compile, deploy, and run. None of them produce a signal that anything changed.
★The common thread is positional coupling.★ Clients depend on byte offsets, error numbers, and account indices — none of which appear in a type signature, and all of which shift when you insert something above them.
Field Offsets Shift Everything Below
// v1
pub struct Pool {
pub authority: Pubkey, // offset 8
pub reserve_a: u64, // ★offset 40★
pub reserve_b: u64, // offset 48
}
// v2 — a field added in the middle
pub struct Pool {
pub authority: Pubkey, // offset 8
pub fee_bps: u16, // ★offset 40 — inserted★
pub reserve_a: u64, // ★now 42★
pub reserve_b: u64, // ★now 50★
}
★A client reading reserve_a at offset 40 now reads two bytes of the fee plus six bytes of the reserve.★ The result is a number — wrong by orders of magnitude, but a number — and a bot will price a trade against it.
Append fields at the end. It costs nothing, and the alternative silently corrupts every client that memorised an offset.
★A migration is required either way if you change the meaning of existing bytes★, but appending at least leaves existing readers correct about the fields they already knew.
Error Renumbering
Anchor numbers errors from 6000 in declaration order:
// v1 // v2 — variant inserted
SlippageExceeded, // 6000 SlippageExceeded, // 6000
PoolPaused, // 6001 ★NewValidation, // 6001★
Unauthorized, // 6002 PoolPaused, // ★now 6002★
Unauthorized, // ★now 6003★
★A bot with 6001 = pool paused, retry later now retries on a validation error that will never clear.★ It burns fees until the blockhash expires, then rebuilds and does it again.
Append error variants. Never insert, never reorder, never reuse a removed number. Leave gaps instead — an unknown code is far safer than a code that means something different than it used to.
If your upgrades are compatible and transactions still miss, a free BoltTx key is one line for your users to test the submission path.
Account Order Is Your Interface
// v1 v2 — inserted in the middle
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: ..., // ★now 2★
} pub dest: ..., // ★now 3★
}
★Programs read accounts positionally.★ A client built against v1 now passes its source account where the program expects an oracle, and its destination where the program expects a source.
If the types differ, it reverts — which is the good outcome. ★If the types are compatible, it executes on the wrong accounts and succeeds.★ That is the failure worth designing against: a transfer that moves the right amount in the wrong direction.
Append new accounts at the end, and mark genuinely optional ones as such rather than inserting them where they read naturally.
Adding a Required Account Is Breaking
Even appended at the end, a required new account breaks every existing client:
pub struct Swap<'info> {
// ... existing accounts ...
pub new_config: Account<'info, Config>, // ★clients do not pass this★
}
They get NotEnoughAccountKeys — which at least fails loudly, but fails for everyone at once, the moment you deploy.
★Two paths that do not break callers:★
A new instruction. Keep the old one working, add swap_v2 with the additional account, and let clients migrate on their own schedule.
An optional account. Where the framework supports it, treat absence as a documented default rather than an error.
The Upgrade Authority Is a Trust Statement
★Every bot screening your program is checking whether it can change under them.★
const info = await connection.getAccountInfo(programId);
// ★A program with a live upgrade authority can be replaced entirely.★
An upgradeable program means whoever holds the authority can swap the logic — including for logic that takes user funds. Sophisticated callers check this, and it is a real factor in whether integrators build against you.
Options, in order of trust:
★Authority set to null★ — immutable, maximum trust, zero flexibility.
Multisig authority — changes require several parties, which is the common middle ground.
★Single key★ — convenient, and a meaningful risk from the caller's perspective.
Whichever you choose, say so publicly. A caller who cannot determine your upgrade policy assumes the worst case.
Version Your Interface Explicitly
#[account]
pub struct Pool {
pub version: u8, // ★first field after the discriminator★
// ...
}
★A version byte lets clients detect a layout they do not understand instead of misreading it.★ It costs one byte and converts a silent corruption into a clean rejection.
Pair it with a changelog that states the compatibility impact, not just the feature:
v2 Added fee_bps (appended) ★clients: no change needed★
v3 Added oracle account (required) ★clients: MUST update★
v4 New error 6005 (appended) ★clients: no change needed★
★That third line is the one integrators need before you deploy, not after.★
What Landing Looks Like
Real transactions through our delivery nodes: median confirmation 336ms — under one slot.
★A compatibility break produces transactions that land and revert, or worse, land and succeed incorrectly.★ Neither is a submission problem, which is why interface stability is the one thing your callers cannot solve on their side.
Where BoltTx Fits
We handle submission for the people calling your program. Interface stability is decided entirely by you.
Submissions route through our own delivery nodes in four regions with stake-weighted routing and no public mempool exposure, so a transaction is not observable in transit before it lands.
Your callers sign locally. We never hold funds, never sign, and never modify transaction contents. The tip travels inside the transaction, paid on chain from their own wallet, and reverts with the transaction if it fails, because that is how Solana handles atomic transactions. They pay only on transactions that reach the chain.
Get a free API key. No monthly fee:
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
FAQ
What breaks when I upgrade a Solana program? Most dangerously, inserted struct fields, inserted error variants, and reordered accounts. All three fail silently, producing wrong values rather than errors.
Why does inserting a struct field break clients? Because clients read fields at byte offsets. Inserting shifts everything below it, so a client reads a mix of two fields and gets a plausible but wrong number.
How should I add fields to an account struct? Append at the end. Existing clients remain correct about fields they already read, and new clients can read the addition without a migration.
Why does inserting an Anchor error variant break bots? Errors are numbered from 6000 in declaration order. Inserting renumbers everything after it, so a bot retrying on 6001 now retries on a condition that will never clear.
Can I reuse an error code after removing a variant? No. Leave a gap. An unknown code is safer than one that means something different than it did, since callers may have the old meaning hardcoded.
Why does account order matter in an upgrade? Programs read accounts positionally. Reordering means a client passes accounts into the wrong slots, and if the types happen to be compatible it succeeds incorrectly.
Is adding a required account a breaking change?
Yes, even appended. Existing clients do not pass it and get NotEnoughAccountKeys. Add a new instruction or make the account optional instead.
How do I add functionality without breaking callers? A new instruction alongside the old one. Callers migrate on their own schedule, and you avoid a deploy that breaks every integration simultaneously.
Should my program have an upgrade authority? It is a trust tradeoff. Null means immutable and maximum trust; a multisig is the common middle ground; a single key is convenient and a real risk from the caller's view.
Why do integrators check the upgrade authority? Because an upgradeable program can be replaced with different logic, including logic that takes funds. A caller who cannot determine your policy assumes the worst case.
What is a version field for? Letting clients detect a layout they do not understand instead of misreading it. One byte converts a silent corruption into a clean rejection.
What should a program changelog contain? The compatibility impact, not just the feature. Integrators need to know whether they must update before you deploy, not discover it afterwards.
How do clients detect that a program changed? Usually they do not, which is the core problem. A version field plus a published changelog are the two mechanisms that make it detectable rather than a surprise.
Is changing the meaning of an existing field safe? No, it is the worst case. Clients keep reading it successfully and interpret it incorrectly, which produces confident wrong behaviour rather than an error.
Should I test upgrades against real clients? Yes, against a client built for the previous version. That is the only test that catches a silent break, since your own updated client will pass by construction.
What is the safest upgrade policy? Append only, never insert or reorder, add new instructions instead of changing existing signatures, and publish compatibility impact ahead of deploying.