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, because 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

What each tool does: Solana Transaction Inspector · Solana Multisender · Solana Devnet Faucet

0 of 32 lessons done