Back to writing
April 22, 2026· 4 min read

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.

JAVASCRIPT
const LAMPORTS_PER_SOL = 1_000_000_000n;

Note the nbigint 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.

JAVASCRIPT
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:

JAVASCRIPT
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.

JAVASCRIPT
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:

BASH
solana balance --url devnet
solana balance --url devnet --lamports

Same address, same number — just in different units.

Gotchas

  • Don't mix bigint and number in arithmetic — TypeError. Keep everything bigint until the final format step.
  • getTransaction returns null for dropped or still-processing signatures. Skip them.
  • Run with Node, not Bun — @solana/rpc uses setMaxListeners(n, AbortSignal) which Bun doesn't implement.

Full script

JAVASCRIPT
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.

    — Writing