learn.sol
Solana Kit Clients • Transaction Message Building And Signers
Lesson 5 of 11

Transaction Message Building and Signers

Build one transaction message in the correct order, understand who really signs it, and see why message mutation after signing is invalid.

Most client-side transaction bugs come from one bad assumption.

People treat a transaction like a function call they can keep editing until the last second.

That is not what it is.

A Solana transaction message is a plan.

It fixes:

  • which instructions will run
  • which accounts are involved
  • who pays fees
  • which recent blockhash defines the lifetime

Then signers approve that exact plan.

If the plan changes after signing, the signatures are no longer valid.

That is the whole mental model for this lesson.

Transaction MessageTap to reveal
The exact plan being signed: instructions, accounts, fee payer, and recent blockhash lifetime.
Transaction Message
Fee PayerTap to reveal
The signer whose account covers transaction fees and is therefore part of the signed message.
Fee Payer
LifetimeTap to reveal
The recent blockhash-based validity window that determines how long the message can still be accepted.
Lifetime
Signer SetTap to reveal
The accounts that must authorize the exact final message, not every account mentioned in it.
Signer Set

The Order Is Not Arbitrary

With Kit, the correct build order is:

  1. create the message shell
  2. set the fee payer signer
  3. set the lifetime with a fresh blockhash
  4. append instructions
  5. sign the final message

That order exists for a reason.

The fee payer and lifetime are part of what gets signed.

So they must exist before the signature step.

Step 1: Build One Transfer Message

Install the system program client if you have not already:

pnpm add @solana-program/system

Now create scripts/build-message.ts:

import {
  appendTransactionMessageInstructions,
  createSolanaRpc,
  createTransactionMessage,
  generateKeyPairSigner,
  getSignatureFromTransaction,
  lamports,
  pipe,
  setTransactionMessageFeePayerSigner,
  setTransactionMessageLifetimeUsingBlockhash,
  signTransactionMessageWithSigners,
} from "@solana/kit";
import { getTransferSolInstruction } from "@solana-program/system";

const rpc = createSolanaRpc("http://127.0.0.1:8899");

async function main() {
  const sender = await generateKeyPairSigner();
  const recipient = await generateKeyPairSigner();
  const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();

  const transferInstruction = getTransferSolInstruction({
    source: sender,
    destination: recipient.address,
    amount: lamports(1_000_000n),
  });

  const message = pipe(
    createTransactionMessage({ version: 0 }),
    (tx) => setTransactionMessageFeePayerSigner(sender, tx),
    (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
    (tx) => appendTransactionMessageInstructions([transferInstruction], tx),
  );

  const signedTransaction = await signTransactionMessageWithSigners(message);

  console.log("signature", getSignatureFromTransaction(signedTransaction));
}

main().catch(console.error);

Run it with:

pnpm tsx scripts/build-message.ts

This script does not send the transaction.

That is intentional.

The lesson is about building and signing the message correctly first.

Read The Script In The Right Order

createTransactionMessage({ version: 0 })

This creates the message shell.

At this point, you do not have enough information to sign anything yet.

You only have the basic container.

setTransactionMessageFeePayerSigner(...)

The fee payer is not just metadata.

It is part of the signed message.

In this example, the sender is also paying fees.

That is common, but it is not required by the protocol.

setTransactionMessageLifetimeUsingBlockhash(...)

The recent blockhash defines how long the message stays valid.

This is why blockhash freshness matters.

A stale blockhash means the message may be structurally correct and still be rejected by the runtime.

appendTransactionMessageInstructions(...)

Only now do you attach the ordered program operations.

Instruction order is part of the message.

If you reorder instructions later, you did not make a small edit.

You created a different message.

signTransactionMessageWithSigners(...)

This is the lock point.

After this, the message contents must not change.

If they change, the signatures no longer describe the transaction honestly.

Who Actually Signs

This is where beginners often get lost.

In the transfer above:

  • sender signs
  • recipient does not sign

Why:

  • the sender is authorizing SOL to leave their account
  • the recipient is only receiving funds

Being involved in a transaction is not the same as being a signer.

That distinction matters in every serious client flow.

The Failure Modes To Avoid

Setting lifetime after signing

That invalidates the signatures.

The message changed.

Reusing an old blockhash blindly

That makes retries fail for the wrong reason.

If the message lifetime expired, rebuild the message first.

Treating recipients like signers by default

Most accounts in a transaction do not sign.

Only accounts that must authorize the message do.

What You Can Do After This

You can build a transaction message in the right order, explain why the fee payer and blockhash belong inside the signed plan, and identify which accounts actually need to sign.

That is the minimum foundation for sending transactions safely.

The next lesson builds on this by covering confirmation, retries, and lifetime-aware resend behavior.

Quick Check

Quick Check
Single answer

Why must the fee payer and recent blockhash be set before the message is signed?

Quick Check
Single answer

Why does the recipient in the example transfer not sign the transaction?

Sources

Solana Assistant

AI-powered documentation helper

Welcome to Solana Assistant

Ask specific questions about Solana development:

Ask specific questions for better results400px