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.
The Order Is Not Arbitrary
With Kit, the correct build order is:
- create the message shell
- set the fee payer signer
- set the lifetime with a fresh blockhash
- append instructions
- 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/systemNow 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.tsThis 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:
sendersignsrecipientdoes 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
Why must the fee payer and recent blockhash be set before the message is signed?
Why does the recipient in the example transfer not sign the transaction?
Up Next in Solana Kit Clients
Send, Confirm, Retry and Lifetime Management
Take the signed message and handle the real transport layer honestly by separating signature, submission, confirmation, and rebuild-aware retry.