Skip to content

The Anatomy of a Transaction

The overview of this part made the claim that a transaction is not a single action but a batch — an ordered list of steps that the runtime treats as one indivisible unit of work. This page takes that claim apart and shows you the actual shape of the thing on the wire.

We are building toward the book’s throughline — how do you build a single global state machine that runs at hardware speed without falling apart? — and the transaction is the smallest complete answer to “what does one change to that state machine look like?” Everything the runtime needs to schedule, verify, and apply a change is packed into one small, self-describing envelope. By the end of this page you will be able to point at each field of that envelope and say what it is for and why it cannot be removed.

A transaction is an ordered array of instructions

Section titled “A transaction is an ordered array of instructions”

The unit a user submits to Solana is not an instruction — it is a transaction, and a transaction is an ordered array of instructions bundled with the signatures required to authorize them. An instruction is a single call to one program (“transfer 1 SOL”, “increment this counter”). A transaction is a list of those calls that must all happen together.

Transaction
├── instruction[0] → call program P0 on accounts [a, b] "approve"
├── instruction[1] → call program P1 on accounts [b, c, d] "swap"
└── instruction[2] → call program P2 on accounts [d] "log"
run in this exact order, and either ALL commit or NONE do

Two properties define it, and they are the whole reason the transaction exists as a container instead of letting users just fire instructions one at a time:

  1. Order is significant. The instructions execute strictly top to bottom. Instruction 1 sees the state that instruction 0 left behind. If you swap their order you get a different — often nonsensical — result.
  2. The whole thing is atomic. The transaction is the unit of all-or-nothing commitment. Either every instruction succeeds and the world moves forward, or the first failure aborts the entire transaction and the state machine is left exactly as it was before instruction 0 ever ran.

That second property is the load-bearing one, so the rest of this page keeps returning to it.

Open up the bytes of a transaction and there are exactly two top-level parts:

Transaction
├── signatures : [Signature] ← one per required signer, in order
└── message : Message ← the actual payload that got signed
├── header (how many signers / read-only accounts)
├── account_keys [Pubkey] ← the FULL list of accounts this tx touches
├── recent_blockhash (a recent block hash — covered on its own page)
└── instructions [CompiledInstruction]

The split is deliberate and worth internalizing:

  • The message is the content. It carries the recent blockhash, the complete list of every account the transaction will read or write, and the ordered instructions. The message is the thing that is serialized and signed.
  • The signatures are the authorization. Each signature is an ed25519 signature over the serialized message bytes. Because the signature covers the whole message, you cannot alter a single account, amount, or instruction after signing without invalidating every signature — the envelope is tamper-evident by construction.

Notice a subtlety that pays off enormously later: the message carries one flat account_keys list, and each instruction refers to its accounts by index into that list rather than repeating the 32-byte Pubkey. So an account used by three instructions appears once in account_keys, and each instruction stores a one-byte index instead of a 32-byte key.

account_keys: [ 0:alice 1:bob 2:pool 3:mint 4:program_swap ]
instruction[1] "swap":
program_id_index = 4 → account_keys[4] = program_swap
account_indexes = [1, 2, 3] → bob, pool, mint (in the order the program expects)
data = <opaque bytes the program decodes>

This indexed layout is not a cosmetic optimization. It is what lets the runtime read one deduplicated list of accounts, know the transaction’s entire footprint before executing a single instruction, and schedule it against other transactions — the parallelism story you met in the account model. The next two pages dissect the two halves in detail: the instruction (program, accounts, and access flags) and the message and recent blockhash.

“All or nothing” is easy to say and easy to get subtly wrong, so let us make it mechanical. You already built the exact mechanism in the account model, in the runtime’s clone-then-commit loop.

When the runtime processes a transaction it does not mutate the real account database directly. It:

  1. Clones the transaction’s declared accounts into a private working copy (a sandbox).
  2. Runs each instruction, in order, against that working copy.
  3. Commits the writable accounts back to the real database only if every instruction returned Ok.

If instruction N fails, the working copy is simply discarded. All the effects of instructions 0..N — every lamport moved, every byte written — lived only in that copy, so throwing it away rolls them back for free. No compensating logic, no “undo” code: the effects never touched the real state to begin with.

real DB ──clone──► working copy
├─ instr 0 ✓ (mutates the copy)
├─ instr 1 ✓ (mutates the copy)
├─ instr 2 ✗ ← returns Err
real DB ◄──discard── working copy ← copy thrown away; DB never changed
(transaction fails as a unit; 0 and 1 are rolled back)

