learn.sol
Solana Foundations • Program Interaction Project
Lesson 6 of 7

Build a Solana Devnet Toolkit with Kit

Build a small TypeScript CLI with @solana/kit to read balances, inspect accounts, and send SOL on devnet using the modern Solana client stack.

A lot of older Solana tutorials still teach client code with @solana/web3.js.

That is no longer the right default for new code.

For new scripts and clients, @solana/web3.js 1.x is the legacy path. The modern low-level SDK is @solana/kit, which is the renamed 2.x direction for Solana JavaScript development.

So this project will not teach the old client surface first and fix it later. We will build with @solana/kit from the start.

Info

This project does not ask you to write an on-chain program yet. The goal is to build a small off-chain toolkit that can talk to devnet, inspect state, and send a real transaction.

RPC ClientTap to reveal
The client object that sends read requests and transaction-related requests to a Solana RPC endpoint.
RPC Client
SignerTap to reveal
A wallet or keypair capable of authorizing a transaction by producing a valid cryptographic signature.
Signer
Fee PayerTap to reveal
The account that covers the network fee for a transaction. In a simple transfer, this is usually the sender.
Fee Payer
Blockhash LifetimeTap to reveal
The freshness window attached to a transaction. If confirmation takes too long, the transaction can expire and must be rebuilt.
Blockhash Lifetime

What You Are Building

By the end of this project, you will have a tiny CLI toolkit with three commands:

  • balance: read the balance of any address
  • inspect: fetch raw account facts and print them clearly
  • send: send SOL from one wallet to another on devnet

That is enough for a strong foundations project because it teaches three real client responsibilities:

  1. reading chain state
  2. understanding account shape
  3. building and sending a transaction correctly

The Mental Model for This Project

Do not think of this toolkit as “three random scripts.”

Think of it as one small client with three jobs:

  • connect to an RPC endpoint
  • turn user input into typed Solana values
  • either read state or produce a signed transaction

That is the same pattern you will keep using later in frontend code, backend services, and Anchor clients.


What You Need Before You Start

You should already have:

  • Node.js installed
  • pnpm available on your machine
  • the Solana CLI set to devnet
  • a funded wallet from the earlier lessons in this section, such as wallet1.json
  • a second wallet address to receive SOL, such as the address from wallet2.json

If you are not sure those are ready, stop and re-run the earlier setup and account lessons first.


The Final Project Shape

We are going to build this exact file structure:

kit-toolkit/
  package.json
  tsconfig.json
  scripts/
    lib/
      solana.ts
    balance.ts
    inspect.ts
    send.ts

Keep it small.

This is not the time to build a framework. This is the time to make the client model feel normal.


Step 1: Scaffold the Toolkit

Create a fresh project directory and install the dependencies.

terminal
mkdir kit-toolkit
cd kit-toolkit
pnpm init
pnpm add @solana/kit @solana-program/system
pnpm add -D typescript tsx @types/node

Why these packages:

  • @solana/kit gives you the modern RPC, signer, and transaction-building APIs
  • @solana-program/system gives you the System Program instruction builder for SOL transfers
  • tsx lets you run TypeScript files directly without adding extra build friction

Now create tsconfig.json:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["scripts/**/*.ts"]
}

This is a minimal config. It is enough for a small Node-based toolkit.


Step 2: Create the Shared Solana Helper

All three scripts need the same basic pieces:

  • a devnet RPC client
  • a devnet WebSocket subscriptions client for confirmation
  • a helper that loads a signer from a local keypair JSON file

Create scripts/lib/solana.ts:

scripts/lib/solana.ts
import { readFile } from "node:fs/promises";
import {
  createKeyPairSignerFromBytes,
  createSolanaRpc,
  createSolanaRpcSubscriptions,
} from "@solana/kit";

export const DEVNET_RPC_URL = "https://api.devnet.solana.com";
export const DEVNET_WS_URL = "wss://api.devnet.solana.com";

// One HTTP RPC client for read requests and transaction-building data.
export const rpc = createSolanaRpc(DEVNET_RPC_URL);

// One subscriptions client for waiting on transaction confirmation.
export const rpcSubscriptions = createSolanaRpcSubscriptions(DEVNET_WS_URL);

export async function loadSignerFromFile(path: string) {
  // Solana CLI keypair files are JSON arrays of secret key bytes.
  const file = await readFile(path, "utf8");
  const bytes = new Uint8Array(JSON.parse(file));

  // Convert raw bytes into a Kit signer that can actually sign transactions.
  return createKeyPairSignerFromBytes(bytes);
}

