The Message and the Recent Blockhash
The previous page, The Instruction: Programs, Accounts, and Access Flags, zoomed all the way in on a single instruction: a program id, a list of accounts, and an opaque data blob. But an instruction on its own is not something a validator will run. It has no signature, no ordering, no protection against being replayed tomorrow. Those guarantees come from the layer that wraps the instructions: the message.
This page walks the message — the exact bytes a signer signs — from the outside in. We will see why the message is laid out as a deduplicated array of accounts with instructions that reference accounts by index, why the signature covers the whole thing, and why one small field near the top, the recent blockhash, quietly solves two problems at once: transaction expiry and replay protection. That last field is the reason Solana needs no per-account nonce, and it is a direct consequence of the book’s throughline — a single global state machine running at hardware speed has a clock built into its ledger, and the message borrows it.
What the message is
Section titled “What the message is”A Solana transaction has exactly two top-level parts:
Transaction ├── signatures : [ [64 bytes], [64 bytes], … ] ← NOT signed (they ARE the signature) └── message : the bytes that every signature above coversThe signatures sit outside the signed region — they cannot sign themselves. Everything else lives in the message, and the message is what each signer’s ed25519 key signs. If you can serialize the message to bytes, you can verify the transaction; nothing else is needed.
The message has four regions, in order on the wire:
message ├── header : 3 bytes (how many signers, how many read-only signers, │ how many read-only non-signers) ├── account_keys : compact-array of 32-byte pubkeys, DEDUPLICATED & ORDERED ├── recent_blockhash: 32 bytes (a blockhash from the last ~150 blocks) └── instructions : compact-array of CompiledInstructionEvery one of those regions exists to answer a question a validator must answer before it runs a single byte of program code: who signed this? which accounts does it touch, and may each be written? is this transaction still fresh? and what should actually run?
The header and the account array
Section titled “The header and the account array”The most important design choice in the message is that accounts are named once, in a single flat array, and everything else refers to them by position. Instead of each instruction carrying full 32-byte addresses, the message hoists every address any instruction mentions into one deduplicated account_keys array, then each instruction points into that array with a one-byte (u8) index.
The array is not in arbitrary order. It is sorted into four contiguous groups, and the 3-byte header tells the runtime where the boundaries are:
account_keys (example: 5 keys, header = [2, 0, 1]) ┌──────────────────────────────────────────────────────────┐ │ index 0 : writable signer ┐ │ │ index 1 : writable signer ┘ num_required_signatures=2│ │ index 2 : writable non-signer ← fee payer is always [0] │ │ index 3 : writable non-signer │ │ index 4 : read-only non-signer ← num_readonly_unsigned=1 │ └──────────────────────────────────────────────────────────┘ header = [ num_required_signatures, num_readonly_signed_accounts, num_readonly_unsigned_accounts ]From three small numbers, the runtime derives the signer/writable classification of every key by its position:
- The first
num_required_signatureskeys must be signed. Of those, the lastnum_readonly_signed_accountsare read-only signers; the rest are writable signers. - The remaining keys are non-signers. Of those, the last
num_readonly_unsigned_accountsare read-only; the rest are writable. - Index 0 is always the fee payer — a writable signer by construction.
This is the same “shared XOR mutable” discipline the companion runtime relies on: the writable/read-only split is not advisory, it is the declaration the scheduler uses to decide which transactions can run in parallel. (See The Instruction: Programs, Accounts, and Access Flags for how a single account can be writable in the message even if one instruction only reads it — the message-level flag is the maximum privilege, unioned across all instructions.)
Compiled instructions
Section titled “Compiled instructions”Inside the message, an instruction is stored in its compiled form — the addresses have already been replaced by indexes:
CompiledInstruction ├── program_id_index : u8 ← index into account_keys ├── accounts : [u8] ← indexes into account_keys, in the order the program expects └── data : [u8] ← opaque instruction dataThere are no addresses left in an instruction at all — only positions. A validator reads program_id_index, looks up the program in the account array, gathers the accounts named by the accounts indexes (already carrying their writable/signer flags from their position), and hands the whole set to the program. The mapping in the companion crate is the same idea, uncompiled: Transaction { program, accounts: Vec<AccountMeta>, data } — the AccountMeta list is exactly what a CompiledInstruction’s indexes resolve back to.
Signing covers the whole message
Section titled “Signing covers the whole message”Because the signature is computed over the serialized message bytes, it commits to everything: the header, every account key, the blockhash, and every compiled instruction including its data. Change any of it and the signature no longer verifies.
signature = ed25519_sign( signer_private_key, serialize(message) )
flip ONE bit anywhere in the message ─► serialize(message) changes ─► signature no longer verifies ─► validator rejects the transactionThis is what makes a transaction tamper-evident end to end. A relay, an RPC node, or a malicious peer cannot:
- redirect a transfer by swapping a destination address in
account_keys— the array is signed; - escalate privileges by flipping a read-only account to writable — the header is signed, and position determines the flag;
- inject or reorder instructions — the instruction array is signed;
- replay it in a different context — the blockhash is signed (next section).
The only field not under the signature is the signature array itself. Everything a program could act on is frozen the moment the signer signs. This is the same integrity property Bitcoin gets from signing its inputs and outputs; Solana simply signs a different, index-compressed structure.
The recent blockhash: a clock in the transaction
Section titled “The recent blockhash: a clock in the transaction”Now the field that does the most work for its 32 bytes. Every message must carry a recent blockhash — the hash of a block produced within roughly the last 150 blocks. It is a value the signer must fetch from the cluster just before signing. That single requirement gives Solana two properties that other chains buy separately.
Job one: expiry (a bounded lifetime)
Section titled “Job one: expiry (a bounded lifetime)”A validator will only accept a transaction whose blockhash is still within its recent window. The runtime keeps a small set of the last ~150 blockhashes; when a transaction arrives, it checks whether the transaction’s blockhash is in that set.
blocks: … B-152 B-151 B-150 ─────────────── B-1 B(now) └──────── valid window (~150) ───────┘ ▲ │ a transaction quoting B-152's hash is now EXPIRED │ → the runtime has forgotten it → rejected outrightAt roughly 400 ms per slot, 150 blocks is on the order of a minute of wall-clock validity (often quoted as “a minute or two” depending on skipped slots). A transaction that is not included in a block before its blockhash falls out of the window becomes permanently un-includable. It does not sit in a mempool forever; it simply dies.
Job two: replay protection (without nonces)
Section titled “Job two: replay protection (without nonces)”Here is the elegant part. Because the blockhash is inside the signed message, it also serves as a near-unique tag for the transaction. A validator records the (blockhash, signature) pairs it has already processed within the valid window. If the exact same signed transaction arrives again, its signature is already on record for that blockhash — so it is rejected as a duplicate.
And once the blockhash expires, the transaction can never be resubmitted either, because the blockhash is no longer in the recent set. So a captured transaction has, at most, the same ~60-second window to be replayed — during which the validator’s dedup set catches it anyway.
Attacker captures a signed transfer and re-broadcasts it:
within the window ─► same signature already seen for this blockhash ─► rejected as a duplicate
after the window ─► blockhash no longer recent ─► rejected as expired
Either way: the money moves exactly once.There is no monotonic counter stored per account. The freshness of the blockhash is the anti-replay mechanism.
Under the hood — contrast with Ethereum’s account nonce
Section titled “Under the hood — contrast with Ethereum’s account nonce”Earlier chains solved replay with an explicit, per-account counter. On Ethereum, every account has a nonce: transaction number 0, then 1, then 2, and so on. The nonce is part of the signed transaction, and a validator accepts your nonce-n transaction only after it has processed your nonce-n-1. Replaying an old transaction fails because its nonce is already spent.
That design has real costs that Solana’s blockhash sidesteps:
Ethereum nonce Solana recent blockhash ────────────── ─────────────────────── per-account monotonic counter one shared cluster value strict order: n must follow n-1 no ordering between a signer's txs a stuck low nonce blocks all later txs are independent; one failing txs from that account ("nonce gap") does not wedge the others never expires (can sit pending expires in ~150 blocks; a for hours/days) non-landed tx just dies replay-safe because counter advances replay-safe because blockhash is fresh + dedup within the windowThe trade-off is deliberate. Ethereum’s nonce gives you guaranteed ordering and indefinite pending — send-and-forget, and the transaction will eventually land in the right sequence. Solana gives up both to gain independence and self-cleaning liveness: a signer’s transactions do not serialize behind each other, and the network never has to remember a transaction longer than a minute. For a state machine chasing hardware-speed throughput, not maintaining a per-account ordered queue — and not keeping an unbounded mempool — is exactly the kind of simplification that keeps the pipeline fast.
(Solana does offer an opt-in durable nonce for the rare case where you need a transaction that never expires — for example an offline multisig that may take days to gather signatures. It replaces the recent blockhash with a value stored in a dedicated nonce account, advanced explicitly. It is the exception that proves the rule: you only reach for it when the ~60-second default is genuinely too short.)
The consequence: expiry is a UX contract
Section titled “The consequence: expiry is a UX contract”Because a transaction expires, the failure mode is different from what an Ethereum developer expects, and clients must be built for it:
- A transaction that does not land in time simply fails. It is not stuck, not pending, not retried by the network. It is gone.
- Recovery means re-signing, not re-broadcasting. To try again you must fetch a new recent blockhash, rebuild the message, and sign again — producing a genuinely new transaction with a new signature.
- Wallets and bots therefore run a fetch-blockhash → sign → send → confirm-or-expire → maybe re-sign loop, often fetching a blockhash as late as possible to maximize the window.
This is a real liveness/UX trade-off, made on purpose. The upside is a network with no ever-growing backlog of ancient pending transactions and no per-account head-of-line blocking. The downside is that “I signed it, so it will eventually happen” is false on Solana — a truth that has surprised many developers porting from EVM chains.
The architect’s lens
Section titled “The architect’s lens”The recent blockhash is the small mechanism that ties a transaction to a point in the ledger’s history — worth examining through the five questions.
- Why does it exist? So that a signed transaction is anchored to a moment in the chain’s timeline, giving it a bounded lifetime and a natural anti-replay tag without any per-account bookkeeping.
- What problem does it solve? Two at once: expiry (a transaction that does not land in ~150 blocks is rejected, so no unbounded mempool) and replay protection (a captured transaction cannot be re-applied outside its short window, and is de-duplicated inside it).
- What are the trade-offs? You gain independence between a signer’s transactions and a self-cleaning network; you give up guaranteed ordering and “send once, land eventually.” Clients must handle expiry by re-signing.
- When should I avoid it? When you genuinely need a transaction that survives for hours or days — offline signing, slow multisigs — reach for a durable nonce account instead of the default recent blockhash.
- What breaks if I remove it? Everything downstream of freshness. With no blockhash, a signed transaction would be replayable forever, and the network would have to remember every transaction it ever saw to prevent it — reintroducing exactly the per-account nonce/mempool machinery Solana was built to avoid.
Check your understanding
Section titled “Check your understanding”- The message has four regions in wire order. Name them, and say which single field lets a validator derive the signer/writable status of every account key.
- Why does the message store account addresses in one deduplicated array and have instructions reference them by
u8index, rather than repeating full addresses in each instruction? - The signature is computed over the serialized message. Explain concretely why this stops a relay from redirecting a transfer to a different destination account.
- Describe the two independent jobs the recent blockhash performs, and explain how it provides replay protection without any per-account nonce.
- A user signs a transaction but it does not land within about a minute. What happens to it, and what must a client do to try again? Contrast this with the behavior of an Ethereum transaction with a valid nonce.
Show answers
- In order: the header (3 counts), the account_keys array (deduplicated, ordered), the recent_blockhash, and the compiled instructions. The 3-byte header —
num_required_signatures,num_readonly_signed_accounts,num_readonly_unsigned_accounts— lets the runtime derive every key’s signer/writable status from its position in the array (index 0 is always the writable-signer fee payer). - To save space. A pubkey is 32 bytes and an index is 1 byte; a transaction that touches the same accounts across several instructions would otherwise repeat 32-byte keys many times, and the whole transaction must fit in a single ~1232-byte packet. Hoisting keys into one array and referencing by index is what lets a transaction carry multiple instructions at all.
- The destination address lives in the signed
account_keysarray. Flipping it changes the serialized message, so the ed25519 signature (computed over those exact bytes) no longer verifies, and the validator rejects the transaction. The relay would have to forge the signer’s signature, which it cannot. - Expiry: a validator only accepts a blockhash from roughly the last 150 blocks (~60 s), so a transaction that does not land in time is rejected and forgotten. Replay protection: because the blockhash is inside the signed message, the (blockhash, signature) pair is a near-unique tag; within the window the validator de-duplicates repeats, and after the window the blockhash is no longer recent, so a captured transaction cannot be re-applied either way. No monotonic per-account counter is needed — freshness does the work.
- It simply expires and fails — it is not pending or auto-retried; the network forgets it. To try again the client must fetch a new recent blockhash, rebuild the message, and re-sign, producing a brand-new transaction with a new signature. An Ethereum transaction with a valid nonce never expires this way: it can sit pending for a long time and will eventually land in nonce order, so “signed it, it’ll happen eventually” is true on Ethereum but false on Solana.