This is the same discipline the solmini companion runtime uses: it gathers a per-transaction working set, runs the program, and only calls commit when the result is Ok:

// from solmini/src/runtime.rs — run one transaction, commit only on success
let mut working = self.working_set(tx); // clone declared accounts
let r = Self::run_tx(&self.programs, self.compute_per_tx, tx, &mut working);
if r.is_ok() {
self.commit(tx, &working); // write back ONLY on success
}
// if r is Err, `working` is dropped here → every mutation is discarded

The lesson from the account-model part transfers directly: the transfer program checks the balance before it debits, so an overdraft returns Err having changed nothing. Atomicity is what lets a program author reason about a multi-step change as if it were a single instant — an approve-then-swap either wholly happened or wholly did not, and there is no observable in-between state on chain.

Composition: why bundle instructions at all

Section titled “Composition: why bundle instructions at all”

If a transaction is just a list of instructions, why not send them one at a time? Because bundling buys you two things you cannot get otherwise: atomic composition and guaranteed ordering.

The canonical example is a token swap. A swap program needs permission to move tokens out of your wallet, which you grant with an approve (or delegate) instruction. Two instructions, and their relationship is the whole point:

Transaction "swap 100 USDC → SOL"
├── instruction[0] approve: let the swap program spend 100 USDC from my token account
└── instruction[1] swap: move 100 USDC in, send SOL out, using that approval

Bundling them into one transaction gives you a property that two separate transactions cannot:

  • Ordering guarantee. Instruction 1 is guaranteed to see the approval instruction 0 just wrote — because they run in the same working copy, back to back, with nothing able to interleave.
  • Atomicity guarantee. If the swap fails (price moved, slippage exceeded), the approval is rolled back too. You are never left in the dangerous intermediate state of “the swap program has standing permission to spend my tokens but the swap didn’t happen.”

Send those as two separate transactions and both guarantees vanish: another transaction could slip between them, the second could fail leaving a live approval dangling, or the two could even land in different slots. Composition inside one transaction is how Solana programs safely build compound operations — flash-loan patterns, atomic arbitrage across several pools, “create account then initialize it then fund it” — out of small single-purpose instructions. The instructions are the Lego bricks; the transaction is the glue that makes the assembly snap together as one move.

Ordering is significant — a concrete failure

Section titled “Ordering is significant — a concrete failure”

Because instructions share one working copy and run in sequence, reordering them changes meaning:

Correct: [create account] → [initialize account] → [deposit]
Reordered: [initialize account] → [create account] → [deposit]
└─ FAILS: initialize touches an account that doesn't exist yet

The runtime will not reorder your instructions to make them work — it runs them exactly as you wrote them. Ordering is a contract you author, and getting it wrong is a common early bug.

The size limit: everything must fit in one packet

Section titled “The size limit: everything must fit in one packet”

Here is the constraint that quietly shapes every downstream design decision on Solana: a transaction must fit in a single network packet. Solana caps a serialized transaction at 1232 bytes — the maximum payload that fits in one IPv6 UDP datagram once you subtract headers from the standard 1280-byte MTU.

1280 bytes IPv6 minimum MTU (the smallest packet the internet promises to deliver)
− 40 IPv6 header
− 8 UDP header
─────────
1232 bytes ← the entire transaction: signatures + message + all instructions + all account keys

Why a single packet? Because Solana ingests transactions over UDP and wants each one to arrive, be verified, and be scheduled without waiting to reassemble fragments across multiple packets — fragmentation is a source of loss, reordering, and denial-of-service amplification. One packet in, one transaction out keeps the ingestion path fast and predictable, which is exactly the “hardware speed” bet of the throughline.

But 1232 bytes is tight. Each ed25519 signature is 64 bytes; each account key is 32 bytes. A transaction touching, say, 30 accounts spends 30 × 32 = 960 bytes on keys alone before you have written a single instruction. This ceiling is why two later features in this part exist:

  • It is the reason you cannot naively reference a large set of accounts (a DeFi route across many pools), and thus the reason for versioned transactions and address lookup tables, which let a transaction point at accounts stored on chain by a short index instead of spending 32 bytes each inline.
  • It is why the compute budget is expressed as a tiny instruction — a few bytes of configuration, not a large declaration — because there simply is no room to spare.

Under the hood — how the runtime reads a transaction

Section titled “Under the hood — how the runtime reads a transaction”

