SOLSTACKAcademy

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

TypeScript
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

src/solstack/lib/tx.js · simulateInstructions, trimmedTypeScript
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

src/solstack/lib/tx.js · sendInstructionsTypeScript
// 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.

TypeScript
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.

TypeScript
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 nonce

Reading the errors

Blockhash not foundThe blockhash was too old when the node saw it. Rebuild with a fresh one; nothing was charged.
insufficient funds for rent / 0x1 from SystemThe fee payer can't cover the fee plus the deposits the transaction creates.
custom program error: 0x1775A program's own error, here 6005. Look it up in that program's IDL — Anchor errors start at 6000.
Transaction simulation failed with logsThe instructions ran and one failed. The last Program log: before the error usually says why.
User rejected the requestThe 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.

Try it

0 of 32 lessons done