RPC — Solana × Agave

When Solana Transaction Simulation Disagrees With Execution

10 min read
SolanaRPCAgaveTransactions

simulateTransaction is how a wallet or a bot asks a node what a transaction will do. When the transaction succeeds, the answer is exact: on a local Agave 4.2.1 validator, the simulated balances and account data matched what landed, byte for byte. When it fails, one field of the answer goes blank, and the transaction can still land, pay its fee and use up its durable nonce.

Agave #16616, opened on 2 November, fills part of that gap inside the bank. It is the fifth pull request for the same one-line fix since the issue was filed in November 2025. We sent ten transactions through simulation and then to the chain, to see which parts of a simulation predict what lands, and which callers the change would reach.


Three ways a transaction fails

A Solana transaction can fail at three points, and the point decides what reaches the chain:

textWhere it fails, and what lands
Fails validation     no fee payer account, unknown blockhash or nonce
                     -> never lands, nothing is charged

Fails loading        program missing or not executable, data limit hit
("fee-only")         -> lands as failed: fee charged, nonce advanced,
                        no instruction runs

Fails executing      an instruction returns an error
                     -> lands as failed: fee charged, nonce advanced,
                        every other change discarded

The last two are committed on purpose: the leader did work for them, so the fee payer pays. What gets written is a rollback state the runtime captures before anything runs: the fee payer minus the fee and, for a durable-nonce transaction, the nonce account advanced to its next value.


Ten transactions, simulated and then sent

Each transaction was signed and simulated with signature checks on and with the accounts option asking for the state of the fee payer, the destination and, where one was used, the nonce account. Then it was sent normally and, when preflight rejected it, sent again with skipPreflight. Then we read the landed transaction and the accounts.

textsimulateTransaction vs the landed transaction, Agave 4.2.1
#   transaction                     simulated error                     fee    landed
1   transfer                        none                                5000   yes, 5000
2   2 transfers, 2nd too large      InstructionError [1, Custom 1]      5000   yes, 5000
3   program does not exist          ProgramAccountNotFound              5000   yes, 5000
4   "program" is a wallet           InvalidProgramForExecution          5000   yes, 5000
5   loaded-data limit of 1 byte     MaxLoadedAccountsDataSizeExceeded   5000   yes, 5000
6   nonce + missing program         ProgramAccountNotFound              5000   yes, 5000
7   nonce + 2nd transfer too large  InstructionError [2, Custom 1]      5000   yes, 5000
8   nonce + transfer                none                                5000   yes, 5000
9   fee payer has no account        AccountNotFound                     null   no
10  nonce value not current         BlockhashNotFound                   null   no
Failing transactions rejected by preflight8 of 8
Landed anyway with skipPreflight6 of 8
Fee charged on each of those, lamports5,000
Simulated postBalances equal to landed8 of 8
accounts returned for a failurenull

The simulation got every number right. Its fee matched the charged fee in all eight transactions that landed, its postBalances matched the landed ones exactly, including the 5,000-lamport debit on each failure, and the two transactions it gave a null fee never landed. The fee-only failures, 3 to 6, show 0 compute units and no logs in the simulation and on chain alike: no instruction ran.

Preflight kept all eight failures off the chain. Without it, the six with a fee landed and paid it, as the simulation’s own fee and balances said they would. The two without one were dropped without an error.


What the accounts field hides

The accounts option asks for the state of chosen accounts after the transaction. For a failing transaction Agave answers null for every address, whatever the failure. Most of the time postBalances covers for it, because a failure changes nothing but the fee payer’s lamports. The durable nonce is the exception:

Nonce account balance, simulated and on chainunchanged
Nonce account data, simulatednull
Nonce account data on chain, cases 6 and 7advanced

In cases 6 and 7 the transaction failed and the nonce account’s stored value still changed on chain. Its balance did not, so postBalances could not show it, and accounts returned null. From the simulation alone, the only sign that a failing nonce transaction will consume its nonce is a non-null fee.

That matters for pre-signed durable-nonce transactions. Once the nonce advances, every other transaction signed against the old value fails validation the way case 10 did, with “Blockhash not found”, and never lands. A pre-signed transaction that fails in simulation is not harmless while anyone holding it can still broadcast it.


Inside the bank: one field, three meanings

JSON-RPC builds its answer from Bank::simulate_transaction, which returns a post_simulation_accounts list next to the result. What goes in that list depends on how the transaction ended:

textpost_simulation_accounts on Agave master, 2 November
Executed, succeeded   accounts after execution       = what lands
Executed, failed      accounts where execution       ≠ what lands
                      stopped, earlier instructions
                      included
Fee-only              empty                          ≠ what lands
Fails validation      empty                          = nothing lands

The failed-execution row exists because the runtime hands back the accounts exactly as execution left them. Nothing is undone when an instruction fails, because on chain the whole set is replaced by the rollback state. In case 2 that list would show the destination 1,000,000 lamports richer, from the transfer in instruction 0, although on chain the destination never changed. We read this from the source; JSON-RPC hides it behind the null.

The fee-only row is the one #16616 changes, and the fix is one line: return the transaction’s rollback accounts instead of an empty list. A fee-only simulation would then report the fee payer after the fee and, with a nonce, the advanced nonce account: exactly what the SVM commits for it.

After the change the list would mean “what lands” for a fee-only failure and “where execution stopped” for an executed one, and a caller would have to infer which from the kind of error. The struct documents neither. The issue thread already asks for the second meaning: a commenter wants the writable accounts touched up to and including the failing instruction.