When a validator receives the 1232-or-fewer bytes, it processes them in a fixed order, and each step maps onto a field you just met:

  1. Deserialize the bytes into signatures + message.
  2. Verify signatures — check each signature in signatures against the corresponding required signer in the message’s account list, over the exact message bytes. A single altered byte fails this.
  3. Check the recent blockhash is still within the valid window (its own page covers why this exists as a replay/expiry guard).
  4. Resolve the account footprint from account_keys (plus any lookup tables) — now the runtime knows every account the transaction can touch, and can schedule it against others.
  5. Load, clone, execute each instruction in order against the working copy.
  6. Commit or discard — commit writable accounts on full success, discard on any failure.

Every field of the transaction exists to serve one of those steps. There is no ceremony; the envelope is exactly as large as the runtime needs and no larger.

  • Why does it exist? A transaction exists to be the unit of atomic change to the global state machine — the smallest self-contained package that carries a change, proves who authorized it, and can be scheduled and applied (or rejected) as one indivisible move.
  • What problem does it solve? It solves partial-failure corruption. Without an atomic container, a multi-step operation could end up half-applied — money moved but the swap not recorded — leaving the ledger in an inconsistent, unrecoverable state. The transaction collapses every partial outcome into “fully applied” or “nothing happened.”
  • What are the trade-offs? Atomicity and composition come at the cost of a hard 1232-byte ceiling and a fixed account footprint declared up front. You gain safety and parallel schedulability; you give up the ability to reference unbounded accounts or make runtime-discovered decisions about which accounts to touch.
  • When should I avoid it? You do not avoid the transaction — it is the only way to change state — but you should avoid cramming unrelated work into one. Bundling independent actions that need not be atomic wastes the size budget and couples their fates: one failing rolls back the rest. Split work that has no atomicity or ordering relationship into separate transactions.
  • What breaks if I remove it? Remove atomicity and every multi-step operation becomes a minefield of half-completed states with no clean rollback. Remove the ordered-array structure and instructions could not build on each other’s effects, so composition (approve-then-swap, create-then-init) becomes impossible. The transaction is the seam that makes complex on-chain logic both safe and expressible.
  1. A transaction is described as “an ordered array of instructions.” Name the two properties that word ordered and the word array each contribute, and give a one-line example where reordering two instructions breaks the transaction.
  2. The top level of a transaction has exactly two parts. What are they, which one is signed, and why does signing that part make the whole transaction tamper-evident?
  3. Instruction 2 of a 3-instruction transaction returns an error after instructions 0 and 1 already mutated accounts. Walk through what the runtime does, and explain why instructions 0 and 1 leave no trace — without any explicit “undo” code.
  4. Give the approve-then-swap example and state the two distinct guarantees you get from bundling those two instructions into one transaction that you would lose if you sent them as two separate transactions.
  5. Solana caps a serialized transaction at 1232 bytes. Where does that number come from, why does the single-packet rule exist, and name one later feature in this part that exists specifically to work around this limit.
Show answers
  1. Array gives you a set of instructions bundled and committed as one atomic unit (all or none). Ordered gives you sequential execution where each instruction sees the previous one’s effects. Example: [initialize account] → [create account] fails because initialize runs against an account that create has not made yet; the correct order is create then initialize.
  2. Signatures and a message. The message is signed — it carries the account list, recent blockhash, and instructions. Because each signature is an ed25519 signature over the entire serialized message, altering any account, amount, or instruction after signing invalidates every signature, so the envelope cannot be tampered with undetected.
  3. The runtime clones the transaction’s declared accounts into a private working copy, runs instructions 0, 1, and 2 against that copy, and commits the writable accounts back to the real database only if all three return Ok. Since instruction 2 fails, the working copy is discarded. Instructions 0 and 1 mutated only the copy, which never touched the real database — so throwing the copy away rolls them back for free, no undo logic required.
  4. Approve lets a swap program spend your tokens; swap uses that approval to trade. Bundling gives: (a) an ordering guarantee — the swap sees the approval written by the immediately-preceding instruction, with nothing able to interleave; and (b) an atomicity guarantee — if the swap fails, the approval is rolled back too, so you are never left with a live approval and no swap. Two separate transactions lose both: something could interleave between them, and a failed second tx would leave a dangling approval.
  5. 1232 = the 1280-byte IPv6 minimum MTU minus the 40-byte IPv6 header and 8-byte UDP header. The single-packet rule exists so the validator can ingest, verify, and schedule each transaction over UDP without reassembling fragments — keeping ingestion fast and DoS-resistant. Address Lookup Tables / versioned transactions exist to work around it, letting a transaction reference on-chain accounts by a short index instead of spending 32 bytes per key inline.