Understanding how a Solana transaction is constructed is crucial for building Solana applications like trading bots, smart contracts, DeFi applications, etc. It's very useful for people who are new to Solana or blockchain in general. In this article, we will learn how to create a Solana transaction without using any client libraries to gain a deeper understanding of how Solana transactions work.
Core concepts
Before continuing, I highly recommend reading the official Solana documentation about Solana Account Model and Transactions and Instructions. They provide a good foundation for understanding Solana account and transaction models.
I'll quickly go over some concepts here:
Account
- The way Solana stores data resembles a key-value store where each account has a unique address and acts as an entry in the store.
- An account can be either a program (smart contract) account or a user (wallet) account.
- Every account has a program "owner". Only the program that owns an account can modify its data or deduct its lamport balance. However, anyone can increase the balance.
- In a transaction, an account is a signer if it's required to sign the transaction. Additionally, an account is writable if its data/state can be modified by the transaction.
Transaction
- Sending a transaction is the way to interact (sending or receiving SOL, calling programs, etc.) with the Solana blockchain.
- A transaction consists of a list of instructions where each instruction represents an operation to be executed. Each instruction specifies the program to call, the accounts to pass to the program, and the data to send to the program.
- Instructions are executed sequentially and atomically. If any instruction fails, the transaction is rolled back.
- The maximum size of a transaction is 1232 bytes.
You can think of a Solana transaction as when you make an order at a restaurant. The order is a transaction, and each dish is an instruction. Or if you're familiar with web development, a transaction is like a POST request, except you can do multiple POST requests in one transaction.
Structure Of A Solana Transaction
It seems complicated, but don't worry; I'll explain each part. Feel free to skip to the next section if you already understand it.
Compact-Array Format
It contains 2 parts:
- The length of the array: encoded in a format called compact-u16.
- The items in the array: each item is sequentially listed after the encoded length.
Signatures
An array of signatures of the transaction. Each signature is 64 bytes long. Each signature is the result of signing the transaction (signatures + message) with the private key of the signer using the Ed25519 algorithm.
Message Header
It contains only 3 u8 fields:
- The number of required signatures for the transaction: This determines how many signer accounts must provide valid signatures for the transaction to be processed.
- The number of read-only (non-writable) and signer accounts: These accounts must sign the transaction to approve it, even though their data remains unchanged.
- The number of read-only (non-writable) and non-signer accounts: These accounts are included for reference but do not require signatures or modification.
We can programmatically create the message header by iterating through the accounts required by each instruction and counting the number of signer and read-only accounts.
Compact Array Of Account Addresses
An array containing all the account addresses needed for the instructions within the transaction. Addresses in the array are ordered by the privileges for the accounts:
- Accounts that are writable and signers
- Accounts that are read-only and signers
- Accounts that are writable and not signers
- Accounts that are read-only and not signers
Just like the message header, we can programmatically create this array using the accounts required by the instructions.
Compact Array Of Instructions
Instruction
Each instruction must include the following information:
- Program address: Specifies the program being invoked.
- Accounts: Lists every account the instruction reads from or writes to, including other programs, using the
AccountMeta
struct. - Instruction Data: A byte array that specifies which instruction handler on the program to invoke as there can be multiple handlers in a program, plus any additional data required by the instruction handler (function arguments).
Compiled Instruction
The structure of an instruction is human-readable, but when we serialize it, it needs to be in a different format called CompiledInstruction. It contains a slightly different structure:
- Program ID Index: This is represented as an u8 index pointing to an account address within the account addresses array.
- Compact array of account address indexes: An array of u8 indexes pointing to the account addresses array for each account required by the instruction.
- Compact array of opaque u8 data: A u8 byte array specific to the program invoked. This data specifies the instruction to invoke on the program along with any additional data that the instruction requires (such as function arguments).
Recent Blockhash
Finally, the final part of the transaction is the recent blockhash. This acts as a timestamp for the transaction and is used to prevent duplications and eliminate stale transactions.
The maximum age of a transaction's blockhash is 150 blocks (~1 minute assuming 400ms block times). If a transaction's blockhash is 150 blocks older than the latest blockhash, it is considered expired and the transaction will be rejected.
Implementation
Enough theory, let's implement a simple application that sends a small amount of SOL from one account to another. As I mentioned earlier, we won't use any client libraries, only the Solana JSON RPC API.
First, we need to figure out which program to call and what data it expects. In Solana, every "wallet" is owned by a program called System Program. This program provides basic functionalities like creating accounts, transferring SOL, etc. Regarding the data, the instruction handler we want to invoke is Transfer
, its index is 2. Note that the type of the index is u32 so we need to encode 2
as a 32-bit integer in little-endian format.
The Transfer function requires 2 accounts: the sender and the receiver and an 64-bit unsigned integer representing the amount of SOL to transfer. The sender account must be a signer, and both accounts must be writable.
Now, let's define some types based on the structure of a Solana transaction:
1// types.go2package main34type PublicKey [32]byte5type Signature [64]byte67type Transaction struct {8Signatures []Signature9Message Message10}1112type AccountMeta struct {13PubKey PublicKey14IsSigner bool15IsWritable bool16}1718type Instruction struct {19ProgramID PublicKey20ProgramIDIndex uint821Accounts []AccountMeta22Data []byte23}2425type Message struct {26Header MessageHeader27AccountKeys []PublicKey28RecentBlockhash PublicKey29Instructions []Instruction30}3132type MessageHeader struct {33NumRequiredSignatures uint834NumReadonlySignedAccounts uint835NumReadonlyUnsignedAccounts uint836}
Next, we're going to create some helper functions:
1// solana.go2package main34import (5"bytes"6"crypto"7"crypto/ed25519"8"crypto/rand"9"encoding/binary"10"encoding/json"11"fmt"12"io"13"math"14"net/http"15"sort"1617"github.com/mr-tron/base58"18)1920const (21PublicKeyLength = 3222)2324func MustPublicKeyFromBase58(in string) PublicKey {25out, err := PublicKeyFromBase58(in)26if err != nil {27panic(err)28}29return out30}3132func PublicKeyFromBase58(in string) (out PublicKey, err error) {33val, err := base58.Decode(in)34if err != nil {35return out, fmt.Errorf("failed to decode base58: %w", err)36}3738if len(val) != PublicKeyLength {39return out, fmt.Errorf("invalid public key length: got %d, want %d", len(val), PublicKeyLength)40}4142copy(out[:], val)43return44}454647// CreateTransferInstruction creates a transfer instruction48// https://docs.rs/solana-sdk/latest/solana_sdk/system_instruction/enum.SystemInstruction.html#variant.Transfer49func CreateTransferInstruction(sender, receiver PublicKey, lamports uint64) Instruction {50systemProgramID := MustPublicKeyFromBase58("11111111111111111111111111111111")5152senderMeta := AccountMeta{53PubKey: sender,54IsSigner: true,55IsWritable: true,56}57receiverMeta := AccountMeta{58PubKey: receiver,59IsSigner: false,60IsWritable: true,61}6263// Transfer instruction data64// The first part is the instruction handler index, system program uses a 4-byte (u32) index, ref: https://github.com/solana-program/system/blob/5363ad3/clients/js/src/generated/programs/system.ts#L64-L6665// The next 8 bytes (u64) are the amount in lamports (little-endian)66data := make([]byte, 12)67binary.LittleEndian.PutUint32(data[:4], 2)68binary.LittleEndian.PutUint64(data[4:], lamports)6970return Instruction{71ProgramID: systemProgramID,72Accounts: []AccountMeta{senderMeta, receiverMeta},73Data: data,74}75}767778func NewTransaction(instructions []Instruction, recentBlockhash [32]byte, feePayer PublicKey) (*Transaction, error) {79message := Message{80RecentBlockhash: recentBlockhash,81Instructions: instructions,82}83seen := make(map[PublicKey]struct{})84accounts := make([]*AccountMeta, 0)85accountKeyIndex := make(map[PublicKey]int)86for _, instr := range instructions {87for _, acct := range instr.Accounts {88if _, ok := seen[acct.PubKey]; !ok {89seen[acct.PubKey] = struct{}{}90accounts = append(accounts, &acct)91}92}93if _, ok := seen[instr.ProgramID]; !ok {94seen[instr.ProgramID] = struct{}{}95accounts = append(accounts, &AccountMeta{96PubKey: instr.ProgramID,97IsSigner: false,98IsWritable: false,99})100}101}102103// Sort the accounts104sort.SliceStable(accounts, func(i, j int) bool {105ai := accounts[i]106aj := accounts[j]107if ai.PubKey == feePayer {108return true109}110if ai.IsSigner != aj.IsSigner {111return ai.IsSigner112}113if ai.IsWritable != aj.IsWritable {114return ai.IsWritable115}116117return false118})119message.AccountKeys = make([]PublicKey, len(accounts))120for i, acc := range accounts {121accountKeyIndex[acc.PubKey] = i122}123for i, inst := range message.Instructions {124// Assign program ID index125message.Instructions[i].ProgramIDIndex = uint8(accountKeyIndex[inst.ProgramID])126}127128// Initialize message header129for i, acc := range accounts {130if acc.IsSigner {131message.Header.NumRequiredSignatures++132if !acc.IsWritable {133message.Header.NumReadonlySignedAccounts++134}135} else {136if !acc.IsWritable {137message.Header.NumReadonlyUnsignedAccounts++138}139}140141// Populate account address array142message.AccountKeys[i] = acc.PubKey143}144145return &Transaction{146Message: message,147}, nil148}149150// SerializeMessage serializes a message to byte array151func SerializeMessage(msg Message) ([]byte, error) {152buf := new(bytes.Buffer)153154// Serialize message header155buf.WriteByte(msg.Header.NumRequiredSignatures)156buf.WriteByte(msg.Header.NumReadonlySignedAccounts)157buf.WriteByte(msg.Header.NumReadonlyUnsignedAccounts)158159// Serialize account keys with compact-array format160// Length as compact-u16161err := EncodeCompactU16Length(buf, len(msg.AccountKeys))162if err != nil {163return nil, fmt.Errorf("failed to encode account keys length: %w", err)164}165for _, acct := range msg.AccountKeys {166buf.Write(acct[:])167}168169// Serialize recent blockhash170buf.Write(msg.RecentBlockhash[:])171172// Serialize instructions173// Each instruction is represented in the CompiledInstruction format174// First, number of instructions as compact-u16175err = EncodeCompactU16Length(buf, len(msg.Instructions))176if err != nil {177return nil, fmt.Errorf("failed to encode instructions length: %w", err)178}179for _, instr := range msg.Instructions {180// Program ID index181buf.WriteByte(byte(instr.ProgramIDIndex))182183accountIndexes := []uint8{}184for _, acct := range instr.Accounts {185// Find the index of the account in the account keys array186for i, key := range msg.AccountKeys {187if key == acct.PubKey {188accountIndexes = append(accountIndexes, uint8(i))189break190}191}192}193// Serialize account indexes with compact-array format194err := EncodeCompactU16Length(buf, len(accountIndexes))195if err != nil {196return nil, fmt.Errorf("failed to encode account indexes length: %w", err)197}198for _, idx := range accountIndexes {199buf.WriteByte(idx)200}201202// Serialize instruction data203err = EncodeCompactU16Length(buf, len(instr.Data))204if err != nil {205return nil, fmt.Errorf("failed to encode instruction data length: %w", err)206}207buf.Write(instr.Data)208}209210return buf.Bytes(), nil211}212213// SerializeTransaction serializes a transaction to byte array214func SerializeTransaction(tx *Transaction, senderPrivateKey ed25519.PrivateKey) ([]byte, error) {215serializedMessage, err := SerializeMessage(tx.Message)216if err != nil {217return nil, fmt.Errorf("failed to serialize message: %w", err)218}219signature, err := SignTransaction(serializedMessage, senderPrivateKey)220if err != nil {221return nil, fmt.Errorf("failed to sign transaction: %w", err)222}223tx.Signatures = append(tx.Signatures, signature)224signatureCount := &bytes.Buffer{}225// Serialize signature array226err = EncodeCompactU16Length(signatureCount, len(tx.Signatures))227if err != nil {228return nil, fmt.Errorf("failed to encode signature count: %w", err)229}230serializedTransaction := make([]byte, 0, signatureCount.Len()+signatureCount.Len()*64+len(serializedMessage))231serializedTransaction = append(serializedTransaction, signatureCount.Bytes()...)232for _, sig := range tx.Signatures {233serializedTransaction = append(serializedTransaction, sig[:]...)234}235serializedTransaction = append(serializedTransaction, serializedMessage...)236237return serializedTransaction, nil238}239240// EncodeCompactU16Length encodes a length as a compact u16241// https://github.com/solana-labs/solana/blob/2ef2b6daa05a7cff057e9d3ef95134cee3e4045d/web3.js/src/util/shortvec-encoding.ts242func EncodeCompactU16Length(buf *bytes.Buffer, len int) error {243if len < 0 || len > math.MaxUint16 {244return fmt.Errorf("length %d out of range", len)245}246rem_len := len247for {248elem := uint8(rem_len & 0x7f)249rem_len >>= 7250if rem_len == 0 {251buf.WriteByte(elem)252break253} else {254elem |= 0x80255buf.WriteByte(elem)256}257}258return nil259}260261// SignTransaction signs a transaction with a private key using ed25519262func SignTransaction(serializedTransaction []byte, privateKey ed25519.PrivateKey) (Signature, error) {263signature, err := privateKey.Sign(rand.Reader, serializedTransaction, crypto.Hash(0))264if err != nil {265return Signature{}, fmt.Errorf("failed to sign transaction: %w", err)266}267var sig Signature268copy(sig[:], signature)269return sig, nil270}271272type RPCResponse struct {273JsonRPC string `json:"jsonrpc"`274ID int `json:"id"`275Result struct {276Context struct {277Slot uint64 `json:"slot"`278} `json:"context"`279Value struct {280Blockhash string `json:"blockhash"`281} `json:"value"`282} `json:"result"`283Error *struct {284Code int `json:"code"`285Message string `json:"message"`286} `json:"error,omitempty"`287}288289// GetLatestBlockhash retrieves the latest blockhash from Solana290func GetLatestBlockhash(rpcEndpoint string) ([32]byte, error) {291requestBody := map[string]interface{}{292"id": 1,293"jsonrpc": "2.0",294"method": "getLatestBlockhash",295"params": []map[string]string{{296"commitment": "processed",297}},298}299jsonData, err := json.Marshal(requestBody)300if err != nil {301return [32]byte{}, fmt.Errorf("failed to marshal request: %v", err)302}303req, err := http.NewRequest("POST", rpcEndpoint, bytes.NewBuffer(jsonData))304if err != nil {305return [32]byte{}, fmt.Errorf("failed to create request: %v", err)306}307req.Header.Set("Content-Type", "application/json")308client := &http.Client{}309resp, err := client.Do(req)310if err != nil {311return [32]byte{}, fmt.Errorf("failed to send request: %v", err)312}313defer resp.Body.Close()314body, err := io.ReadAll(resp.Body)315if err != nil {316return [32]byte{}, fmt.Errorf("failed to read response: %v", err)317}318var rpcResponse RPCResponse319if err := json.Unmarshal(body, &rpcResponse); err != nil {320return [32]byte{}, fmt.Errorf("failed to parse response: %v", err)321}322if rpcResponse.Error != nil {323return [32]byte{}, fmt.Errorf("RPC error: %d - %s",324rpcResponse.Error.Code, rpcResponse.Error.Message)325}326decoded, err := base58.Decode(rpcResponse.Result.Value.Blockhash)327if err != nil {328return [32]byte{}, fmt.Errorf("failed to decode blockhash: %v", err)329}330if len(decoded) != 32 {331return [32]byte{}, fmt.Errorf("invalid blockhash length: %d", len(decoded))332}333var result [32]byte334copy(result[:], decoded)335return result, nil336}
A lot things going on here, I'll explain some key functions:
- PublicKeyFromBase58(): Since the string representation of a public key is in base58 format, we need a function to convert it to a 32-byte array.
- CreateTransferInstruction(): Create a transfer instruction. It will contain the sender and receiver accounts and the amount of SOL to transfer.
- NewTransaction(): Create a new transaction. It will contain the
Message
part of the transaction. Account addresses are aggregated and sorted by their privileges. The message header is then populated based on the accounts. - SerializeMessage(): Serialize a
Message
to a byte array. It will serialize the message header, account keys, recent blockhash, and instructions sequentially. - SerializeTransaction(): Serialize a transaction to a byte array. It will serialize
Message
, sign theMessage
with the sender's private key, and append the serialized signature array and message to the final byte array.
Finally, let's create the main function and use the functions we just created:
1// main.go2package main34import (5"encoding/base64"6"fmt"7"log"89"github.com/mr-tron/base58"10"golang.org/x/crypto/ed25519"11)1213func main() {14senderPubKey := MustPublicKeyFromBase58("ENTER_SENDER_PUBKEY_HERE")15receiverPubKey := MustPublicKeyFromBase58("ENTER_RECEIVER_PUBKEY_HERE")16senderPrivateKeyBytes, err := base58.Decode("ENTER_SENDER_PRIVATE_KEY_HERE")17if err != nil {18log.Fatalf("Invalid private key provided: %v", err)19}20if len(senderPrivateKeyBytes) != ed25519.PrivateKeySize {21log.Fatalf("Private key must be %d bytes", ed25519.PrivateKeySize)22}23senderPrivateKey := ed25519.PrivateKey(senderPrivateKeyBytes)2425amountSOL := 0.00126lamports := uint64(amountSOL * 1e9) // 1 SOL = 1e9 lamports2728transferInstr := CreateTransferInstruction(senderPubKey, receiverPubKey, lamports)29recentBlockhash, err := GetLatestBlockhash("https://api.mainnet-beta.solana.com")30if err != nil {31log.Fatalf("Failed to get recent blockhash: %v", err)32}33tx, err := NewTransaction([]Instruction{transferInstr}, recentBlockhash, senderPubKey)34if err != nil {35log.Fatalf("Failed to create transaction: %v", err)36}37serializedTx, err := SerializeTransaction(tx, senderPrivateKey)3839fmt.Printf("Base64-encoded transaction: %s\n", base64.StdEncoding.EncodeToString(serializedTx))40}
Nothing fancy here, it just defines necessary variables, creates a transfer instruction, gets the latest blockhash, creates a transaction, serializes it, and prints the base64-encoded transaction. Remember to replace the placeholders with your own values if you want to run the code.
That's it! We've just created a simple application for transfering SOL between. You can run the code with the following command:
1go run .
Library alternative
Don't want to reinvent the wheel or believe that the code above can work properly? Here's the code that uses Solana Go SDK to achieve the same result:
1package main23import (4"context"5"encoding/base64"6"fmt"78"github.com/gagliardetto/solana-go"9"github.com/gagliardetto/solana-go/programs/system"10"github.com/gagliardetto/solana-go/rpc"11)1213func main() {14receiverPubKey := solana.MustPublicKeyFromBase58("ENTER_RECEIVER_PUBKEY_HERE")15senderPrivateKey := solana.MustPrivateKeyFromBase58("ENTER_SENDER_PRIVATE_KEY_HERE")16signers := []solana.PrivateKey{senderPrivateKey}17client := rpc.New("https://api.mainnet-beta.solana.com")18res, err := client.GetLatestBlockhash(context.Background(), rpc.CommitmentProcessed)19if err != nil {20panic(err)21}22recentBlockhash := res.Value.Blockhash2324amountSOL := 0.00125lamports := uint64(amountSOL * 1e9) // 1 SOL = 1e9 lamports26tx, err := solana.NewTransaction(27[]solana.Instruction{28system.NewTransferInstruction(29lamports,30senderPrivateKey.PublicKey(),31receiverPubKey,32).Build(),33},34recentBlockhash,35solana.TransactionPayer(senderPrivateKey.PublicKey()),36)37if err != nil {38panic(err)39}40_, err = tx.Sign(41func(key solana.PublicKey) *solana.PrivateKey {42for _, payer := range signers {43if payer.PublicKey().Equals(key) {44return &payer45}46}47return nil48},49)50serializedTx, err := tx.MarshalBinary()51if err != nil {52panic(err)53}5455fmt.Printf("Base64-Encoded Tx from Solana-go: %s\n", base64.StdEncoding.EncodeToString(serializedTx))56}57
If you want the exact same result between the two code snippets, you need to hard-code the recent blockhash, as it changes every time you run the code.
Conclusion
I hope this article has helped you understand the structure of a Solana transaction and how to create one without any SDKs or libraries. IMO, this knowledge is essential for building more complex applications on Solana. In the next article, we'll create an application that swaps tokens on the most popular DEX on Solana, Raydium. Stay tuned!