• SettleMintSettleMint
    • Introduction
    • Market pain points
    • Lifecycle platform approach
    • Platform capabilities
    • Use cases
    • Compliance & security
    • Glossary
    • Core component overview
    • Frontend layer
    • API layer
    • Blockchain layer
    • Data layer
    • Deployment layer
    • System architecture
    • Smart contracts
    • Application layer
      • Frontend design
      • Asset management UX
      • Backend API
    • Data & indexing
    • Integration & operations
    • Performance
    • Quality
    • Getting started
    • Asset issuance
    • Platform operations
    • Troubleshooting
    • Development environment
    • Code structure
    • Smart contracts
    • API integration
    • Data model
    • Deployment & ops
    • Testing and QA
    • Developer FAQ
Back to the application
  1. Documentation
  2. Architecture
  3. Application 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.list → src/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}`,
    };
  });

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.

Asset management UX
Blockchain indexing
llms-full.txt

On this page

ORPC architecture overviewWhy ORPC over REST or GraphQLRequest flowRouter hierarchyBase router stackRouter selection guideMiddleware stackCore middleware layersMiddleware composition examplesRoute organizationDomain-based structureRoute module anatomyMain router integrationData integration patternsCombining multiple sourcesPagination patternTransaction submission patternError handlingStandardized error codesError contextClient-side error handlingTesting proceduresHandler unit testsIntegration testsPerformance considerationsRequest deduplicationCaching strategiesQuery optimizationMonitoring