Query a devnet wallet balance in SOL and lamports, inspect transaction fees. Why Solana uses integer lamports end-to-end, and why you must never cast bigint balances to Number.
Part of my 100 Days of Solana challenge (MLH).
Goal
Query a devnet wallet's balance in both SOL and lamports, then pull recent transactions and inspect their fees. Reuse the wallet.json from Day 002, or pass any devnet address on the CLI.
Key things I learned
1. 1 SOL = 1,000,000,000 lamports
The lamport is Solana's indivisible unit — 10⁻⁹ SOL. Everything on-chain is denominated in lamports. SOL is just a display convenience.
const LAMPORTS_PER_SOL = 1_000_000_000n;Note the n — bigint literal. That matters.
2. getBalance returns a bigint. Never cast it to Number.
JavaScript Number loses precision past 2⁵³. Solana balances can easily exceed that (in lamports). Cast to Number and you silently corrupt the value.
const { value: lamports } = await rpc.getBalance(address).send();
// lamports is a bigint — keep it that way for math.For display, do integer math with bigint:
function formatSol(lamports) {
const whole = lamports / LAMPORTS_PER_SOL;
const frac = lamports % LAMPORTS_PER_SOL;
return `${whole}.${frac.toString().padStart(9, '0')}`;
}Divide to get whole SOL, modulo to get the remaining lamports, pad to 9 digits. No float, no rounding, no surprises.
3. Why integer lamports (and not floats)?
Validators must agree byte-for-byte on the state transition. Floating point is non-deterministic across hardware and compilers — two validators could land on slightly different numbers and break consensus. Integer lamports dodge the whole problem.
This is the same reason Bitcoin uses satoshis. Money + floats = pain.
4. Transaction fees live on tx.meta.fee
Fetch a signature's transaction, read meta.fee — that's the fee in lamports. The base fee is 5000 lamports per signature.
const sigs = await rpc.getSignaturesForAddress(address, { limit: 5 }).send();
for (const { signature, slot } of sigs) {
const tx = await rpc
.getTransaction(signature, {
commitment: 'confirmed',
maxSupportedTransactionVersion: 0,
})
.send();
if (!tx) continue;
console.log(`slot ${slot} fee: ${tx.meta.fee} lamports`);
}The maxSupportedTransactionVersion: 0 is required or versioned transactions blow up the call.
5. CLI sanity check
The solana CLI gives you a second opinion:
solana balance --url devnet
solana balance --url devnet --lamportsSame address, same number — just in different units.
Gotchas
- Don't mix
bigintandnumberin arithmetic — TypeError. Keep everythingbigintuntil the final format step. getTransactionreturnsnullfor dropped or still-processing signatures. Skip them.- Run with Node, not Bun —
@solana/rpcusessetMaxListeners(n, AbortSignal)which Bun doesn't implement.
Full script
import { readFile } from 'node:fs/promises';
import {
address as toAddress,
createKeyPairSignerFromBytes,
createSolanaRpc,
devnet,
} from '@solana/kit';
const LAMPORTS_PER_SOL = 1_000_000_000n;
const arg = process.argv[2];
const rpc = createSolanaRpc(devnet('https://api.devnet.solana.com'));
async function resolveAddress() {
if (arg) return toAddress(arg);
const raw = await readFile('../day002-persistent-wallet/wallet.json', 'utf8');
const bytes = Uint8Array.from(JSON.parse(raw));
const signer = await createKeyPairSignerFromBytes(bytes);
return signer.address;
}
function formatSol(lamports) {
const whole = lamports / LAMPORTS_PER_SOL;
const frac = lamports % LAMPORTS_PER_SOL;
return `${whole}.${frac.toString().padStart(9, '0')}`;
}
const address = await resolveAddress();
console.log('Address:', address);
const { value: lamports } = await rpc.getBalance(address).send();
console.log(`Lamports: ${lamports}`);
console.log(`SOL: ${formatSol(lamports)}`);
const sigs = await rpc.getSignaturesForAddress(address, { limit: 5 }).send();
for (const { signature, slot } of sigs) {
const tx = await rpc
.getTransaction(signature, {
commitment: 'confirmed',
maxSupportedTransactionVersion: 0,
})
.send();
if (!tx) continue;
console.log(`slot ${slot} sig ${signature.slice(0, 16)}...`);
console.log(` fee: ${tx.meta.fee} lamports = ${formatSol(tx.meta.fee)} SOL`);
}Takeaway
Lamports are the truth; SOL is UI. Keep balances as bigint end-to-end, format only at the display boundary. Fees are visible on every transaction via meta.fee — 5000 lamports per signature is your baseline.