Here's the deal: your private keys are like the nuclear codes to your Solana wallet. Once they're online, hackers have a shot at 'em. Offline signing? That's signing transactions on a machine with zero internet. Air gapped. No exposure. I usually do this for big transfers - like moving 10 SOL or more. Why does this matter? One slip up online and poof, funds gone.
But it's not just for whales. Even small stuff benefits. Think hardware wallets or multi sig setups where signers are in different spots. Sound familiar? You'll create the tx online, sign offline, then broadcast from anywhere.
Easy, right? But blockhashes expire fast - about 150 blocks, roughly 1-2 minutes. That's the catch. Fix? Durable nonces. More on that later.
Okay, let's code. You need Node.js, @solana/web3.js, tweetnacl, bs58. npm install those. This is a basic Alice to Bob transfer, feePayer separate.
In my experience, start on devnet. Fees are zero there mostly, but mainnet's like 0.000005 SOL per signature. Tiny.
import { Connection, Keypair, Transaction, SystemProgram, LAMPORTSPERSOL, clusterApiUrl } from "@solana/web3.js";
import * as nacl from "tweetnacl";
import * as bs58 from "bs58"; const connection = new Connection(clusterApiUrl("devnet")); // Generate keys (in real life, load from secure file)
const feePayer = Keypair.generate();
const alice = Keypair.generate();
const bob = Keypair.generate(); // Airdrop for testing
await connection.requestAirdrop(feePayer.publicKey, LAMPORTSPERSOL);
await connection.requestAirdrop(alice.publicKey, LAMPORTSPERSOL); // 1. Build tx
const tx = new Transaction().add( SystemProgram.transfer({ fromPubkey: alice.publicKey, toPubkey: bob.publicKey, lamports: 0.1 * LAMPORTSPERSOL, // 0.1 SOL })
); tx.recentBlockhash = (await connection.getRecentBlockhash()).blockhash;
tx.feePayer = feePayer.publicKey; // 2. Get message to sign (this is what goes offline)
const messageBytes = tx.serializeMessage();
Now copy that messageBytes to your offline machine. How? USB drive. QR code for small txs. Whatever. No network.
Offline machine. Load your keys from a file - never type 'em. Here's the sign code:
// Offline - pure signing
const feePayerSig = nacl.sign.detached(messageBytes, feePayer.secretKey);
const aliceSig = nacl.sign.detached(messageBytes, alice.secretKey); // Verify first (good habit)
const feeVerify = nacl.sign.detached.verify(messageBytes, feePayerSig, feePayer.publicKey.toBytes());
const aliceVerify = nacl.sign.detached.verify(messageBytes, aliceSig, alice.publicKey.toBytes());
console.log('Fee verify:', feeVerify); // true
console.log('Alice verify:', aliceVerify); // Serialize sigs as base58 for easy copy paste
const feeSigB58 = bs58.encode(feePayerSig);
const aliceSigB58 = bs58.encode(aliceSig);
console.log('Fee sig:', feeSigB58);
console.log('Alice sig:', aliceSigB58);
Copy those base58 sigs back online. Boom.
Back online. Paste the messageBytes, pubkeys, and sigs:
// Recover tx two ways - pick one
// Way 1: populate then add sigs
let recoveredTx = Transaction.populate(Message.from(messageBytes));
recoveredTx.addSignature(feePayer.publicKey, Buffer.from(feePayerSig));
recoveredTx.addSignature(alice.publicKey, Buffer.from(aliceSig)); // Way 2: populate with sig array (base58)
let recoveredTx2 = Transaction.populate(Message.from(messageBytes), [ feeSigB58, aliceSigB58
]); // Send raw
const txid = await connection.sendRawTransaction(recoveredTx.serialize());
console.log('Txid:', txid);
Test it. Works? Great. But if you wait too long.. blockhash dead. "Transaction expired" error. Happens all the time first try.
Say Bob wants to send Alice 1 token, but only after she pays 0.01 SOL. Bob partially signs first. Alice verifies, adds her sig, sends.
The thing is, this is perfect for escrow or swaps. Here's Bob's side:
import { createTransferCheckedInstruction, getAssociatedTokenAddress, getMint, getOrCreateAssociatedTokenAccount } from "@solana/spl token"; const tokenMint = new PublicKey("Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr"); // Some token
const alicePubkey = new PublicKey("5YNmS1R9nNSCDzb5a7mMJ1dwK9uHeAAF4CmPEwKgVWr8");
const bobKeypair = Keypair.fromSecretKey(bs58.decode('yourbobkey')); // Bob's token account
const bobTokenAcct = await getAssociatedTokenAddress(tokenMint, bobKeypair.publicKey);
const aliceTokenAcct = await getOrCreateAssociatedTokenAccount(connection, bobKeypair, tokenMint, alicePubkey); // Build tx
const tx = new Transaction({ recentBlockhash: (await connection.getLatestBlockhash()).blockhash, feePayer: alicePubkey });
tx.add(SystemProgram.transfer({ fromPubkey: alicePubkey, toPubkey: bobKeypair.publicKey, lamports: 0.01 * LAMPORTSPERSOL }));
tx.add(createTransferCheckedInstruction(bobTokenAcct, tokenMint, aliceTokenAcct.address, bobKeypair.publicKey, 1 * 109, 9)); // 1 token, 9 decimals // Bob partial signs
tx.partialSign(bobKeypair); // Serialize WITHOUT all sigs
const serialized = tx.serialize({ requireAllSignatures: false });
const base64Tx = serialized.toString('base64');
console.log('Send this base64 to Alice:', base64Tx);
Alice gets the base64, deserializes:
const unsignedTx = Transaction.from(Buffer.from(base64Tx, 'base64'));
unsignedTx.partialSign(aliceKeypair); // Alice signs
const fullSerialized = unsignedTx.serialize();
const txid = await connection.sendRawTransaction(fullSerialized);
Pretty much escrow without smart contracts. Love it.
| Regular Blockhash | Durable Nonce |
|---|---|
| Expires ~2 min | Valid until used/expired by you |
| Network fetches | You create account first |
| Simple | Extra setup (one time) |
| Offline tricky | Offline friendly |
async function setupNonce() { const nonceKeypair = Keypair.generate(); await connection.requestAirdrop(nonceKeypair.publicKey, 0.002 * LAMPORTSPERSOL); const nonceIx = SystemProgram.createNonceAccount({ fromPubkey: feePayer.publicKey, noncePubkey: nonceKeypair.publicKey, authorizedPubkey: feePayer.publicKey, // Who advances nonce lamports: await connection.getMinimumBalanceForRentExemption(80) // Nonce account size }); const setupTx = new Transaction().add(nonceIx); setupTx.recentBlockhash = (await connection.getRecentBlockhash()).blockhash; setupTx.feePayer = feePayer.publicKey; setupTx.partialSign(feePayer, nonceKeypair); await connection.sendTransaction(setupTx); return nonceKeypair.publicKey;
}
Now use it in tx:
const nonceAccount = await setupNonce();
const nonceState = await connection.getNonceAccount(nonceAccount);
const durableHash = nonceState.value.blockhash; // Your eternal hash // Build tx with nonce instead of recentBlockhash
tx.recentBlockhash = durableHash;
tx.add(SystemProgram.advanceNonceAccount(nonceAccount, feePayer.publicKey)); // Advance after // Sign offline same way..
// Send. No expiry!
Pro tip: Nonce authority signs the advance. Keeps control.
Don't wanna code? Solana CLI. Install with sh -c "$(curl -sSfL https://release.solana.com/stable/install)".
Offline sign:
solana transfer bobpubkey 0.1 --blockhash YOUR_BLOCKHASH --sign only --fee payer yourfee.jsonOnline submit:
solana transfer bobpubkey 0.1 --blockhash SAME_BLOCKHASH --signer YOURPUBKEY=YOURSIGHASHWorks with hardware wallets too. --signer usb://ledger. I use this for quick tests.
Quick Python detour. Same flow.
from solana.rpc.async_api import AsyncClient
from solders.keypair import Keypair
from solders.system_program import transfer
from solders.transaction import VersionedTransaction
from solders.message import MessageV0 connection = AsyncClient("https://api.devnet.solana.com")
fee_payer = Keypair()
alice = Keypair()
bob = Keypair() # Build message
msg = MessageV0.trycompile( payer=feepayer.pubkey(), instructions=[transfer(TransferParams(frompubkey=alice.pubkey(), topubkey=bob.pubkey(), lamports=int(0.1*109)))], addresslookuptableaccounts=[], recentblockhash=(await connection.getlatestblockhash()).value.blockhash
) # Offline sign
tx = VersionedTransaction(msg, [fee_payer, alice])
serialized = bytes(tx) # Save to file # Online recover/send
recovered = VersionedTransaction.from_bytes(serialized)
sig = await connection.send_transaction(recovered)
Clean. pip install solana py solders.
Been there. Here's what bites newbies.
Oh, and decimals! Tokens have 'em. 1 USDC = 106 lamports. Miss it, send dust.
My rig: Old laptop, no wifi card, Ubuntu live USB. Keys on encrypted USB. Sign, copy to phone QR, scan to online machine. For big $: Ledger + solana CLI.
Multi sig? Use Squads or something. Collect partial sigs via email/USB. Each verifies before passing on.
Pro move: Batch multiple instructions. One tx, multiple actions. Saves fees.
That's it. Practice once, you'll get it. Hit issues? Common stuff above. Go make secure txs, buddy.
(