Why this file matters:

It keeps the RPC and signer setup in one place. If you scatter that logic across every script, the project gets harder to reason about for no benefit.


Step 3: Build the Balance Command

Start with the easiest possible read.

Create scripts/balance.ts:

scripts/balance.ts
import { address } from "@solana/kit";
import { rpc } from "./lib/solana";

async function main() {
  const input = process.argv[2];

  if (!input) {
    throw new Error("Usage: pnpm tsx scripts/balance.ts <ADDRESS>");
  }

  // Convert the raw string into a typed Solana address.
  const accountAddress = address(input);
  const { value: balanceInLamports } = await rpc.getBalance(accountAddress).send();

  console.log({
    address: input,
    lamports: balanceInLamports.toString(),
    sol: Number(balanceInLamports) / 1_000_000_000,
  });
}

main().catch((error) => {
  console.error("balance failed", error);
  process.exit(1);
});

Run it against the wallet you funded earlier:

terminal
pnpm tsx scripts/balance.ts $(solana address -k wallet1.json)
Debugging balance issues

If you see 0 lamports, verify your wallet is funded on devnet with solana balance -k wallet1.json. If you get an RPC error, check that devnet is reachable with solana cluster-version.

What this teaches:

  • how to create an RPC client with Kit
  • how to turn a string into an Address
  • how basic RPC reads work in the request.send() model

Step 4: Build the Account Inspector

Balance is only one fact.

Now inspect the account itself.

Create scripts/inspect.ts:

scripts/inspect.ts
import { address, fetchEncodedAccount } from "@solana/kit";
import { rpc } from "./lib/solana";

async function main() {
  const input = process.argv[2];

  if (!input) {
    throw new Error("Usage: pnpm tsx scripts/inspect.ts <ADDRESS>");
  }

  const accountAddress = address(input);
  const account = await fetchEncodedAccount(rpc, accountAddress);

  if (!account.exists) {
    console.log({ exists: false, address: input });
    return;
  }

  console.log({
    exists: true,
    address: input,
    lamports: account.lamports.toString(),
    owner: account.owner.toString(),
    executable: account.executable,
    dataLength: account.data.length,
  });
}

main().catch((error) => {
  console.error("inspect failed", error);
  process.exit(1);
});

Run it:

terminal
pnpm tsx scripts/inspect.ts $(solana address -k wallet2.json)

What to notice in the output:

  • lamports is the raw balance stored in the account
  • owner tells you which program owns the account layout
  • executable is false for a normal wallet account
  • dataLength is usually 0 for a plain system wallet

This is the same account model you already saw in the CLI lessons, now accessed through code.

Quick Check
Single answer

If `inspect.ts` shows `owner` as the System Program, `executable` as `false`, and `dataLength` as `0`, what are you usually looking at?


Step 5: Build the Send Command

This is the most important part of the project.

You are now moving from reads to writes. That means you need to:

  • load a signer
  • fetch a fresh blockhash
  • build a transaction message
  • append an instruction
  • sign the message
  • send it and wait for confirmation

Create scripts/send.ts:

scripts/send.ts
import {
  address,
  appendTransactionMessageInstructions,
  createTransactionMessage,
  getSignatureFromTransaction,
  lamports,
  pipe,
  sendAndConfirmTransactionFactory,
  setTransactionMessageFeePayerSigner,
  setTransactionMessageLifetimeUsingBlockhash,
  signTransactionMessageWithSigners,
} from "@solana/kit";
import { getTransferSolInstruction } from "@solana-program/system";
import { loadSignerFromFile, rpc, rpcSubscriptions } from "./lib/solana";

