Backend API - ORPC architecture and middleware
The Asset Tokenization Kit backend exposes a type-safe RPC API built with ORPC (Object RPC), providing end-to-end type safety from server to client with automatic TypeScript inference. The architecture uses composable middleware to build specialized routers for different security and context requirements.
ORPC architecture overview
Why ORPC over REST or GraphQL
ORPC provides:
- End-to-end type safety - Shared contracts between client and server
- Automatic serialization - Handles dates, BigInts, and complex types
- Minimal boilerplate - No manual API client generation
- TypeScript-first - Full inference without code generation steps
- Middleware composition - Layered security and context injection
Unlike REST (requires OpenAPI codegen) or GraphQL (requires schema-first design), ORPC derives types directly from TypeScript implementation.
Request flow
All requests hit /api/rpc and are routed to handlers based on the contract
path (e.g., token.list → src/orpc/routes/token/routes/token.list.ts).
Router hierarchy
Base router stack
Routers are layered with progressively stricter requirements:
Router selection guide
| Router | Use when | Context provided |
|---|---|---|
| Base | Building custom middleware | Raw context only |
| Public | Public endpoints, optional auth | auth?, headers, i18n |
| Auth | Requires login | auth (required), session |
| Onboarded | Requires full setup | auth, wallet, system, userClaimTopics |
| Token | Token-specific operations | auth, token, permissions |
Example handler selection:
// ❌ Wrong - using auth router for public data
export const getPublicStats = authRouter.stats.public.handler(...)
// ✅ Correct - using public router
export const getPublicStats = publicRouter.stats.public.handler(...)
// ❌ Wrong - manual auth check in public router
export const createToken = publicRouter.token.create.handler(({ context }) => {
if (!context.auth) throw new Error('Unauthorized')
// ...
})
// ✅ Correct - using onboarded router
export const createToken = onboardedRouter.token.create.handler(({ context }) => {
// context.auth is guaranteed to exist
// context.system has deployed contracts
})Middleware stack
Core middleware layers
1. Error middleware (src/orpc/middlewares/error.middleware.ts)
Catches and formats errors consistently:
{
error: {
code: 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND' | 'BAD_REQUEST' | 'INTERNAL_ERROR',
message: 'Human-readable error',
details?: { field: 'validation error' }
}
}2. i18n middleware (src/orpc/middlewares/i18n.middleware.ts)
Injects translation function based on Accept-Language header:
context.t("token.created.success");3. Session middleware (src/orpc/middlewares/session.middleware.ts)
Loads session from HTTP-only cookie (optional in public router, required in auth router):
context.auth = {
user: { id, email, name },
session: { id, expiresAt },
};4. Auth middleware (src/orpc/middlewares/auth.middleware.ts)
Enforces authentication, throws UNAUTHORIZED if not logged in.
5. Wallet middleware (src/orpc/middlewares/wallet.middleware.ts)
Validates user has connected wallet and completed security setup:
context.wallet = {
address: "0x...",
chainId: 1,
recoveryCodesVerified: true,
};6. System middleware (src/orpc/middlewares/system.middleware.ts)
Loads deployed SMART system contracts:
context.system = {
address: "0xSystemAddress",
tokenFactories: [
{ address: "0xBondFactory", type: "bond" },
{ address: "0xEquityFactory", type: "equity" },
],
identityRegistry: "0xIdentityRegistry",
claimTopicsRegistry: "0xClaimTopicsRegistry",
};7. User claims middleware (src/orpc/middlewares/user-claims.middleware.ts)
Fetches user's identity claims (KYC status, accreditation):
context.userClaimTopics = ["COUNTRY", "KYC_VERIFIED", "ACCREDITED_INVESTOR"];8. Token middleware (src/orpc/middlewares/token.middleware.ts)
Loads token context from route parameters:
context.token = {
address: "0xTokenAddress",
factoryAddress: "0xFactoryAddress",
type: "equity",
name: "Company Shares",
symbol: "COMP",
};9. Token permission middleware
(src/orpc/middlewares/token-permission.middleware.ts)
Validates user's roles on specific token:
context.permissions = ["ADMIN", "COMPLIANCE_OFFICER", "AGENT"];Middleware composition examples
Public endpoint (health check, public stats):
export const publicStats = publicRouter.stats.public.handler(
async ({ context }) => {
// context.auth is optional
const stats = await getPublicStatistics();
return stats;
}
);Authenticated endpoint (user profile):
export const getProfile = authRouter.user.me.handler(async ({ context }) => {
// context.auth is required
return getUserProfile(context.auth.user.id);
});Onboarded endpoint (create token):
export const createToken = onboardedRouter.token.create.handler(
async ({ context, input }) => {
// context.auth, context.wallet, context.system all guaranteed
const factoryAddress = context.system.tokenFactories.find(
(f) => f.type === input.type
)?.address;
return deployToken(factoryAddress, input, context.wallet.address);
}
);Token-specific endpoint (mint tokens):
export const mintTokens = tokenRouter.token.mint.handler(
async ({ context, input }) => {
// context.token and context.permissions guaranteed
if (!context.permissions.includes("ADMIN")) {
throw errors.FORBIDDEN("Only admins can mint");
}
return mint(context.token.address, input.to, input.amount);
}
);Route organization
Domain-based structure
Routes are organized by domain in src/orpc/routes/:
routes/
├── account/ # Wallet and identity management
├── actions/ # Time-bound executable tasks
├── addons/ # System addon modules (vault, XvP, token sale)
├── common/ # Shared schemas and utilities
├── exchange-rates/ # Currency conversion rates
├── settings/ # User preferences
├── system/ # SMART system operations
├── token/ # Token creation and management
├── user/ # User profiles and KYC
├── contract.ts # Main contract aggregation
└── router.ts # Main router with lazy loadingRoute module anatomy
Each domain follows a consistent structure:
token/
├── routes/
│ ├── token.create.ts # Handler implementation
│ ├── token.create.schema.ts # Zod validation schemas
│ ├── token.list.ts
│ ├── token.list.schema.ts
│ └── ...
├── token.contract.ts # ORPC contract definition
└── token.router.ts # Handler aggregationContract (token.contract.ts) - Defines API surface:
import { oc } from "@orpc/contract";
import { TokenCreateSchema, TokenSchema } from "./routes/token.create.schema";
export const tokenContract = {
create: oc
.input(TokenCreateSchema)
.output(TokenSchema)
.metadata({
openapi: {
method: "POST",
path: "/token",
description: "Deploy new token contract",
},
}),
list: oc
.input(TokenListSchema)
.output(z.array(TokenSchema))
.metadata({
openapi: {
method: "GET",
path: "/token",
description: "List tokens with filters",
},
}),
};Schema (token.create.schema.ts) - Validation rules:
import { z } from "zod";
import { ethereumAddress } from "@atk/zod/ethereum-address";
export const TokenCreateSchema = z.object({
type: z.enum(["bond", "equity", "fund", "stableCoin"]),
name: z.string().min(1).max(100),
symbol: z.string().min(1).max(10).toUpperCase(),
initialSupply: z.bigint().positive(),
complianceRules: z.array(
z.object({
moduleAddress: ethereumAddress,
parameters: z.record(z.unknown()),
})
),
});
export const TokenSchema = TokenCreateSchema.extend({
address: ethereumAddress,
factoryAddress: ethereumAddress,
deployedAt: z.date(),
status: z.enum(["pending", "deployed", "failed"]),
});Handler (token.create.ts) - Business logic:
import { onboardedRouter } from "@/orpc/procedures/onboarded.router";
import { TokenCreateSchema, TokenSchema } from "./token.create.schema";
export const create = onboardedRouter.token.create
.use(portalMiddleware) // Add SettleMint Portal client
.handler(async ({ input, context }) => {
// 1. Validate user has permission
const canCreate = await checkTokenCreationPermission(context.auth.user.id);
if (!canCreate) {
throw errors.FORBIDDEN("User lacks token creation permission");
}
// 2. Get factory address
const factory = context.system.tokenFactories.find(
(f) => f.type === input.type
);
if (!factory) {
throw errors.NOT_FOUND(`No factory deployed for type ${input.type}`);
}
// 3. Submit transaction
const { transactionHash } = await context.portalClient.mutate({
mutation: CREATE_TOKEN_MUTATION,
variables: {
factoryAddress: factory.address,
tokenParams: input,
},
});
// 4. Save to database
await context.db.insert(tokens).values({
address: null, // Unknown until mined
transactionHash,
type: input.type,
status: "pending",
createdBy: context.auth.user.id,
});
// 5. Return tracking info
return {
transactionHash,
message: context.t("token.creation.initiated"),
estimatedTime: "~2 minutes",
};
});Router (token.router.ts) - Handler aggregation:
import { create } from "./routes/token.create";
import { list } from "./routes/token.list";
import { read } from "./routes/token.read";
// ... other handlers
const routes = {
create,
list,
read,
// ... more handlers
};
export default routes;Main router integration
All domain routers are aggregated in src/orpc/routes/router.ts with lazy
loading:
import { baseRouter } from "@/orpc/procedures/base.router";
import { contract } from "./contract";
export const router = baseRouter.$implement(contract, {
token: async () => (await import("./token/token.router")).default,
user: async () => (await import("./user/user.router")).default,
system: async () => (await import("./system/system.router")).default,
// ... lazy-loaded domains
});This allows code-splitting at the route level, reducing initial bundle size.
Data integration patterns
Combining multiple sources
Handlers often aggregate data from multiple services:
export const read = authRouter.token.read
.use(theGraphMiddleware) // Blockchain indexer
.use(dbMiddleware) // PostgreSQL
.handler(async ({ input, context }) => {
// Fetch from blockchain
const blockchainData = await context.theGraphClient.query({
query: GET_TOKEN_QUERY,
variables: { id: input.address },
schema: TokenBlockchainSchema,
});
// Fetch from database
const dbData = await context.db
.select()
.from(tokenMetadata)
.where(eq(tokenMetadata.address, input.address))
.get();
// Combine sources
return {
...blockchainData.token,
metadata: dbData,
isUserHolder: blockchainData.token.holders.some(
(h) => h.address === context.wallet.address
),
};
});Pagination pattern
Use shared pagination schema from
src/orpc/routes/common/schemas/list.schema.ts:
export const ListSchema = z.object({
page: z.number().int().positive().default(1),
pageSize: z.number().int().positive().max(100).default(20),
sortBy: z.string().optional(),
sortOrder: z.enum(["asc", "desc"]).default("desc"),
});
export const ListResponseSchema = <T>(itemSchema: z.ZodType<T>) =>
z.object({
items: z.array(itemSchema),
pagination: z.object({
page: z.number(),
pageSize: z.number(),
totalItems: z.number(),
totalPages: z.number(),
hasNextPage: z.boolean(),
hasPreviousPage: z.boolean(),
}),
});Transaction submission pattern
All blockchain mutations follow a consistent flow:
export const transfer = tokenRouter.token.transfer
.use(portalMiddleware)
.handler(async ({ input, context }) => {
// 1. Validate compliance
const canTransfer = await validateTransfer(
context.token.address,
input.from,
input.to,
input.amount
);
if (!canTransfer.allowed) {
throw errors.FORBIDDEN(`Transfer blocked: ${canTransfer.reason}`);
}
// 2. Submit transaction
const { transactionHash } = await context.portalClient.mutate({
mutation: TRANSFER_MUTATION,
variables: {
tokenAddress: context.token.address,
to: input.to,
amount: input.amount,
},
});
// 3. Log action
await context.db.insert(tokenTransfers).values({
tokenAddress: context.token.address,
from: input.from,
to: input.to,
amount: input.amount,
transactionHash,
status: "pending",
initiatedBy: context.auth.user.id,
});
// 4. Return tracking info
return {
transactionHash,
message: context.t("transfer.initiated"),
explorer: `https://explorer.example.com/tx/${transactionHash}`,
};
});Claim history timelines
/system/identity/claims/history exposes a ready-to-render audit trail for any
identity contract. Before querying TheGraph it resolves the identity contract
to the wallet Account that emitted the events so the query matches the event
store’s emitter column. The handler runs inside the authenticated system
router, layers systemMiddleware + theGraphMiddleware, and enforces the same
claimList permission guard as claims.list.
- Inputs –
identityAddressis required. Optional filters support both the human-readableclaimTopic(name, decimal, or keccak256 hash) and aclaimSignaturehex string. Passing both scopes the lifecycle to a single claim and keepsClaimRevokedevents in the response. - Data source – looks up the identity’s wallet account and fetches
ClaimAdded,ClaimChanged,ClaimRemoved, andClaimRevokedevents from TheGraph using@fetchAllto avoid pagination gaps. - Outputs – returns events sorted by block timestamp with rich metadata:
eventName,blockNumber,blockTimestamp,transactionHash,emitter,sender, and rawvalues. UI layers can derive timelines, actor badges, and transaction links without refetching the blockchain.
const { data } = useSuspenseQuery(
orpc.system.identity.claims.history.queryOptions({
input: {
identityAddress,
filters: {
claimTopic: selectedClaim?.name,
claimSignature: selectedClaim?.signature,
},
},
})
);
return (
<ClaimHistoryDialog
identityAddress={identityAddress}
events={data.events}
selectedClaim={selectedClaim}
/>
);The backend normalizes topic filters by hashing friendly names into the same
32-byte identifiers stored on-chain, so clients can pass either the UI label or
raw topic ID and receive a scoped timeline without duplicating hashing logic.
Resolving the emitter to the identity’s wallet also means the events[].emitter
field always matches the account that actually triggered the transaction,
allowing UI badges to highlight the actor without guessing which contract was
involved.
Error handling
Standardized error codes
All errors use consistent codes:
errors.BAD_REQUEST("Invalid input"); // 400
errors.UNAUTHORIZED("Login required"); // 401
errors.FORBIDDEN("Insufficient permissions"); // 403
errors.NOT_FOUND("Resource not found"); // 404
errors.CONFLICT("Resource already exists"); // 409
errors.INTERNAL_ERROR("Server error"); // 500Error context
Errors can include structured details:
throw errors.BAD_REQUEST("Validation failed", {
details: {
name: "Name must be at least 3 characters",
symbol: "Symbol must be uppercase",
},
});Client-side error handling
Frontend automatically receives typed errors:
const mutation = orpc.token.create.useMutation({
onError: (error) => {
if (error.code === "FORBIDDEN") {
toast.error(error.message);
} else if (error.code === "BAD_REQUEST" && error.details) {
// Show field-specific errors
Object.entries(error.details).forEach(([field, message]) => {
form.setError(field, { message });
});
}
},
});Testing procedures
Handler unit tests
Test handlers in isolation with mock context:
import { describe, it, expect, vi } from "vitest";
import { create } from "./token.create";
describe("token.create", () => {
it("deploys token via factory", async () => {
const mockContext = {
auth: { user: { id: "user123" } },
system: {
tokenFactories: [{ address: "0xFactory", type: "equity" }],
},
portalClient: {
mutate: vi.fn().mockResolvedValue({
transactionHash: "0xTx123",
}),
},
db: {
insert: vi.fn().mockReturnValue({
values: vi.fn().mockResolvedValue(undefined),
}),
},
};
const result = await create({
input: {
type: "equity",
name: "Test Token",
symbol: "TEST",
initialSupply: 1000000n,
},
context: mockContext,
});
expect(result.transactionHash).toBe("0xTx123");
expect(mockContext.portalClient.mutate).toHaveBeenCalled();
});
});Integration tests
Test full middleware stack:
import { testClient } from '@/test/orpc-route-helpers'
describe('token API integration', () => {
it('creates token with authenticated user', async () => {
const client = testClient({ userId: 'user123' })
const result = await client.token.create.mutate({
type: 'equity',
name: 'Test',
symbol: 'TST',
initialSupply: 1000000n
})
expect(result.transactionHash).toMatch(/^0x[a-fA-F0-9]{64}$/)
})
it('rejects unauthenticated requests', async () => {
const client = testClient() // No user
await expect(
client.token.create.mutate({ ... })
).rejects.toThrow('UNAUTHORIZED')
})
})Performance considerations
Request deduplication
ORPC automatically deduplicates identical requests within a time window, reducing database load.
Caching strategies
- Immutable data (deployed tokens) - Cache indefinitely
- Frequently changing (token balances) - Short TTL (30s)
- User-specific (permissions) - Cache per session
Query optimization
- Use database indexes on frequently queried fields
- Batch related queries with
Promise.all - Use subgraph for complex blockchain queries instead of direct contract calls
Monitoring
All procedures are instrumented with:
- Duration tracking - Log slow queries (>1s)
- Error rates - Alert on elevated error rates
- Usage metrics - Track most-called endpoints
See Scalability patterns for detailed optimization strategies.
Asset management UX
Architecture patterns for building persona-driven asset tokenization interfaces. Examines state management, accessibility standards, and observability integration that enable compliant multi-persona workflows without fragmentation.
Blockchain indexing
TheGraph provides high-performance blockchain indexing that transforms raw contract events into a queryable GraphQL API. The Asset Tokenization Kit's subgraph indexes all smart contract state—tokens, balances, compliance rules, identities—enabling fast, complex queries that would be prohibitively expensive to execute directly against contracts.