ArchitectureApplication layer

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

Rendering chart...

All requests hit /api/rpc and are routed to handlers based on the contract path (e.g., token.listsrc/orpc/routes/token/routes/token.list.ts).

Router hierarchy

Base router stack

Routers are layered with progressively stricter requirements:

Rendering chart...

Router selection guide

RouterUse whenContext provided
BaseBuilding custom middlewareRaw context only
PublicPublic endpoints, optional authauth?, headers, i18n
AuthRequires loginauth (required), session
OnboardedRequires full setupauth, wallet, system, userClaimTopics
TokenToken-specific operationsauth, 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 loading

Route 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 aggregation

Contract (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.

  • InputsidentityAddress is required. Optional filters support both the human-readable claimTopic (name, decimal, or keccak256 hash) and a claimSignature hex string. Passing both scopes the lifecycle to a single claim and keeps ClaimRevoked events in the response.
  • Data source – looks up the identity’s wallet account and fetches ClaimAdded, ClaimChanged, ClaimRemoved, and ClaimRevoked events from TheGraph using @fetchAll to avoid pagination gaps.
  • Outputs – returns events sorted by block timestamp with rich metadata: eventName, blockNumber, blockTimestamp, transactionHash, emitter, sender, and raw values. 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"); // 500

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