async function main() {
  const senderKeypairPath = process.argv[2];
  const recipientAddressInput = process.argv[3];
  const amountSolInput = process.argv[4];

  if (!senderKeypairPath || !recipientAddressInput || !amountSolInput) {
    throw new Error(
      "Usage: pnpm tsx scripts/send.ts <SENDER_KEYPAIR_PATH> <RECIPIENT_ADDRESS> <AMOUNT_SOL>"
    );
  }

  const sender = await loadSignerFromFile(senderKeypairPath);
  const recipientAddress = address(recipientAddressInput);
  const amountSol = Number(amountSolInput);

  if (!Number.isFinite(amountSol) || amountSol <= 0) {
    throw new Error("Amount must be a positive SOL value like 0.01");
  }

  // Convert SOL into lamports so the on-chain amount is explicit.
  const amountLamports = BigInt(Math.round(amountSol * 1_000_000_000));

  // Fetch a fresh blockhash before building the transaction message.
  const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();

  // Build the transfer instruction for the System Program.
  const transferInstruction = getTransferSolInstruction({
    source: sender,
    destination: recipientAddress,
    amount: lamports(amountLamports),
  });

  // Build the transaction in the correct order:
  // message -> fee payer -> lifetime -> instructions.
  const message = pipe(
    createTransactionMessage({ version: 0 }),
    (tx) => setTransactionMessageFeePayerSigner(sender, tx),
    (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
    (tx) => appendTransactionMessageInstructions([transferInstruction], tx)
  );

  // Sign after the message is fully built.
  const signedTransaction = await signTransactionMessageWithSigners(message);

  // Send the transaction bytes and wait for confirmation.
  const sendAndConfirm = sendAndConfirmTransactionFactory({
    rpc,
    rpcSubscriptions,
  });

  await sendAndConfirm(signedTransaction, { commitment: "confirmed" });

  console.log({
    signature: getSignatureFromTransaction(signedTransaction),
    from: sender.address,
    to: recipientAddress,
    amountSol,
  });
}

main().catch((error) => {
  console.error("send failed", error);
  process.exit(1);
});

Now run it using the wallet from your earlier lessons:

terminal
pnpm tsx scripts/send.ts wallet1.json $(solana address -k wallet2.json) 0.01

Then confirm the state change:

terminal
pnpm tsx scripts/balance.ts $(solana address -k wallet1.json)
pnpm tsx scripts/balance.ts $(solana address -k wallet2.json)
Debugging failed transactions

If the send fails with "insufficient funds," remember you need enough SOL to cover both the transfer amount and the transaction fee (~5000 lamports). Use pnpm tsx scripts/balance.ts to check your sender's balance first.

Blockhash expired

If you see "blockhash not found" or "transaction expired," the blockhash became stale before confirmation. This happens if there is a long delay between fetching the blockhash and sending. Re-run the command to get a fresh blockhash.

What this command teaches that the read scripts did not:

  • the fee payer must be explicit
  • transaction lifetime depends on a fresh blockhash
  • the message must be fully built before signing
  • sending and confirming are separate steps

The Client Pattern You Just Built

This project should not feel like three unrelated scripts.

It should feel like one small Solana client split into three commands.

Each command reuses the same foundation:

  • one RPC setup
  • one signer-loading path
  • one way to turn raw strings into typed Solana values

From there, the commands branch into two kinds of work:

  • read state with balance.ts and inspect.ts
  • write state with send.ts

That is the pattern that keeps showing up later in real Solana work.

A frontend does the same thing. A backend job does the same thing. An Anchor client does the same thing.

The surface area gets bigger, but the client model does not fundamentally change.


Common Mistakes

Using @solana/web3.js out of habit

For new low-level client code, prefer @solana/kit. If you keep learning through the legacy surface first, you will eventually have to unlearn the object-heavy API model anyway.

Forgetting that reads and writes are different kinds of work

Reading an account only needs a valid address and RPC access. Sending a transaction needs a signer, a fresh blockhash, a complete message, and a confirmation flow.

Signing too early

Once the transaction message is signed, you should not mutate it. Build the fee payer, lifetime, and instructions first. Sign last.

Treating the local keypair file like a harmless config file

wallet1.json contains secret key material. Never commit it, publish it, or paste it into random tools.


Summary

You built a real Solana client toolkit with the modern SDK direction:

  • one helper for RPC and signer loading
  • one script for balance reads
  • one script for account inspection
  • one script for signed SOL transfers

That means you can now do the three things every real Solana client must eventually do:

  • fetch state
  • interpret state
  • submit state changes

That is a much stronger outcome for this section than just knowing a few commands.

Sources


Quick Check

Quick Check
Single answer

Why do all three toolkit scripts share the same `scripts/lib/solana.ts` helper?

Quick Check
Single answer

Which extra requirements appear when you move from account reads to sending a transaction?

Solana Assistant

AI-powered documentation helper

Welcome to Solana Assistant

Ask specific questions about Solana development:

Ask specific questions for better results400px