Week 4 • Transaction Message Building And Signers
Transaction Message Building & Signers
Build a transaction the right way: message -> fee payer -> lifetime -> instructions -> signatures.
Step 1: 0-to-1 Theory
Primitives
transaction message: immutable plan before signingfee payer: signer that pays network feeblockhash lifetime: validity windowinstructions: ordered program operations
Mental Model
A transaction is not “a function call”. It is a signed packet that says:
- which accounts are involved
- which programs to invoke
- in what order
Signatures cover the message bytes. If you change the message after signing, the signature no longer matches.
Invariants
- Lifetime must be set before signing.
- Signatures are invalid if message changes after signing.
- Instruction order changes semantics.
Quick Checks
- Why can’t you modify a message after signing?
- Why is blockhash freshness a runtime invariant?
Step 2: Real-World Implementation (Code Solution)
Install system program client:
pnpm add @solana-program/systemCreate scripts/build-message.ts:
import {
appendTransactionMessageInstructions,
createSolanaRpc,
createTransactionMessage,
generateKeyPairSigner,
lamports,
pipe,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
signTransactionMessageWithSigners,
} from "@solana/kit";
import { getTransferSolInstruction } from "@solana-program/system";
// Local validator RPC (fastest way to learn without devnet latency).
const rpc = createSolanaRpc("http://127.0.0.1:8899");
async function main() {
// In real apps, the wallet is usually the sender signer.
// Here we generate keypairs so the script is self-contained.
const sender = await generateKeyPairSigner();
const recipient = await generateKeyPairSigner();
// The blockhash defines the transaction lifetime window.
const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
// Build one instruction: transfer SOL from sender -> recipient.
const transferIx = getTransferSolInstruction({
source: sender,
destination: recipient.address,
amount: lamports(1_000_000n),
});
// Build the message in the correct order:
// message -> fee payer -> lifetime -> instructions.
const message = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayerSigner(sender, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
(tx) => appendTransactionMessageInstructions([transferIx], tx),
);
// Signatures cover the message bytes; after signing, do not mutate the message.
const signed = await signTransactionMessageWithSigners(message);
console.log("Built and signed transaction message", !!signed);
}
main().catch(console.error);Run:
pnpm tsx scripts/build-message.tsExpected result:
- script logs successful signed-message build.
Step 3: Mastery Test
- Easy: what happens if fee payer signer is omitted?
- Medium: what if blockhash expires before send?
- Hard: why can instruction reordering create security bugs?