What Is Inside a Solana Transaction

The wire format byte by byte — signatures, header, account keys, blockhash, instructions — and where your 1232 bytes actually go.

BoltTx Team··8 min read
solanatransactionserializationwire-formattransaction-landingrpc

Most Solana developers build transactions through an SDK and never see the bytes. That works until a transaction is too large, or an account is writable when it should not be, or a signature verifies against something you did not expect.

★Every one of those problems is legible if you know what the structure looks like.★

The Layout

┌──────────────────────────────────┐
│ signatures     ★64 bytes each★    │
├──────────────────────────────────┤
│ header                  3 bytes  │
├──────────────────────────────────┤
│ account keys   ★32 bytes each★    │
├──────────────────────────────────┤
│ recent blockhash       32 bytes  │
├──────────────────────────────────┤
│ instructions           variable  │
└──────────────────────────────────┘
        ★total ≤ 1232 bytes★

Everything after the signatures is the message — and that is what signatures cover. Change one byte of it and every signature already collected becomes invalid.

The Header Is Three Bytes That Decide Everything

byte 0: numRequiredSignatures
byte 1: numReadonlySignedAccounts
byte 2: numReadonlyUnsignedAccounts

There are no per-account flags in the wire format. ★The flags you set in JavaScript become positions in a sorted list, and these three counts define where the boundaries fall.★

accountKeys = [
  ★writable signers★      ← indices 0 .. (numRequiredSignatures - numReadonlySigned - 1)
  readonly signers        ← next numReadonlySignedAccounts
  ★writable non-signers★  ← the middle
  readonly non-signers    ← last numReadonlyUnsignedAccounts
]

This is why account order is not cosmetic, and why the fee payer is always index 0 — it is the first writable signer by construction.

const message = tx.message;
console.log("required signatures:", message.header.numRequiredSignatures);
message.staticAccountKeys.forEach((k, i) => {
  console.log(i, k.toBase58(),
    message.isAccountSigner(i) ? "signer" : "",
    message.isAccountWritable(i) ? "writable" : "readonly");
});

Instructions Reference Accounts by Index

An instruction does not carry account keys. It carries indices into the account list:

programIdIndex   1 byte    ← which account is the program
accountIndices   1 byte each
data             variable

★This is why duplicate accounts are free.★ Referencing the same account across five instructions costs 32 bytes once in the account list, plus one index byte per reference.

It is also why order is part of the interface. The program receives accounts positionally, so a wrong index silently points it at a different account — the failure mode that produces a successful transaction doing the wrong thing.

If your transaction is well-formed and still lands late, a free BoltTx key is one line to test the submission path.

Where the Bytes Actually Go

For a typical two-hop swap:

Component Bytes Share
1 signature 64 5%
Header 3
★30 account keys★ ★960★ ★78%★
Blockhash 32 3%
Instructions ~150 12%
Total ~1209 ★at the ceiling★

★Account keys dominate, which is the single most useful fact in this article.★ When a transaction is too large, the answer is almost never "shorten the instruction data" — it is to reduce or compress account references.

const raw = tx.serialize();
console.log(`${raw.length} / 1232`);
console.log("accounts:", message.staticAccountKeys.length,
            "=", message.staticAccountKeys.length * 32, "bytes");

Run this before assuming the problem is elsewhere. If account bytes exceed roughly 800, AddressLookupTableProgram is the fix; if instruction data dominates, they will not help at all.

What Versioned Transactions Change

A v0 transaction adds a section after the instructions:

┌──────────────────────────────────┐
│ ... same as legacy ...           │
├──────────────────────────────────┤
│ ★address table lookups★           │
│   table address      32 bytes    │
│   writable indices   ★1 byte each★│
│   readonly indices   ★1 byte each★│
└──────────────────────────────────┘

★Accounts referenced through a lookup table cost 1 byte instead of 32.★ Thirty accounts inline is 960 bytes; through a table it is roughly 30 bytes plus the 32-byte table address.

The version is signalled by a prefix byte on the wire, which is why a v0 transaction read back without maxSupportedTransactionVersion: 0 returns null — the RPC will not hand you a format you did not say you understood.

Signatures Cover the Message, Nothing Else

const messageBytes = tx.message.serialize();
// ★Each signature is over exactly these bytes.★

