On Solana, a feature gate activating and the change it carries actually happening are two separate events. For most gates the difference never shows. For a gate that replaces a core program’s bytes, it can: the replacement runs once, at the epoch boundary, and if it fails, the gate stays active anyway.
SIMD-0690, merged on 26 October, adds a deliberate way for that replacement to fail: the runtime hashes the new program bytes and refuses to deploy them unless they match a hash shipped inside the validator. Agave implemented it on 29 October. On 2 November it queued the first migration that relies on it, p-ATA, a rewrite of the Associated Token Account program, and Firedancer followed on 3 November. We pulled the real p-ATA buffer from mainnet, hashed it the way both clients do, and replayed every way the activation can go through the loader Agave uses. The check works. It also depends on something no code enforces: the two clients agree only if the gates activate in the right order.
Activated is not the same as replaced
In the first bank of each epoch, Agave works out which feature gates have just activated, writes the activation slot into each gate’s account, and then runs the migrations tied to those gates. The p-ATA one is a single call, and its failure path is a log line:
if new_feature_activations.contains(&feature_set::replace_ata_with_p_ata::id())
&& let Err(e) = self.upgrade_loader_v2_program_with_loader_v3_program(
&feature_set::replace_ata_with_p_ata::SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID,
&feature_set::replace_ata_with_p_ata::PROGRAM_BUFFER,
feature_set::replace_ata_with_p_ata::BUILD_HASH,
true,
"replace_ata_with_p_ata",
)
{
warn!("Failed to replace SPL Associated Token Account with p-ATA buffer '{}': {e}", ...);
}Every check runs before anything is written, so a failure changes nothing except the gate. The gate is now active, so it is never a new activation again and the call never repeats. The proposal says so in one line: on a mismatch, “the feature is still marked as activated, but the program data remains unchanged.” A retry means a new gate in a new release. That leaves four states a migration can be in:
What the hash check adds
A migration copies a Loader v3 buffer account, uploaded ahead of time, into the target program’s address. Before SIMD-0690, the runtime checked that the buffer existed, was owned by the upgradeable loader and deserialized as a buffer, then deployed whatever it held. Agave’s implementation adds one step between those checks and the deploy:
let buffer = Self::new_checked(bank, buffer_address)?;
let data = buffer.buffer_account.data();
let elf = &data[UpgradeableLoaderState::size_of_buffer_metadata()..];
let end_offset = elf.iter().rposition(|&x| x != 0).map_or(0, |i| i + 1);
let hash = solana_sha256_hasher::hash(&elf[..end_offset]);
if hash != expected_hash {
return Err(CoreBpfMigrationError::BuildHashMismatch(hash, expected_hash));
}Skip the 37-byte buffer header, drop trailing zero bytes, take SHA-256, compare. Only then does the deploy step load and verify the ELF in the runtime’s program environment. That step deliberately still accepts SBPF v0 programs “for core program migrations”, which matters here because the p-ATA ELF is v0.
What came before is the reason for the change. When p-token replaced SPL Token in place on 13 May 2026, the function that did it took a program address, a buffer address, a flag and a log name. There was no hash. A Loader v3 buffer always has a write authority (the loader refuses to remove it), so until the activation slot, any change that authority made to the buffer, as long as it still loaded and verified, would have become SPL Token. Our p-token explainer covers that migration. Agave’s SIMD-0690 change also turns the expected hash from an optional parameter into a required one, so every future migration must name its bytes, while the check itself waits for its own gate, core_bpf_hash_validation.
The real buffer, hashed
SIMD-0567 merged on 1 November at 11:02 UTC. Twenty-seven minutes later, the p-ATA buffer appeared on mainnet: 37 transactions in slots 464,075,848 to 464,075,854, between 11:30:19 and 11:30:21 UTC. No transaction has touched it since. We read its bytes and hashed them both ways:
The buffer matches. The 15 zero bytes are worth a second look, because they are not padding. The buffer holds exactly one ELF file, and the file itself ends in zeros: its last section header has an alignment of 1 and an entry size of 0. Hash the ELF as a file and you get a different value. The expected hash is defined over the stripped bytes, the way solana-verify computes it, so solana-verify get-buffer-hash reproduces it straight from the chain.
The header also shows the buffer’s authority is still set, to 8pEc9HbS…7pkzf. Until the gate activates, whoever holds that key can rewrite or close the buffer. Before SIMD-0690 a rewrite would have been deployed. Now it can only make the activation fail.
Seven ways the activation can go
To see the success and failure paths without a cluster, we built a harness around the real buffer bytes. It runs Agave’s check copied verbatim, Firedancer’s check ported from C, and the deploy step through solana-sbpf 0.27.0, the loader version Agave pins, configured the way Agave’s migration path configures it. With the SIMD-0690 gate active, both clients give the same answer for every case:
Every failure leaves the gate active and ATA exactly as it was. Two of the rows are worth a closer look.
The hash pins the content but not the length. A buffer missing its last 15 bytes hashes identically, because those bytes are zeros. It is still a broken ELF: its section header table now runs past the end of the file, and the loader stops with “Offset or value is out of bounds.” The check and the loader cover each other. Neither alone would catch both a substituted program and a truncated one.
The authority problem has changed shape rather than disappeared. A changed letter in a string constant and a changed constant in an instruction both pass the loader and the verifier. Only the hash notices them. The buffer’s authority can no longer decide what ATA becomes, but closing or rewriting the buffer before activation still decides that ATA does not change, and a retry then needs a new gate.
When the order slips, the clients split
The two clients compute the same hash, but they decide differently whether to compute it. Agave hashes only if core_bpf_hash_validation is already active and otherwise skips the check. Firedancer always passes the p-ATA hash and checks whenever one is passed. Its feature map says why: it “assumes this feature gate is activated for all migrations”, because the SIMD-0690 gate will activate first. The same harness, with the SIMD-0690 gate inactive when p-ATA activates:
In the highlighted rows the clients finish the epoch’s first slot with different bytes at the ATA address, so they compute different bank hashes. Validators executing with Firedancer’s runtime would disagree with validators executing with Agave’s about the same block. Frankendancer, the hybrid, executes with Agave’s runtime, so the line falls between runtimes rather than client brands. Which software each validator runs is visible in gossip and in block footers.
None of this is a bug today. The buffer on mainnet matches, so the order makes no difference to the bytes that would be deployed. SIMD-0567 names SIMD-0690 as a dependency, and both clients document the order. Activating both in the same epoch is also safe: Agave installs the new feature set before it runs any migration, so the check is already on. But the order is a scheduling commitment, not a check in code. Anza’s activation guidelines fix a priority order and say exceptions will occur. The thing to watch is simple: gate FCsQnd4V…zp1u must be active no later than gate pata6uFa…ksx3. Neither gate account had been created on mainnet when this was written.
Telling the states apart
A failed migration leaves no transaction and no event on chain. The only direct record is a warning in each validator’s log. It can still be detected from three accounts, read in one call:
const FEATURE_OWNER = 'Feature111111111111111111111111111111111111';
const LOADER_V3 = 'BPFLoaderUpgradeab1e11111111111111111111111';
async function migrationState(rpc, gate, program, buffer) {
const res = await fetch(rpc, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getMultipleAccounts',
params: [[gate, program, buffer], { encoding: 'base64' }] }),
});
const [g, p, b] = (await res.json()).result.value;
// Feature account: owned by the Feature program, byte 0 = Some(activated_at)
const data = g?.owner === FEATURE_OWNER ? Buffer.from(g.data[0], 'base64') : null;
if (!data) return 'gate not submitted';
if (data[0] !== 1) return 'gate submitted, pending';
const slot = data.readBigUInt64LE(1);
if (p.owner === LOADER_V3) return `activated at ${slot}: migrated`;
return `activated at ${slot}: MIGRATION FAILED (buffer ${b ? 'still there' : 'gone'})`;
}
// p-token: activated at 419472000: migrated
// p-ATA: gate not submittedThe two comments are the script’s output against mainnet, for p-token and p-ATA. Checking the owner of the gate account matters: a gate’s address can hold lamports in an ordinary system account before anyone submits it, and reading those as feature data would report nonsense. Our upgrades tracker follows which protocol upgrades are live.
When p-ATA does migrate, the program account at ATokenGP…8knL changes owner from BPFLoader2111… to BPFLoaderUpgradeab1e…, gains a program-data account and has no upgrade authority. SIMD-0567 adds that ATA cannot be invoked during the migration slot, because Loader v3 programs take effect one slot after deployment. Tools that decide whether a program is upgradeable by its loader will see the change.
Two smaller things in the code
The log reads backwards. The mismatch error is built as BuildHashMismatch(hash, expected_hash), with the buffer’s hash first, but its message labels the first value “Expected”. For our altered-letter buffer, the warning prints:
Invalid build hash. Expected: 7c10bb1d…a3b147e730, Got: 4e008052…674e7f7d9eThe value after “Expected” is what the buffer holds, and the value after “Got” is what the validator expected. The labels have been this way since the error was introduced in April 2025. Anyone diagnosing a failed migration from logs should swap them.
A panic fixed in passing. The 2025 version of this check searched for the last non-zero byte across the whole account, header included. When the program bytes were all zero, as in a buffer created but never written, that search could land inside the 37-byte header and produce a slice that ends before it starts. Our harness reproduces the panic for a header with no authority, and for an authority whose last byte is zero. On a real cluster it would have stopped every validator at the activation slot. The SIMD-0690 commit fixed it under the line “strip trailing zeros from ELF bytes only”. It was never reachable on mainnet: the only migration that ever passed a hash before then was the slashing program’s, whose gate was never created on mainnet, and Agave removed it in June 2026. Firedancer’s version never had the problem.
What this does not show
The harness replays the checks and the loader on real bytes. It does not run a cluster through an epoch boundary. The deploy column uses Agave’s loader for both clients; Firedancer has its own loader, which we did not run. The altered buffers are our edits, not anything seen on chain. We checked the on-chain buffer against the published hash but did not rebuild p-ATA from source with solana-verify. We read mainnet only, not testnet or devnet.
Program Migrations: Questions People Actually Ask
Can a Solana feature gate be active while the program it was meant to replace is unchanged?
Yes. A core program migration runs once, inside the first bank of the epoch in which its feature gate activates. If any check fails, the runtime logs a warning and moves on: the feature account records its activation slot and the target program keeps its old bytes. Nothing retries, because the gate is never newly activated again. A second attempt needs a new feature gate in a new validator release.
What exactly does SIMD-0690 hash?
The program bytes in the source buffer account: everything after the 37-byte Loader v3 buffer header, with trailing zero bytes removed, hashed with SHA-256. The result must equal a hash compiled into the validator next to the feature gate. solana-verify computes executable hashes the same way, so solana-verify get-buffer-hash reproduces the value from the chain.
Is the p-ATA buffer on mainnet the one Agave expects?
Yes. The buffer at pataAAkF439TTjwRqmxRkTTEfZemRdoriXusez4PQ6S hashes to 4e008052…7f7d9e, the value in Agave’s replace_ata_with_p_ata gate, in Firedancer’s constant and in the SIMD-0567 text. It was created and written in 37 transactions on 1 November 2026 and has not been written since.
Do Agave and Firedancer run the hash check the same way?
They compute the same hash but differ on when they check it. Agave checks only once the SIMD-0690 gate, core_bpf_hash_validation, is active. Firedancer always checks the p-ATA hash and records that it assumes the SIMD-0690 gate activates first. If p-ATA activated before SIMD-0690 with an altered buffer, Agave would deploy the altered bytes, Firedancer would refuse them, and the two clients would compute different bank hashes.
How do I check whether a migration actually happened?
Read three accounts. The feature account is owned by the Feature program and holds an activation slot once active. The target program is owned by BPFLoaderUpgradeab1e… after a successful migration. The source buffer is consumed on success. An active gate with the program still under its old loader means the migration failed.
The one-line version
SIMD-0690 makes a core program migration deploy the reviewed bytes or nothing. A failure leaves the gate active and the program unchanged, and Agave and Firedancer agree on which happened only if the hash-check gate activates first.
The last in-place swap is in SPL Token was replaced in place, the prefunded-account rule p-ATA builds on is in SIMD-0312’s CreateAccountAllowPrefund, and how to tell which client built a block is in reading the block footer.
Sources: SIMD-0690 at commit eac20f29; SIMD-0567 at 93cdf03a; Agave #16077 and #16524 (source read at 9260869d); Firedancer #12680 (37c65819); the buffer and gate accounts read from mainnet with getAccountInfo, getMultipleAccounts and getSignaturesForAddress. Scenario results are from our own harness.