DEV Community

Deek Roumy
Deek Roumy

Posted on

Web3 for Web2 Developers: Solana Transaction Mechanics Explained

Web3 for Web2 Developers: Solana Transaction Mechanics Explained

If you've spent years building REST APIs, querying SQL databases, and deploying to Heroku or Vercel, Solana's transaction model can feel like stepping into a parallel universe. Everything that seemed straightforward — sending data, calling functions, updating state — works differently here.

This isn't a "what is blockchain" article. You already know the basics. This is the guide I wish existed when I made the jump: how Solana transactions actually work, with real code you can run today.


The Mental Model Shift

In Web2, you call an endpoint. The server receives the request, runs some logic, updates a database, and sends back a response. Simple.

In Solana, you construct a transaction — a signed bundle of instructions — and broadcast it to a validator network. The network executes those instructions atomically. Either all succeed, or all fail. There's no partial state.

The key difference: you're not calling a server. You're submitting signed proof of intent to a decentralized computer.


What's Inside a Solana Transaction?

Every Solana transaction has three core components:

1. Instructions

Instructions are the actual commands — "transfer X lamports from account A to account B" or "call function Y on program Z with these arguments."

Each instruction specifies:

  • Program ID — the on-chain program (smart contract) to execute
  • Accounts — which accounts to read from or write to
  • Data — serialized arguments for the instruction

2. Accounts

Every piece of state on Solana lives in an account. Accounts are like files: they store data, have an owner (usually a program), and require rent (lamports) to exist.

Key account types:

  • Wallet accounts — hold SOL, owned by the System Program
  • Token accounts — hold SPL tokens, owned by the Token Program
  • Program accounts — contain executable bytecode
  • Data accounts — store state for programs (PDAs)

3. Recent Blockhash

This is Solana's answer to transaction ordering and replay prevention. Every transaction must include a recent blockhash (from within ~150 slots / ~90 seconds). Stale transactions are rejected.


Building a Transaction in TypeScript

Let's start with the most common operation: sending SOL.

import {
  Connection,
  PublicKey,
  SystemProgram,
  Transaction,
  sendAndConfirmTransaction,
  Keypair,
  LAMPORTS_PER_SOL
} from "@solana/web3.js";

// Connect to devnet for testing
const connection = new Connection("https://api.devnet.solana.com", "confirmed");

// Load your keypair (in production, use a wallet adapter)
const senderKeypair = Keypair.generate(); // Replace with your actual keypair
const recipient = new PublicKey("CjiaN9Bu3A7XNtBD89fuuFeb-CKde4jBVXaRhjChG8fwk");

async function sendSOL(lamports: number) {
  // Step 1: Get a recent blockhash
  const { blockhash, lastValidBlockHeight } = 
    await connection.getLatestBlockhash();

  // Step 2: Build the instruction
  const transferInstruction = SystemProgram.transfer({
    fromPubkey: senderKeypair.publicKey,
    toPubkey: recipient,
    lamports, // 1 SOL = 1,000,000,000 lamports
  });

  // Step 3: Build the transaction
  const transaction = new Transaction({
    feePayer: senderKeypair.publicKey,
    blockhash,
    lastValidBlockHeight,
  }).add(transferInstruction);

  // Step 4: Sign and send
  const signature = await sendAndConfirmTransaction(
    connection,
    transaction,
    [senderKeypair] // signers array
  );

  console.log(`Transaction confirmed: https://explorer.solana.com/tx/${signature}?cluster=devnet`);
  return signature;
}

sendSOL(0.1 * LAMPORTS_PER_SOL); // Send 0.1 SOL
Enter fullscreen mode Exit fullscreen mode

Notice what's happening here: you construct the transaction entirely client-side, sign it with your private key, and submit the signed bytes. The network validates your signature without ever needing to trust you.


The Account Model: Why It's Counterintuitive

Coming from Web2, the account model trips people up most.

In a traditional database, state is implicit — "users" table, "products" table, etc. In Solana, every piece of state is an explicit account that must be created upfront and passed as an argument to every instruction that touches it.

This is why Solana transactions require you to list all accounts in advance. The runtime can parallelize execution because it knows which instructions touch which accounts. That's how Solana achieves 50,000+ TPS.

Program Derived Addresses (PDAs)

PDAs are the Web3 equivalent of database rows keyed by a compound index. They're deterministic account addresses derived from a program ID + seeds.

import { PublicKey } from "@solana/web3.js";

const PROGRAM_ID = new PublicKey("YourProgramId11111111111111111111111111111111");
const userPublicKey = new PublicKey("UserPublicKey11111111111111111111111111111111");

// Deterministically derive an account address
const [pdaAddress, bump] = PublicKey.findProgramAddressSync(
  [
    Buffer.from("user-data"),      // seeds can be strings
    userPublicKey.toBuffer(),      // or public keys
  ],
  PROGRAM_ID
);

console.log("PDA:", pdaAddress.toString());
console.log("Bump:", bump); // 0-255, used to ensure valid curve point
Enter fullscreen mode Exit fullscreen mode

The bump ensures the derived address is NOT on the Ed25519 curve (meaning no private key exists for it). Only the program itself can sign for PDA accounts — making them trustless vaults.


Working with Transactions in Python

The solders and solana-py libraries bring the same power to Python:

from solana.rpc.api import Client
from solana.transaction import Transaction
from solana.keypair import Keypair
from solana.publickey import PublicKey
from solana.system_program import transfer, TransferParams
import base58

# Connect to devnet
client = Client("https://api.devnet.solana.com")

# Load keypair from base58 private key
sender = Keypair.from_secret_key(
    base58.b58decode("YOUR_PRIVATE_KEY_BASE58")
)
recipient = PublicKey("CjiaN9Bu3A7XNtBD89fuuFeb-CKde4jBVXaRhjChG8fwk")

