Week 4 • Rpc Reads And Account Decoding With Kit
RPC Reads & Account Decoding with Kit
Fetch and decode accounts with a clean helper-first pattern using @solana/kit.
Step 1: 0-to-1 Theory
Primitives
Address: typed account identifierMaybeEncodedAccount: account that may or may not existexistsinvariant: always check before decode/use
Mental Model
On Solana, “state” lives inside accounts. An account is just:
lamports(SOL balance)owner(which program owns the data layout)data(raw bytes)
RPC reads return raw facts. Your client job is to normalize those facts into a UI-safe shape.
Invariants
- A missing account is a valid state, not a runtime surprise.
- Fetch and decode are two separate responsibilities.
- UI consumes normalized outputs, not raw RPC blobs.
Quick Checks
- Why does
existsmatter before decoding? - What bug appears if UI parses raw RPC output directly?
Step 2: Real-World Implementation (Code Solution)
Create lib/solana/rpc.ts:
import {
address,
createSolanaRpc,
fetchEncodedAccount,
type Address,
} from "@solana/kit";
import { SOLANA_RPC } from "@/lib/solana/config";
// Create one RPC client pointing at your configured endpoint (devnet by default).
const rpc = createSolanaRpc(SOLANA_RPC);
export type AccountView =
| { exists: false; address: string }
| {
exists: true;
address: string;
lamports: string;
executable: boolean;
owner: string;
dataLength: number;
};
export async function fetchAccountView(input: string): Promise<AccountView> {
// Convert a string into a strongly typed Address (throws if invalid).
const addr = address(input) as Address;
// Fetch raw account facts from the RPC endpoint.
const account = await fetchEncodedAccount(rpc, addr);
if (!account.exists) {
// Missing account is a valid result: wrong cluster or account never created.
return { exists: false, address: input };
}
return {
exists: true,
address: input,
// Keep values UI-friendly: convert bigints to strings.
lamports: account.lamports.toString(),
executable: account.executable,
owner: account.owner.toString(),
dataLength: account.data.length,
};
}Create components/solana/AccountInspector.tsx:
"use client";
import React, { useState } from "react";
import { fetchAccountView } from "@/lib/solana/rpc";
export function AccountInspector() {
const [pubkey, setPubkey] = useState("");
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
const onFetch = async () => {
setLoading(true);
try {
// Trim whitespace so copy/pasted addresses work reliably.
setResult(await fetchAccountView(pubkey.trim()));
} finally {
setLoading(false);
}
};
return (
<div className="space-y-3">
<input value={pubkey} onChange={(e) => setPubkey(e.target.value)} className="w-full px-3 py-2 rounded bg-zinc-900" placeholder="Account address" />
<button onClick={onFetch} disabled={loading} className="px-3 py-2 rounded bg-cyan-600 text-white">
{loading ? "Loading..." : "Fetch account"}
</button>
{/* Show raw JSON so beginners can see what the chain returned. */}
{result && <pre className="text-xs bg-zinc-950 p-3 rounded">{JSON.stringify(result, null, 2)}</pre>}
</div>
);
}Expected result:
- existing account returns structured fields
- unknown account returns
{ exists: false }
Step 3: Mastery Test
- Easy: why is
exists: falsebetter than throwing by default? - Medium: how do you batch-fetch many accounts without losing mapping clarity?
- Hard: how would you cache account reads and invalidate them safely after writes?