API Design
Building robust APIs for Solana applications requires handling blockchain-specific concerns like transaction states, async operations, and data consistency.
API Architecture
Text
┌─────────────────────────────────────────────────────────────────┐
│ API Architecture │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Client │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ API Gateway │ │
│ │ • Rate Limiting • Auth • Validation • Logging │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ├──────────────────┬──────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ REST │ │GraphQL │ │WebSocket│ │
│ │ API │ │ API │ │ API │ │
│ └────────┘ └────────┘ └────────┘ │
│ │ │ │ │
│ └──────────────────┼──────────────────┘ │
│ │ │
│ ┌──────────────────┼──────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │Solana │ │Database│ │ Cache │ │
│ │ RPC │ │ │ │(Redis) │ │
│ └────────┘ └────────┘ └────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
REST API Design
TypeScript
import express from "express";
import { Connection, PublicKey, Transaction } from "@solana/web3.js";
import { Pool } from "pg";
import Redis from "ioredis";
import { z } from "zod";
const app = express();
app.use(express.json());
// Dependency injection
interface AppContext {
connection: Connection;
db: Pool;
redis: Redis;
}
const ctx: AppContext = {
connection: new Connection(process.env.RPC_URL!),
db: new Pool({ connectionString: process.env.DATABASE_URL }),
redis: new Redis(process.env.REDIS_URL),
};
// ==================== Validation Schemas ====================
const AddressSchema = z.string().refine(
(val) => {
try {
new PublicKey(val);
return true;
} catch {
return false;
}
},
{ message: "Invalid Solana address" },
);
const PaginationSchema = z.object({
limit: z.coerce.number().min(1).max(100).default(20),
cursor: z.string().optional(),
});
const TransferSchema = z.object({
to: AddressSchema,
amount: z.number().positive(),
currency: z.enum(["SOL", "USDC"]),
});
// ==================== Middleware ====================
// Error handling middleware
function errorHandler(err: any, req: any, res: any, next: any) {
console.error(err);
if (err instanceof z.ZodError) {
return res.status(400).json({
error: "Validation error",
details: err.errors,
});
}
if (err.name === "NotFoundError") {
return res.status(404).json({ error: err.message });
}
res.status(500).json({ error: "Internal server error" });
}
// Request validation middleware
function validate(schema: z.ZodType) {
return (req: any, res: any, next: any) => {
try {
req.validated = schema.parse(req.body);
next();
} catch (err) {
next(err);
}
};
}
// Caching middleware
function cache(ttl: number) {
return async (req: any, res: any, next: any) => {
const key = `cache:${req.originalUrl}`;
const cached = await ctx.redis.get(key);
if (cached) {
return res.json(JSON.parse(cached));
}
// Store original json method
const originalJson = res.json.bind(res);
res.json = (data: any) => {
ctx.redis.setex(key, ttl, JSON.stringify(data));
return originalJson(data);
};
next();
};
}
// ==================== Routes ====================
// Get account info
app.get("/api/accounts/:address", cache(30), async (req, res, next) => {
try {
const address = AddressSchema.parse(req.params.address);
const pubkey = new PublicKey(address);
const [accountInfo, balance] = await Promise.all([
ctx.connection.getAccountInfo(pubkey),
ctx.connection.getBalance(pubkey),
]);
if (!accountInfo) {
throw { name: "NotFoundError", message: "Account not found" };
}
res.json({
address,
lamports: balance,
owner: accountInfo.owner.toBase58(),
executable: accountInfo.executable,
rentEpoch: accountInfo.rentEpoch,
dataLength: accountInfo.data.length,
});
} catch (err) {
next(err);
}
});
// Get token accounts
app.get("/api/accounts/:address/tokens", cache(30), async (req, res, next) => {
try {
const address = AddressSchema.parse(req.params.address);
const pubkey = new PublicKey(address);
const tokenAccounts = await ctx.connection.getParsedTokenAccountsByOwner(
pubkey,
{
programId: new PublicKey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"),
},
);
const tokens = tokenAccounts.value.map((ta) => ({
address: ta.pubkey.toBase58(),
mint: ta.account.data.parsed.info.mint,
balance: ta.account.data.parsed.info.tokenAmount.uiAmount,
decimals: ta.account.data.parsed.info.tokenAmount.decimals,
}));
res.json({ tokens });
} catch (err) {
next(err);
}
});
// Get transaction history
app.get("/api/accounts/:address/transactions", async (req, res, next) => {
try {
const address = AddressSchema.parse(req.params.address);
const { limit, cursor } = PaginationSchema.parse(req.query);
const pubkey = new PublicKey(address);
const signatures = await ctx.connection.getSignaturesForAddress(pubkey, {
limit,
before: cursor,
});
const transactions = await Promise.all(
signatures.map(async (sig) => {
const tx = await ctx.connection.getParsedTransaction(sig.signature, {
maxSupportedTransactionVersion: 0,
});
return {
signature: sig.signature,
slot: sig.slot,
blockTime: sig.blockTime,
err: sig.err,
memo: sig.memo,
fee: tx?.meta?.fee,
};
}),
);
const nextCursor =
signatures.length === limit
? signatures[signatures.length - 1].signature
: null;
res.json({
transactions,
pagination: {
limit,
nextCursor,
hasMore: nextCursor !== null,
},
});
} catch (err) {
next(err);
}
});
// Create transfer transaction (returns unsigned transaction)
app.post(
"/api/transactions/transfer",
validate(TransferSchema),
async (req, res, next) => {
try {
const { to, amount, currency } = req.validated;
const from = new PublicKey(req.user.address);
const { blockhash, lastValidBlockHeight } =
await ctx.connection.getLatestBlockhash();
const transaction = new Transaction({
recentBlockhash: blockhash,
feePayer: from,
});
if (currency === "SOL") {
transaction.add(
SystemProgram.transfer({
fromPubkey: from,
toPubkey: new PublicKey(to),
lamports: amount * 1e9,
}),
);
} else {
// USDC transfer
const usdcMint = new PublicKey(process.env.USDC_MINT!);
const fromAta = await getAssociatedTokenAddress(usdcMint, from);
const toAta = await getAssociatedTokenAddress(
usdcMint,
new PublicKey(to),
);
transaction.add(
createTransferInstruction(fromAta, toAta, from, amount * 1e6),
);
}
// Serialize for client signing
const serialized = transaction
.serialize({ requireAllSignatures: false })
.toString("base64");
// Create pending transaction record
const txId = crypto.randomUUID();
await ctx.db.query(
`INSERT INTO pending_transactions (id, user_address, to_address, amount, currency, blockhash, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
txId,
from.toBase58(),
to,
amount,
currency,
blockhash,
new Date(Date.now() + 60000), // 60 second expiry
],
);
res.json({
id: txId,
transaction: serialized,
blockhash,
lastValidBlockHeight,
expiresAt: new Date(Date.now() + 60000).toISOString(),
});
} catch (err) {
next(err);
}
},
);
// Submit signed transaction
app.post("/api/transactions/:id/submit", async (req, res, next) => {
try {
const { id } = req.params;
const { signedTransaction } = req.body;
// Get pending transaction
const result = await ctx.db.query(
"SELECT * FROM pending_transactions WHERE id = $1 AND user_address = $2",
[id, req.user.address],
);
if (result.rows.length === 0) {
throw { name: "NotFoundError", message: "Transaction not found" };
}
const pending = result.rows[0];
if (new Date() > new Date(pending.expires_at)) {
throw new Error("Transaction expired");
}
// Decode and send
const buffer = Buffer.from(signedTransaction, "base64");
const signature = await ctx.connection.sendRawTransaction(buffer, {
skipPreflight: false,
preflightCommitment: "confirmed",
});
// Update record
await ctx.db.query(
`UPDATE pending_transactions SET signature = $1, status = 'submitted' WHERE id = $2`,
[signature, id],
);
// Start confirmation tracking
trackConfirmation(id, signature);
res.json({
id,
signature,
status: "submitted",
});
} catch (err) {
next(err);
}
});
// Get transaction status
app.get("/api/transactions/:id", async (req, res, next) => {
try {
const { id } = req.params;
const result = await ctx.db.query(
"SELECT * FROM pending_transactions WHERE id = $1",
[id],
);
if (result.rows.length === 0) {
throw { name: "NotFoundError", message: "Transaction not found" };
}
const tx = result.rows[0];
// If submitted, check on-chain status
if (tx.signature && tx.status === "submitted") {
const status = await ctx.connection.getSignatureStatus(tx.signature);
return res.json({
id: tx.id,
signature: tx.signature,
status: status?.value?.confirmationStatus ?? "pending",
confirmations: status?.value?.confirmations,
slot: status?.value?.slot,
error: status?.value?.err,
});
}
res.json({
id: tx.id,
status: tx.status,
signature: tx.signature,
});
} catch (err) {
next(err);
}
});
// Track confirmation in background
async function trackConfirmation(id: string, signature: string) {
try {
const confirmation = await ctx.connection.confirmTransaction(
signature,
"confirmed",
);
await ctx.db.query(
`UPDATE pending_transactions SET status = $1 WHERE id = $2`,
[confirmation.value.err ? "failed" : "confirmed", id],
);
} catch (error) {
await ctx.db.query(
`UPDATE pending_transactions SET status = 'failed' WHERE id = $1`,
[id],
);
}
}
app.use(errorHandler);
WebSocket API
TypeScript
import WebSocket from "ws";
import { Connection, PublicKey } from "@solana/web3.js";
interface WSMessage {
type: string;
id?: string;
data?: any;
}
interface Subscription {
type: "account" | "transaction" | "program";
address: string;
subscriptionId: number;
}
export class WebSocketAPI {
private wss: WebSocket.Server;
private connection: Connection;
private subscriptions: Map<WebSocket, Subscription[]> = new Map();
constructor(server: any, connection: Connection) {
this.connection = connection;
this.wss = new WebSocket.Server({ server, path: "/ws" });
this.wss.on("connection", (ws) => this.handleConnection(ws));
}
private handleConnection(ws: WebSocket) {
console.log("Client connected");
this.subscriptions.set(ws, []);
ws.on("message", async (data) => {
try {
const message: WSMessage = JSON.parse(data.toString());
await this.handleMessage(ws, message);
} catch (error) {
this.send(ws, { type: "error", data: "Invalid message format" });
}
});
ws.on("close", () => {
this.cleanup(ws);
});
// Send welcome message
this.send(ws, { type: "connected", data: { version: "1.0" } });
}
private async handleMessage(ws: WebSocket, message: WSMessage) {
switch (message.type) {
case "subscribe":
await this.handleSubscribe(ws, message);
break;
case "unsubscribe":
await this.handleUnsubscribe(ws, message);
break;
case "ping":
this.send(ws, { type: "pong", id: message.id });
break;
default:
this.send(ws, { type: "error", data: "Unknown message type" });
}
}
private async handleSubscribe(ws: WebSocket, message: WSMessage) {
const { channel, address } = message.data;
const pubkey = new PublicKey(address);
const subs = this.subscriptions.get(ws)!;
let subscriptionId: number;
switch (channel) {
case "account":
subscriptionId = this.connection.onAccountChange(
pubkey,
(accountInfo, context) => {
this.send(ws, {
type: "accountUpdate",
data: {
address,
slot: context.slot,
lamports: accountInfo.lamports,
owner: accountInfo.owner.toBase58(),
data: accountInfo.data.toString("base64"),
},
});
},
"confirmed",
);
break;
case "logs":
subscriptionId = this.connection.onLogs(
pubkey,
(logs, context) => {
this.send(ws, {
type: "logs",
data: {
address,
slot: context.slot,
signature: logs.signature,
logs: logs.logs,
err: logs.err,
},
});
},
"confirmed",
);
break;
case "signature":
subscriptionId = this.connection.onSignature(
address, // signature string
(result, context) => {
this.send(ws, {
type: "signatureUpdate",
data: {
signature: address,
slot: context.slot,
err: result.err,
},
});
},
"confirmed",
);
break;
default:
return this.send(ws, { type: "error", data: "Unknown channel" });
}
subs.push({ type: channel, address, subscriptionId });
this.send(ws, {
type: "subscribed",
id: message.id,
data: { channel, address, subscriptionId },
});
}
private async handleUnsubscribe(ws: WebSocket, message: WSMessage) {
const { subscriptionId } = message.data;
const subs = this.subscriptions.get(ws)!;
const index = subs.findIndex((s) => s.subscriptionId === subscriptionId);
if (index === -1) {
return this.send(ws, { type: "error", data: "Subscription not found" });
}
const sub = subs[index];
await this.removeSubscription(sub);
subs.splice(index, 1);
this.send(ws, {
type: "unsubscribed",
id: message.id,
data: { subscriptionId },
});
}
private async removeSubscription(sub: Subscription) {
switch (sub.type) {
case "account":
await this.connection.removeAccountChangeListener(sub.subscriptionId);
break;
case "transaction":
await this.connection.removeSignatureListener(sub.subscriptionId);
break;
case "program":
await this.connection.removeOnLogsListener(sub.subscriptionId);
break;
}
}
private async cleanup(ws: WebSocket) {
const subs = this.subscriptions.get(ws);
if (subs) {
for (const sub of subs) {
await this.removeSubscription(sub);
}
}
this.subscriptions.delete(ws);
}
private send(ws: WebSocket, message: WSMessage) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
// Broadcast to all clients subscribed to an address
broadcast(address: string, message: WSMessage) {
for (const [ws, subs] of this.subscriptions) {
if (subs.some((s) => s.address === address)) {
this.send(ws, message);
}
}
}
}
Rate Limiting
TypeScript
import rateLimit from "express-rate-limit";
import RedisStore from "rate-limit-redis";
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
// Basic rate limiter
export const basicLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args: string[]) => redis.call(...args),
}),
});
// Strict limiter for write operations
export const writeLimiter = rateLimit({
windowMs: 60 * 1000,
max: 20,
store: new RedisStore({
sendCommand: (...args: string[]) => redis.call(...args),
}),
keyGenerator: (req) => req.user?.address || req.ip,
});
// Dynamic rate limiting based on tier
export function tierBasedLimiter(getTier: (req: any) => string) {
const limits: Record<string, number> = {
free: 100,
basic: 500,
pro: 2000,
enterprise: 10000,
};
return rateLimit({
windowMs: 60 * 1000,
max: (req) => limits[getTier(req)] || 100,
store: new RedisStore({
sendCommand: (...args: string[]) => redis.call(...args),
}),
});
}
OpenAPI Documentation
YAML
openapi: 3.0.0
info:
title: Solana API
version: 1.0.0
description: REST API for Solana blockchain interactions
servers:
- url: https://api.example.com/v1
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
Account:
type: object
properties:
address:
type: string
example: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"
lamports:
type: integer
format: int64
owner:
type: string
executable:
type: boolean
dataLength:
type: integer
TokenAccount:
type: object
properties:
address:
type: string
mint:
type: string
balance:
type: number
decimals:
type: integer
TransactionRequest:
type: object
required:
- to
- amount
- currency
properties:
to:
type: string
description: Recipient address
amount:
type: number
minimum: 0
currency:
type: string
enum: [SOL, USDC]
TransactionResponse:
type: object
properties:
id:
type: string
format: uuid
transaction:
type: string
description: Base64 encoded transaction
blockhash:
type: string
expiresAt:
type: string
format: date-time
Error:
type: object
properties:
error:
type: string
details:
type: array
items:
type: object
paths:
/accounts/{address}:
get:
summary: Get account information
tags: [Accounts]
parameters:
- name: address
in: path
required: true
schema:
type: string
responses:
200:
description: Account information
content:
application/json:
schema:
$ref: "#/components/schemas/Account"
404:
description: Account not found
/accounts/{address}/tokens:
get:
summary: Get token accounts
tags: [Accounts]
parameters:
- name: address
in: path
required: true
schema:
type: string
responses:
200:
description: List of token accounts
content:
application/json:
schema:
type: object
properties:
tokens:
type: array
items:
$ref: "#/components/schemas/TokenAccount"
/transactions/transfer:
post:
summary: Create transfer transaction
tags: [Transactions]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TransactionRequest"
responses:
200:
description: Unsigned transaction
content:
application/json:
schema:
$ref: "#/components/schemas/TransactionResponse"
400:
description: Validation error
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
Best Practices
| Area | Practice |
|---|---|
| Versioning | Use URL path versioning (/api/v1/) |
| Pagination | Cursor-based pagination for large datasets |
| Caching | Cache immutable data (confirmed transactions) |
| Idempotency | Use idempotency keys for write operations |
| Error handling | Return consistent error format with codes |
| Documentation | OpenAPI spec with examples |
Next: Database Patterns - Storing and querying blockchain data.