Soldev

Solana transactions

Last updated:

Transactions consist of 2 components:

  1. A message
  2. A list of signatures

A message is just a [recent_blockhash + list_of_accounts + list_of_instructions].

The most basic operating unit on Solana is an instruction.

Each instruction uses a certain amount of compute units (CU). By default an instruction can use up to 200k compute units, with a max limit that can be requested of up to 1.4 million.

The basic flow of a transaction is:

  1. The user or app submits a transaction with one or more instructions to a node that accepts RPC requests.
  2. The transaction is then forwarded, according to the leader schedule, to the next leader.
  3. The leader validates the transaction, processes it, and includes it in a new block. This block is then broadcasted to all other validators who also validate and process the transaction.
  4. During the transaction processing, instructions are executed by the previously deployed programs, and relevant accounts are modified accordingly.

When you read a transaction you will see a bunch of accounts listed at the top level. These are the accounts required for the entire transaction.

Then you will have a list of instructions, and in each instruction you will have a subset of the accounts, perhaps reordered that pertain to that specific instruction. In addition, there is the Data that is passed to the instruction itself, usually some bytes of encoded Borsh.

An instruction contains:

{
  program_id: ,
  accounts: [pubkey],
  instruction_data: [byte]
}

Programs process the instruction data to determine what actions to take on which accounts that have been provided in the instruction.

A message can only be a maximum of 1232 bytes.

Transactions are atomic: if a single instruction fails, the whole transaction will fail and have no effect on the global state.

You must forward-declare every account you intend to read from or write to, which may include:

Accounts are passed in as an array so you need to get the order right. Usually this is abstracted away in the client to make this easier and less error prone.

Writing to an account requires authority, which is provided by signing the full message with the private key.

There are also non-signed accounts that can be writable, for instance if owned by the program your instruction calls.

Transactions are uniquely identified by its first message signature.

Finally a message contains a blockhash, acting the same as Ethereum nonces. This blockhash acts as a TTL requiring it be no more than 150 blocks old, which works out to about 1 minute 19 seconds (currently).

There is a special nonce mode to enable airgapped signing.

Most of the time we want to implement multiple different instruction calls in the same program. For example the SPL Token Program implements:

Each of these requires different call arguments. A common pattern is to encode a discriminator in the first byte of the instruction_data argument in the entrypoint function.

Then at the beginning of the program we decode the first byte which tells us which instruction the user is trying to call. With just one byte we can differentiate up to 256 different instructions.

We can encode the instruction arguments in the remaining bytes of the instruction_data byte slice.

Transactions have changed from their legacy days and become VersionedTransaction which is represented by v0.

The newer transactions add support for Address Lookup Tables. These tables let you go and lookup accounts you need instead of passing them directly into the instructions. These lookup addresses are stored inside an account and looked up at runtime.

Instructions have a limit of the number of parameters. Its undocumented but I've seen people complain around 18.

Instructions only have only have access to the instruction data, accounts and sysvars.

We can't pass accounts in as arguments to the instruction because the runtime needs to know the accounts before the transaction for parallel execution.

This is why even though the system programs, spl programs, etc are all effectively constants, we still need to pass them in.

Confirming transactions

Solana secures itself using a proof of history which requires a long chain of recursive SHA-256 hashes to build a trusted clock.

Blocks producers (validators) hash transaction id's into the stream to record which transactions were processed in their block.

next_hash = hash(prev_hash, hash(transaction_ids))

This becomes a trusted clock because each hash must be produced sequentially. Each block contains a blockhash and a list of hash checkpoints called ticks so that validators can verify the full chain of hashes in parallel, proving that time has actually passed.

Each tick is 12,500 hashes. A slot has 64 ticks (800k hashes) and an epoch has 432,000 slots.

When you submit a transaction, to keep the fees deterministic, its required to include a recent blockhash and if it is not recent enough, then the transaction is rejected.

Validators look up the corresponding slot number for each transaction's blockhash that they wish to process in the block. If this slot is 151 slots lower than the current slot number of the most recent block then it is rejected.

Slots are configured to last about 400ms but can fluctuate between 400-600ms. So a single blockhash is usually valid for about 60 to 90 seconds (~150 slots).

Transactions being dropped helps validators avoid processing the same transaction twice. It helps validators by only requiring them to check if a new transaction is in a small set of recently processed transactions.

A side effect of this is that validators only need about 150MB of memory to track transactions.

Forking

Solana doesn't wait for all validators to agree on a block before the next block is produced. It is common for two different blocks to be chained to the same parent.

We call each conflicting chain a fork. Validators need to vote on one of these forks to reach agreement on which one to choose. Only one fork will be finalized by the cluster and all competing blocks are discarded.

Because of this you should use the confirmed commitment level for your RPC requests. It is only a few slots behind the processed commitment and has a low chance of being owned by a dropped fork.

Roughly 5% of blocks do not end up being finalized by the cluster. So using processed there is a chance you will be dropped.

Using finalized will eliminate the risk of being dropped, but there is typically at least a 32 slot difference between the most recent confirmed block and the most recent finalized block. This would reduce the expiration of your transaction by about 13 seconds, but that could be greater during unstable cluster conditions.

Preflight

Sometimes you can run into issues with different RPC nodes lagging behind one another.

When an RPC node receives a transaction, it will attempt to determine the expiration block using the most recent finalized block or that selected by the preflightCommitment parameter.

A very common issue is that a transaction's blockhash was produced after the block used to calculate the expiration of that transaction. If the node cannot determine when your transaction expires, it will only forward the transaction one time and then drop the transaction.

The same thing happens when simulating transactions. The simulation would fail with a blockhash not found error.

Even if you use skipPreflight you should always set the preflightCommitment to the same commitment level used to fetch your transaction's blockhash for both sendTransaction and simulateTransaction.