Okay, look. Most guides out there treat Solana transactions like some magic black box. They throw code at you without explaining why you sign the message first, or what happens if your blockhash goes stale. You'll end up with "transaction expired" errors and no clue why. The thing is, Solana's fast-transactions live or die in like 60 seconds. I usually tell friends: get the blockhash fresh, sign quick, send quicker. That's the real secret. Sound familiar? You've probably popped open Phantom a hundred times just to see it fail.
But we're fixing that today. This guide's for you if you're coding a dApp, automating trades, or just messing around on devnet. We'll do JS mostly-it's what I use daily. No fluff. Let's build something that actually works.
A transaction on Solana? It's basically a bundle: instructions (like "transfer 0.01 SOL"), a recent blockhash (your time ticket), and signatures proving you approve it. Without a sig, it fails instantly. The first sig's from the fee payer-that's you, covering like ~0.000005 SOL in fees.
Why sign? Proves ownership without sending your private everywhere. Your wallet (or keypair) hashes the message and signs it with your secret. Boom, authenticated.
In my experience, newbies mix up legacy vs versioned transactions. Legacy's old school; versioned (v0) handles address lookups better. Stick to v0 now-it's the future.
npm i @solana/web3.js.solana keygen new.solana airdrop 2 on CLI.new Connection("https://api.devnet.solana.com"). Mainnet? Swap to mainnet beta.That's it. No fancy RPCs yet. QuickNode or Helius if devnet's slow, but public works fine.
You're building a frontend? Use a wallet like Phantom. User connects, you build tx, they approve in popup. No private keys exposed-perfect.
First, install wallet adapter: npm i @solana/wallet adapter react @solana/wallet adapter phantom. Hook it up in your app.
const { blockhash } = await connection.getLatestBlockhash('finalized');
Super short. Why finalized? Less likely to expire.const tx = new Transaction().add( SystemProgram.transfer({ fromPubkey: wallet.publicKey, toPubkey: new PublicKey(' recipient pubkey here '), lamports: 10000000n, // BigInt for safety })
);
tx.recentBlockhash = blockhash;
tx.feePayer = wallet.publicKey;const serializedTx = tx.serialize({ requireAllSignatures: false });
const base64Tx = serializedTx.toString('base64');
Wallet needs base64.const signed = await wallet.signTransaction(tx);
Or raw provider: provider.request({ method: 'signTransaction', params: { transaction: base64Tx } }).const sig = await connection.sendRawTransaction(signed.serialize());
await connection.confirmTransaction(sig);
Check explorer: solana.fm/tx/[sig].Done. User sees popup, clicks approve, tx flies. Fees? Tiny, like 0.000005 SOL base + compute units.
Potential issue: "Blockhash not found." Happens if you wait too long. Fix? Fetch blockhash right before signing. Or use durable nonces for offline signing-more on that later.
Scripts? Bots? No UI needed. Load your keypair, sign offline, send. Risky-never expose keys in frontend.
I usually do this for testing. Generate keypair:
const payer = Keypair.fromSecretKey(Uint8Array.from([your 64-byte secret]));
Full script for 0.01 SOL transfer:
import { Connection, Keypair, PublicKey, SystemProgram, Transaction, LAMPORTSPERSOL } from '@solana/web3.js'; const connection = new Connection('https://api.devnet.solana.com');
const payer = Keypair.fromSecretKey(/ your bytes /);
const recipient = new PublicKey('AaYFExyZuMHbJHzjimKyQBAH1yfA9sKTxSzBc6Nr5X4s'); async function sendSOL() { const { blockhash } = await connection.getLatestBlockhash('finalized'); const tx = new Transaction().add( SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: recipient, lamports: LAMPORTSPERSOL / 100n, // 0.01 SOL }) ); tx.recentBlockhash = blockhash; tx.feePayer = payer.publicKey; const sig = await connection.sendTransaction(tx, [payer]); await connection.confirmTransaction(sig); console.log(Sig: ${sig});
} sendSOL(); Boom. Runs in Node. sendTransaction auto signs with [payer]. Simpler than manual.
Pro tip: For partial signing (multi sig), use tx.partialSign(keypair), serialize, send to next signer.
Legacy txs max out at basic stuff. Versioned (v0) add "address table lookups"-pack more accounts without bloating size. Fees same, but scales for complex swaps.
Quick swap:
import { VersionedTransaction, TransactionMessage } from '@solana/web3.js'; const messageV0 = new TransactionMessage({ payerKey: payer.publicKey, recentBlockhash: blockhash, instructions: [transferIx], // your ix here
}).compileToV0Message(); const tx = new VersionedTransaction(messageV0);
tx.sign([payer]);
const sig = await connection.sendTransaction(tx, { skipPreflight: false }); Why skipPreflight false? Simulates tx first, catches dumb errors. Takes extra sec, worth it.
| Error | Why? | Fix |
|---|---|---|
| Blockhash not found | Expired (60s window) | Refetch right before sign. Use 'finalized' commitment. |
| Insufficient funds | Forgot rent/fees | Check balance: connection.getBalance(). Need ~0.00089 SOL rent min + tx fee. |
| Signature verification failed | Wrong or tampered tx | Don't deserialize wrong. Use fresh serialization. |
| Program error 0x1 | Invalid instruction | Log compiled message: getCompiledTransactionMessageDecoder().decode(tx.messageBytes) |
Honestly, 90% of headaches are blockhash or funds. Test on devnet first-airdrop spam away.
Paranoid? Sign offline. Build tx on online machine, serialize base64, USB to air gapped signer, sign, send back.
tx.serialize({requireAllSignatures: false}).toString('base64').const unsignedTx = VersionedTransaction.deserialize(Buffer.from(base64, 'base64')); unsignedTx.sign([keypair]); Serialize signed.Blockhash expires fast, so use durable nonce. Create nonce account (costs ~0.0015 SOL rent), set tx.recentBlockhash to nonce pubkey. Lives hours. CLI: solana nonce advance.
In code: Get nonce: await getNonce(connection, payer.publicKey, { nonceAccount: nonceKey });. Set tx.nonce = nonce.value.uiAmount?.toString().
Team wallet? Multiple signers. Build tx, partialSign(first), base64 to second, they partialSign, etc.
tx.partialSign(signer1);
const base64 = tx.serialize({requireAllSignatures: false}).toString('base64');
// Send to signer2..
const recovered = Transaction.from(Buffer.from(base64, 'base64'));
recovered.partialSign(signer2);
connection.sendRawTransaction(recovered.serialize()); Check readiness: tx.verifySignatures(). Throws if missing.
What's next for complex? Program derived addresses (PDAs)-no sig needed if program's the signer.
window.solana.signTransaction(tx). Returns signed tx.signTransaction({ transaction: base64Tx, wallet }). Server side too.walletProvider.sendTransaction(tx, connection). Handles modal.In my experience, test each wallet-some picky on versioned txs.
Base fee: 5000 lamports (~0.000005 SOL). Multiplies by loaded units. Transfer? 1ku. Swaps? 100s k.
Priority fees: Add ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1000000n }) first ix. Bumps you up queue-pays extra ~0.0001 SOL on busy nets.
Don't hardcode RPCs-rotate endpoints. Helius free tier rocks for webhooks.
Batch txs? Simulate first: connection.simulateTransaction(signedTx). Catches 80% issues.
Errors? Always log full tx: console.log(bs58.encode(signedTx.serialize())). Explorer gold.
Scale? Address lookups in v0 txs. Lookup table preloads accounts, saves sig space.
Question: Automating? Use keypair JSON files, never commit secrets. dotenv ftw.
That's your toolkit. Grab a keypair, fire up devnet, transfer some dust. Tweak, break, fix. You'll be signing like a pro. Hit snags? Common ones covered. Go build.