Three consequences worth holding onto:

Identical bytes produce an identical signature. A signature is included at most once, which is why resending the same serialized transaction is safe and why rebuilding is not.

Any modification invalidates every signature. Adding a setComputeUnitLimit instruction after a user signed breaks their signature — and the error names their key, not your change.

The blockhash is inside the signed message. You cannot refresh it without re-signing, which is the entire reason durable nonces exist.

Reading a Transaction Off the Chain

const tx = await connection.getTransaction(sig, {
  maxSupportedTransactionVersion: 0,
});

console.log("fee:", tx?.meta?.fee);
console.log("compute:", tx?.meta?.computeUnitsConsumed);
console.log("err:", tx?.meta?.err);
tx?.meta?.logMessages?.forEach((l) => console.log(l));

computeUnitsConsumed is the field to compare against the limit you requested.★ A large gap means you are paying priority fees against a figure well above real usage — the compute limit is a fee decision, and this is where you see it.

What Landing Looks Like

Real transactions through our delivery nodes: median confirmation 336ms — under one slot.

★Structure decides whether a transaction can be sent; routing decides when it lands.★ A well-formed transaction still has to reach a block producer, and no amount of byte-level tuning changes that half.

Where BoltTx Fits

We transmit the bytes you signed, unchanged.

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. ★We never modify transaction contents — which is not only a policy but a structural necessity, since any change would invalidate your signature.★

You sign locally. We never hold funds and never sign. The tip travels inside the transaction, paid on chain from your own wallet, and reverts with the transaction if it fails, because that is how Solana handles atomic transactions. You 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 is the structure of a Solana transaction? Signatures, then a message containing a three-byte header, the account key list, the recent blockhash, and the instructions. Signatures cover the message and nothing else.

Why is the Solana transaction size limit 1232 bytes? It derives from the network packet size after protocol overhead. In practice account keys at 32 bytes each consume most of it, so the limit binds on account count rather than instruction data.

What does the transaction header contain? Three counts: required signatures, readonly signed accounts, and readonly unsigned accounts. They define the boundaries in the sorted account list, since there are no per-account flags on the wire.

Why is the fee payer always index 0? Because the account list is sorted with writable signers first, and the fee payer is a writable signer by definition. Its position is a consequence of the ordering rules.

How do instructions reference accounts? By index into the transaction's account list, one byte per reference. This is why duplicate accounts cost 32 bytes only once and why account order is part of the program's interface.

What takes up the most space in a transaction? Account keys, at 32 bytes each. For a multi-hop swap they routinely account for around three quarters of the total, which is why lookup tables target them specifically.

How do I check my transaction size? Call serialize() and read the length, then compare against staticAccountKeys.length * 32. If account bytes dominate, lookup tables help; if instruction data dominates, they do not.

How do versioned transactions reduce size? They add a lookup section where accounts are referenced by a one-byte index into an on-chain table instead of a full 32-byte key, plus 32 bytes for the table address itself.

Why does getTransaction return null for my transaction? Most likely a missing maxSupportedTransactionVersion: 0. The RPC will not return a versioned transaction to a caller that did not declare it can parse one.

What exactly does a signature sign? The serialized message — header, account keys, blockhash, and instructions. Not the signatures themselves, which is what allows several parties to sign the same message independently.

Why does adding an instruction break existing signatures? Because instructions are part of the signed message. Any change produces different bytes, so previously collected signatures no longer verify against what you are submitting.

Can I change the blockhash without re-signing? No. The blockhash is inside the signed message, which is precisely why durable nonces exist for flows where signatures cannot be regenerated on demand.

How many signatures can a transaction have? As many as fit within the size limit at 64 bytes each. Signatures are rarely the binding constraint compared with account keys.

What is computeUnitsConsumed used for? Comparing actual usage against the limit you requested. A large gap means you are paying priority fees against an inflated figure, since the fee is price multiplied by requested limit.

Is transaction data encrypted? No. Transaction contents are visible once broadcast, which is why a submission path that does not expose transactions to a public mempool before landing matters for trading.

How do I decode a raw transaction? Transaction.from for legacy or VersionedTransaction.deserialize for v0, then inspect the message. It is the fastest way to confirm what you actually built versus what you intended.

Back to all posts