def send_sol(lamports: int) -> str:
    # Get recent blockhash
    recent_blockhash = client.get_latest_blockhash()["result"]["value"]["blockhash"]

    # Build transfer instruction
    transfer_ix = transfer(TransferParams(
        from_pubkey=sender.public_key,
        to_pubkey=recipient,
        lamports=lamports
    ))

    # Build transaction
    txn = Transaction(recent_blockhash=recent_blockhash)
    txn.add(transfer_ix)
    txn.fee_payer = sender.public_key

    # Sign and send
    response = client.send_transaction(txn, sender)
    signature = response["result"]

    print(f"Signature: {signature}")
    return signature

# Send 0.01 SOL (10,000,000 lamports)
send_sol(10_000_000)
Enter fullscreen mode Exit fullscreen mode

Transaction Fees: Cheaper Than You Think

Solana transaction fees are tiny — typically 5,000 lamports (~$0.0001) for a basic transfer. For complex transactions with multiple instructions, fees scale slightly but remain negligible.

Fee structure:

  • Base fee: 5,000 lamports per signature
  • Priority fee (optional): micro-lamports per compute unit, to jump ahead in the queue during congestion
  • Rent: one-time cost to store account data (refundable if account is closed)

Adding priority fees for time-sensitive transactions:

import { ComputeBudgetProgram } from "@solana/web3.js";

const priorityFeeInstruction = ComputeBudgetProgram.setComputeUnitPrice({
  microLamports: 1000, // priority fee in micro-lamports per compute unit
});

const transaction = new Transaction()
  .add(priorityFeeInstruction)
  .add(transferInstruction);
Enter fullscreen mode Exit fullscreen mode

Transaction Lifecycle: What Actually Happens

When you call sendAndConfirmTransaction, here's what actually happens:

  1. Serialization: The transaction is serialized to bytes (~1232 byte max)
  2. Broadcast: Sent to an RPC node, which forwards to the current leader validator
  3. Validation: Signature verification, account checks, blockhash freshness
  4. Execution: Instructions run in sequence, state updates applied atomically
  5. Confirmation: Transaction included in a block, propagated to the network
  6. Finality: After ~32 blocks (~13 seconds), considered irreversible

Confirmation levels:

  • "processed" — included in a block (can still be rolled back)
  • "confirmed" — voted on by 2/3+ of stake (usually sufficient)
  • "finalized" — 32+ blocks confirmed (maximum security)

Common Web2 → Web3 Mistakes

1. Not handling blockhash expiry

Blockhashes expire after ~90 seconds. If your user takes too long to approve a transaction, it'll fail with BlockhashNotFound. Solution: fetch a fresh blockhash right before sending, not when building the UI.

2. Forgetting to fund accounts

Before writing to an account, it must exist and hold enough lamports for rent. New accounts need explicit creation instructions. Nothing is implicit.

3. Ignoring compute limits

Each transaction has a compute budget (default: 200,000 compute units). Complex operations — especially cross-program invocations — can exceed this. Use ComputeBudgetProgram.setComputeUnitLimit() to increase.

4. Assuming synchronous behavior

sendTransaction returns immediately after broadcast. The transaction may take 400ms to a few seconds to confirm. Always await confirmation before updating UI state.


A Complete Example: Token Transfer

Here's a real-world example that combines multiple concepts — transferring an SPL token between accounts:

import {
  getOrCreateAssociatedTokenAccount,
  transfer as transferToken,
  getMint,
} from "@solana/spl-token";
import { Connection, PublicKey, Keypair } from "@solana/web3.js";

const connection = new Connection("https://api.devnet.solana.com", "confirmed");

async function transferSPLToken(
  senderKeypair: Keypair,
  recipientWallet: PublicKey,
  mintAddress: PublicKey,
  amount: number
) {
  // Get token mint info (to know decimals)
  const mint = await getMint(connection, mintAddress);

  // Get or create token accounts for sender and recipient
  const senderTokenAccount = await getOrCreateAssociatedTokenAccount(
    connection,
    senderKeypair,    // payer for account creation
    mintAddress,
    senderKeypair.publicKey
  );

  const recipientTokenAccount = await getOrCreateAssociatedTokenAccount(
    connection,
    senderKeypair,    // payer (sender pays to create recipient's token account)
    mintAddress,
    recipientWallet
  );

  // Transfer tokens
  const signature = await transferToken(
    connection,
    senderKeypair,
    senderTokenAccount.address,
    recipientTokenAccount.address,
    senderKeypair.publicKey,
    amount * Math.pow(10, mint.decimals) // adjust for decimals
  );

  console.log(`Token transfer confirmed: ${signature}`);
  return signature;
}
Enter fullscreen mode Exit fullscreen mode

Notice how we explicitly manage the token accounts. In Web2, you'd just say "send 10 USDC to Alice." In Solana, you: look up the USDC mint, find (or create) Alice's USDC token account, then transfer. More steps, but fully transparent.


What's Next

Now that you understand the transaction model, the natural next steps are:

  • Anchor framework — abstracts away most of the boilerplate for writing Solana programs
  • Versioned transactions — supports address lookup tables for transactions with many accounts
  • Cross-Program Invocations (CPIs) — calling one program from another, composability's core primitive

The mental model shift is the hard part. Once you internalize that Solana is a global state machine where you submit signed state transitions (not server requests), the rest starts to click.

The full code examples from this article are available as a runnable repo — drop questions in the comments if anything doesn't make sense.


This article is part of a series on Web3 tooling and DeFi infrastructure. Previous articles covered Bungee exchange aggregation and Lume's Lightning/Solana bridge mechanics.

Top comments (0)