Five pull requests, one line

Issue #9231 filedNov 22, 2025
#9234 and #9237 closed as staleJun 8, 2026
#13076 closed as staleSep 22, 2026
#15841 opened, still openOct 7, 2026
#16616 openedNov 2, 2026

The issue asks for one thing: write the rollback accounts into the simulation result. #9234 and #9237 arrived the next day, #13076 in June, #15841 in October. None got a review, and a bot closed the first three as stale. #15841 drew one outside comment, from someone who ran it on Linux: its new test passed, but the existing test_failed_simulation_load_error failed, because it still expected an empty list. #16616 makes the one-line change and updates that test without adding one, and its description says the tests were not run.

The updated test is weaker than it looks. It runs in a bank with zero fees, where the fee payer after the fee is identical to the fee payer before it, so its assertion would pass whether the bank returned the rollback account or the untouched one. #15841’s added test sets a 5,000-lamport fee and checks that the returned balance is 5,000 lower, which is the property the fix exists for.


Who would see the change

textCallers of Bank::simulate_transaction, with #16616
simulateTransaction (JSON-RPC)     no change: accounts is null on any error
sendTransaction preflight          no change: the list is discarded
BanksClient, solana-program-test   no change: the banks server discards it
Rust code calling the bank         changes: fee-only returns the fee payer,
  directly (forks, custom RPC)       and the nonce account if one is used
Agave's own test                   changes: updated in the pull request

For JSON-RPC clients, which covers wallets, bots and explorers, nothing changes: the RPC layer drops the list in preflight and nulls it in simulateTransaction, and the banks server behind solana-program-test drops it too. #13076’s author made the same point in that pull request’s description. The callers who gain are Rust programs that link Agave’s runtime and call the bank themselves, such as validator forks with their own endpoints and simulators built on the runtime crate.

Forks keep their own copies of this code, and #16616 changes only Agave’s. Jito’s bundle simulation has its own fee-only branch and runs with drop_on_failure, which in its SVM turns a load failure into a plain error before that branch is reached. That matches how a bundle lands: whole, or not at all.


How to read a simulation

textWhat each part of a simulation predicts
err null                    lands if sent in time; accounts = post-state
err set, fee null           will not land; nothing is charged
err set, fee set            lands as failed if it gets past preflight:
                            fee charged, durable nonce advanced,
                            all other changes discarded
err set, fee set,           failed while loading; no instruction ran
  unitsConsumed 0
postBalances                the balances that land, failures included
accounts                    null on every error

This holds for one node at one moment. Accounts can change between simulation and landing, and a program can detect that it is being simulated and behave differently. If all you need is the fee, getFeeForMessage gives it without simulating, but says nothing about whether the transaction lands.


What this does not show

We ran Agave 4.2.1’s test validator: one node, processed commitment, no priority fees, so every fee is 5,000 lamports. The branches described here are the same on Agave master as of 2 November, which we read but did not run; the bank-level table and the case-2 example come from the source, not from a patched build. Newer Agave also has leader-side rules for no-op and bail-out transactions that 4.2.1 does not have, and this run does not exercise them.


Simulation vs Execution: Questions People Actually Ask

If simulateTransaction returns an error, can the transaction still cost a fee?

Yes. If the simulation returns an error and a non-null fee, the transaction fails while loading accounts or while executing, and a leader still commits it: the fee payer pays the fee and a durable nonce advances. In our test six such transactions, sent with skipPreflight, all landed and paid 5,000 lamports. If the fee is null, the transaction fails validation and does not land.

Why does simulateTransaction return null for every account when the transaction fails?

Agave fills the accounts option only for a successful simulation; for any error it returns null for each requested address. postBalances still shows the fee payer’s balance after the fee. A durable nonce advance changes the nonce account’s data, not its balance, so no field of a failed simulation shows it.

Does a failed Solana transaction advance its durable nonce?

Yes, if it lands. A transaction that fails while loading accounts or while executing is committed with the fee charged and the nonce advanced, so every other transaction signed against that nonce value stops being valid. One that fails validation, for example because its nonce value is not current, never lands and changes nothing.

What is a fee-only transaction on Solana?

A transaction whose fee payer and blockhash are valid but which fails while its accounts are loaded: a program account that does not exist or is not executable, or more account data than its loaded-data limit allows. It lands as failed with the fee charged and the nonce advanced, but no instruction runs, so it shows 0 compute units and no logs.

Will Agave #16616 change simulateTransaction responses?

No. JSON-RPC returns null accounts on any error without reading the field the pull request fills, sendTransaction’s preflight discards that field, and so does the banks server behind solana-program-test. It changes what Bank::simulate_transaction returns to Rust code that calls the bank directly. On 2 November 2026 it was open and unreviewed.


The one-line version

A failed simulation that still returns a fee describes a transaction that will land and pay if it gets past preflight, and the response says so in its fee and postBalances, never in accounts; Agave #16616 would make the bank report what a fee-only failure writes, but no JSON-RPC response changes.

Sources: Agave #16616, the earlier attempts #9234, #9237, #13076 and #15841, and issue #9231; Agave master at 8da849e9 for the bank, RPC, SVM and banks-server code; Jito-Solana v4.4.0-jito for bundle simulation. Measurements are from our own client against a local solana-test-validator 4.2.1.

xroot.devOn-chain research, published in full
𝕏 @xrootdev
← Back to all notes