Builder· Lesson 3 of 8· 5 min
Building and sending transactions in TypeScript
From a list of instructions to a confirmed signature: the send loop every Solstack tool uses, with simulation, compute budget, blockhash expiry and confirmation handled the way the network expects.
A client's job is small and easy to get subtly wrong: assemble instructions, attach a recent blockhash, get signatures, send, and know for certain whether it landed. The code below is @solana/web3.js 1.x, which most wallets and libraries still target; the newer @solana/kit has the same concepts with different names.
Instructions
import { Connection, PublicKey, SystemProgram, LAMPORTS_PER_SOL } from "@solana/web3.js";
import {
createAssociatedTokenAccountIdempotentInstruction,
createTransferCheckedInstruction,
getAssociatedTokenAddressSync,
} from "@solana/spl-token";
const connection = new Connection("https://api.devnet.solana.com", "confirmed");
// 0.01 SOL to a friend
const paySol = SystemProgram.transfer({
fromPubkey: me,
toPubkey: friend,
lamports: 0.01 * LAMPORTS_PER_SOL,
});
// 5 tokens of a 6-decimal mint: create their ATA if missing, then transfer
const theirAta = getAssociatedTokenAddressSync(mint, friend);
const createAta = createAssociatedTokenAccountIdempotentInstruction(me, theirAta, friend, mint);
const sendTokens = createTransferCheckedInstruction(
getAssociatedTokenAddressSync(mint, me), // source
mint,
theirAta,
me, // owner of the source
5_000_000n, // amount in base units
6 // decimals, checked on-chain
);
const instructions = [paySol, createAta, sendTokens];Use the idempotent form of account creation so a retry never fails because the first attempt landed. Use transferChecked over transfer: it takes the mint and decimals and fails if either is wrong, which is exactly the mistake you want caught.
Simulate, then set the budget
import { ComputeBudgetProgram, TransactionMessage, VersionedTransaction } from "@solana/web3.js";
async function simulate(connection, payer, instructions) {
const { blockhash } = await connection.getLatestBlockhash("confirmed");
const message = new TransactionMessage({ payerKey: payer, recentBlockhash: blockhash, instructions })
.compileToV0Message();
const tx = new VersionedTransaction(message);
const { value } = await connection.simulateTransaction(tx, {
sigVerify: false, // no signatures yet
replaceRecentBlockhash: true, // don't fail on a stale one
commitment: "confirmed",
});
return { ok: !value.err, err: value.err, logs: value.logs ?? [], units: value.unitsConsumed ?? null };
}
const sim = await simulate(connection, me, instructions);
if (!sim.ok) throw new Error(`would fail: ${JSON.stringify(sim.err)}\n${sim.logs.join("\n")}`);
const budgeted = [
ComputeBudgetProgram.setComputeUnitLimit({ units: Math.ceil(sim.units * 1.1) }),
ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1_000 }),
...instructions,
];Simulation returns the units consumed and the full log, so a failing transaction never reaches the wallet. The budget instructions go first; Compute budget and priority fees explains what to set them to.
Sign: wallet adapter or keypair
// In a browser the wallet signs. Never ask for a private key.
export async function sendInstructions(connection, wallet, instructions, signers = []) {
if (!wallet?.publicKey) throw new Error("Wallet not connected");
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash("finalized");
const tx = new Transaction({ feePayer: wallet.publicKey, blockhash, lastValidBlockHeight }).add(...instructions);
// wallet-adapter: the wallet signs (and partially signs with any extra keypairs, e.g. a new mint)
const signature = await wallet.sendTransaction(tx, connection, { signers, skipPreflight: false });
const result = await connection.confirmTransaction({ signature, blockhash, lastValidBlockHeight }, "confirmed");
if (result.value?.err) throw new Error(`Transaction failed: ${JSON.stringify(result.value.err)} (${signature})`);
return signature;
}
// On a server or in a script, a Keypair signs.
tx.sign(keypair);
const raw = tx.serialize();Send until it lands or expires
sendTransaction returns as soon as an RPC node accepts the bytes; it says nothing about inclusion. A signed transaction is valid until its blockhash's lastValidBlockHeight, and resending the same bytes is safe — the same signature can only land once. So the reliable pattern is: send, poll the status, resend every second or two, and stop when it confirms or the block height passes.
async function sendAndConfirm(connection, raw: Uint8Array, lastValidBlockHeight: number) {
const signature = await connection.sendRawTransaction(raw, { skipPreflight: true, maxRetries: 0 });
while (true) {
const { value } = await connection.getSignatureStatuses([signature]);
const status = value[0];
if (status?.confirmationStatus === "confirmed" || status?.confirmationStatus === "finalized") {
if (status.err) throw new Error(`failed on-chain: ${JSON.stringify(status.err)}`);
return signature;
}
if ((await connection.getBlockHeight("confirmed")) > lastValidBlockHeight) {
throw new Error("expired before it landed — rebuild with a fresh blockhash");
}
await connection.sendRawTransaction(raw, { skipPreflight: true, maxRetries: 0 }); // resend, same signature
await new Promise((r) => setTimeout(r, 1_500));
}
}When a minute is not enough: durable nonces
A blockhash expires after about a minute, which is a problem for transactions signed offline, by a multisig, or queued for later. A durable nonce account replaces the blockhash with a value that only changes when you advance it, so the signed transaction stays valid until it is used. The advance must be the first instruction.
import { Keypair, NonceAccount, NONCE_ACCOUNT_LENGTH, SystemProgram, Transaction } from "@solana/web3.js";
// once: create the nonce account (you are its authority)
const nonceKp = Keypair.generate();
const rent = await connection.getMinimumBalanceForRentExemption(NONCE_ACCOUNT_LENGTH);
const create = new Transaction().add(
SystemProgram.createNonceAccount({
fromPubkey: me, noncePubkey: nonceKp.publicKey, authorizedPubkey: me, lamports: rent,
})
);
// every time: read the current nonce and use it as the blockhash
const info = await connection.getAccountInfo(nonceKp.publicKey);
const nonce = NonceAccount.fromAccountData(info!.data).nonce;
const tx = new Transaction({ feePayer: me, nonceInfo: {
nonce,
nonceInstruction: SystemProgram.nonceAdvance({ noncePubkey: nonceKp.publicKey, authorizedPubkey: me }),
} }).add(...instructions);
// sign now, send whenever — it cannot expire, and it can land only once, because sending advances the nonceReading the errors
Blockhash not found | The blockhash was too old when the node saw it. Rebuild with a fresh one; nothing was charged. |
|---|---|
insufficient funds for rent / 0x1 from System | The fee payer can't cover the fee plus the deposits the transaction creates. |
custom program error: 0x1775 | A program's own error, here 6005. Look it up in that program's IDL — Anchor errors start at 6000. |
Transaction simulation failed with logs | The instructions ran and one failed. The last Program log: before the error usually says why. |
User rejected the request | The wallet popup was dismissed. Not a network error; offer to try again. |
Solstack's readableError maps each of these to a sentence for the review card, and Transaction Inspector shows the decoded instructions and logs for any signature you are debugging.
What to remember
- Build instructions, simulate, set the compute budget from the result, then ask for signatures.
- Use idempotent account creation and transferChecked so retries and wrong-decimals mistakes are safe.
- sendTransaction is not confirmation. Poll statuses and resend the same bytes until confirmed or lastValidBlockHeight passes.
- A durable nonce replaces the blockhash for transactions that must outlive a minute.
- Errors are structured: expired, insufficient funds, custom program error with a code, or user rejection.