tRPC API Architecture

Overview

Wafra has fully migrated to tRPC for all API communication, providing end-to-end type safety, excellent developer experience, and simplified client-server communication. The REST API has been completely removed in favor of this modern, type-safe approach.

Current Architecture

Backend Implementation

Fastify Integration:

  • tRPC Fastify Plugin: Direct integration with Fastify server
  • Request Context: Rich context including user sessions and request metadata
  • Error Handling: Centralized error handling with custom error types
  • Middleware Chain: Authentication, validation, and logging middleware

Server Structure:

// apps/server/src/trpc/index.ts
export const appRouter = router({
  auth: authRouter,
  passkey: passkeyRouter,
  balances: balancesRouter,
  payment: paymentRouter,
  wallet: walletRouter,
  transactions: transactionsRouter,
  kyc: kycRouter,
  dev: devRouter, // Development only
});
 
export type AppRouter = typeof appRouter;

Router Organization

Current Router Structure:

RouterPurposeKey Procedures
authAuthentication flowssignIn, signUp, refresh
passkeyWebAuthn managementregister, authenticate, approve
balancesWallet balancesgetBalances, getHistory
paymentPayment processingcreateOrder, getQuotes
walletWallet operationssetup, getInfo, backup
transactionsTransaction historygetTransactions, getDetails
kycKYC workflowsgetRequirements, submitKYC
devDevelopment toolsgenerateJWT, getUsers

Type Safety Implementation

End-to-End Types:

// Shared types across client and server
import type { AppRouter } from "@/server/trpc";
 
// Client gets full type safety
const trpc = createTRPCReact<AppRouter>();
 
// All queries and mutations are fully typed
const { data, isLoading } = trpc.auth.getSession.useQuery();
const signIn = trpc.auth.signIn.useMutation();

Request Context System

Context Structure

Current Context Implementation:

interface TRPCContext {
  req: FastifyRequest;
  res: FastifyReply;
  session?: SessionContext;
  db: PrismaClient;
  services: {
    auth: AuthService;
    payment: PaymentService;
    wallet: WalletService;
    kyc: KYCService;
  };
}
 
interface SessionContext {
  user: {
    id: string;
    phone: string;
    name?: string;
    walletAddress?: string;
    phoneVerified: Date | null;
    onboardingCompleted: boolean;
    country: string;
    currency?: string;
  };
  passkeyId?: string;
}

Context Creation

Request Processing:

export async function createContext({
  req,
  res,
}: {
  req: FastifyRequest;
  res: FastifyReply;
}): Promise<TRPCContext> {
  // Extract JWT from Authorization header
  const token = req.headers.authorization?.replace("Bearer ", "");
 
  // Verify session if token exists
  let session: SessionContext | undefined;
  if (token) {
    session = await verifySession(token);
  }
 
  return {
    req,
    res,
    session,
    db: prisma,
    services: {
      auth: new AuthService(),
      payment: new PaymentService(),
      wallet: new WalletService(),
      kyc: new KYCService(),
    },
  };
}

Procedure Types & Protection

Public Procedures

Open Access Endpoints:

// No authentication required
export const publicProcedure = t.procedure.use(loggingMiddleware);
 
// Examples:
auth.signIn; // Phone + password login
auth.signUp; // User registration
dev.generateJWT; // Development JWT (dev only)

Protected Procedures

Authentication Required:

// Requires valid JWT and session
export const protectedProcedure = t.procedure
  .use(loggingMiddleware)
  .use(requireAuth);
 
// Examples:
balances.getBalances; // User wallet balances
wallet.setup; // Create new wallet
payment.createOrder; // Start payment flow
passkey.register; // Register new device

Middleware Chain

Current Middleware Stack:

const requireAuth = t.middleware(async ({ ctx, next }) => {
  if (!ctx.session) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "Authentication required",
    });
  }
 
  return next({
    ctx: {
      ...ctx,
      session: ctx.session, // Now guaranteed to exist
    },
  });
});

Input Validation & Schemas

Zod Schema Integration

Runtime Validation:

// Example procedure with validation
export const createPaymentOrder = protectedProcedure
  .input(
    z.object({
      amount: z.number().positive('Amount must be positive'),
      currency: z.enum(['USD', 'EUR', 'GBP']),
      provider: z.enum(['onramp', 'dtr']).optional(),
    })
  )
  .output(
    z.object({
      orderId: z.string(),
      status: z.enum(['pending', 'processing', 'completed']),
      paymentUrl?: z.string(),
    })
  )
  .mutation(async ({ input, ctx }) => {
    // Input is fully typed and validated
    return await ctx.services.payment.createOrder(ctx.session.user, input);
  });

Common Validation Patterns

Reusable Schemas:

// Common validation schemas
export const UserIdSchema = z.string().uuid();
export const AmountSchema = z.number().positive().max(1000000);
export const CurrencySchema = z.enum(["USD", "EUR", "GBP"]);
export const AddressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/);
 
// Reused across multiple procedures
export const WalletAddressInput = z.object({
  address: AddressSchema,
});

Error Handling System

Custom Error Types

Application-Specific Errors:

export class AppError extends Error {
  constructor(
    public statusCode: number,
    message: string,
    public details?: any
  ) {
    super(message);
  }
}
 
export class BusinessLogicError extends AppError {
  constructor(message: string, details?: any) {
    super(400, message, details);
  }
}
 
export class ValidationError extends AppError {
  constructor(message: string, validationErrors: any[]) {
    super(400, message, validationErrors);
  }
}

Global Error Handler

Centralized Error Processing:

export const errorHandler = (error: any, req: FastifyRequest) => {
  if (error instanceof AppError) {
    return {
      code: "BAD_REQUEST",
      message: error.message,
      details: error.details,
    };
  }
 
  if (error instanceof ZodError) {
    return {
      code: "BAD_REQUEST",
      message: "Validation failed",
      details: error.errors,
    };
  }
 
  // Log unexpected errors
  logger.error("Unexpected tRPC error:", { error, url: req.url });
 
  return {
    code: "INTERNAL_SERVER_ERROR",
    message: "Internal server error",
  };
};

Response Patterns

Standardized Response Format

Consistent API Responses:

interface ApiResponse<T = any> {
  success: boolean;
  data?: T;
  error?: string;
  details?: any;
}
 
// Success responses
return {
  success: true,
  data: result,
};
 
// Error responses
throw new TRPCError({
  code: "BAD_REQUEST",
  message: "Invalid input",
  cause: validationErrors,
});

Pagination Support

List Endpoints with Pagination:

export const getTransactions = protectedProcedure
  .input(
    z.object({
      limit: z.number().min(1).max(100).default(20),
      cursor: z.string().optional(),
      filter: z
        .object({
          type: z.enum(["deposit", "withdrawal"]).optional(),
          status: z.enum(["pending", "completed", "failed"]).optional(),
        })
        .optional(),
    })
  )
  .query(async ({ input, ctx }) => {
    const transactions = await getTransactionsPaginated(
      ctx.session.user.id,
      input
    );
 
    return {
      items: transactions,
      nextCursor: getNextCursor(transactions),
      hasMore: transactions.length === input.limit,
    };
  });

Client Integration

React Query Integration

Automatic Caching & Synchronization:

// apps/app/src/utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/trpc';
 
export const trpc = createTRPCReact<AppRouter>();
 
// Provider setup with React Query
export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: '/api/trpc',
          headers: () => ({
            authorization: `Bearer ${getAuthToken()}`,
          }),
        }),
      ],
    })
  );
 
  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}

Manual Refresh with Real-Time Awareness

Socket.IO Integration with Manual Control:

// Listen-only mode for real-time events (no automatic updates)
useEffect(() => {
  const socket = io();
 
  socket.on("balance-updated", (data) => {
    // Log event but don't auto-update
    console.log("💰 Balance update available - manual refresh needed");
  });
 
  socket.on("transaction-status-changed", (data) => {
    // Log event but don't auto-update
    console.log("📡 Transaction status changed:", data.status);
    console.log("📡 Manual refresh needed to see changes");
  });
 
  return () => socket.disconnect();
}, []);
 
// Manual refresh function
const manualRefresh = useCallback(() => {
  // User-triggered invalidation
  queryClient.invalidateQueries({ queryKey: ["balances"] });
  queryClient.invalidateQueries({ queryKey: ["transactions"] });
}, [queryClient]);

Development Tools

Development Router

Development-Only Endpoints:

// Only available when NODE_ENV=development
export const devRouter = router({
  generateJWT: publicProcedure
    .input(z.object({ userId: z.string().optional() }))
    .mutation(async ({ input }) => {
      if (process.env.NODE_ENV !== "development") {
        throw new TRPCError({ code: "FORBIDDEN" });
      }
 
      return generateDevJWT(input.userId);
    }),
 
  getUsers: publicProcedure.query(async () => {
    if (process.env.NODE_ENV !== "development") {
      throw new TRPCError({ code: "FORBIDDEN" });
    }
 
    return getAllUsers();
  }),
});

Type Generation

Automatic Type Extraction:

// Generate types for frontend consumption
export type RouterInputs = inferRouterInputs<AppRouter>;
export type RouterOutputs = inferRouterOutputs<AppRouter>;
 
// Usage in components
type AuthSignInInput = RouterInputs["auth"]["signIn"];
type UserBalances = RouterOutputs["balances"]["getBalances"];

Performance Optimizations

Query Batching

Automatic Request Batching:

  • Multiple simultaneous queries batched into single HTTP request
  • Reduces network overhead and improves performance
  • Configurable batch window for optimal bundling

Caching Strategy

React Query Integration with Manual Control:

  • Query Caching: Long-term caching with manual invalidation
  • Initial Auto-Load: Automatic data loading on first mount
  • No Background Refetch: Eliminates automatic polling/refreshing after initial load
  • Optimistic Updates: Immediate UI updates for mutations
  • Manual Invalidation: User-controlled cache invalidation via refresh buttons
  • Real-Time Awareness: Socket events provide update notifications without auto-refresh

Migration Benefits

Developer Experience

Improvements Over REST:

  • Type Safety: Compile-time error detection
  • Auto-Complete: Full IDE support for API calls
  • Reduced Boilerplate: No manual API client code
  • Integrated Validation: Zod schemas shared between client/server

Performance Benefits

Technical Improvements:

  • Request Batching: Multiple queries in single request
  • Automatic Caching: Built-in React Query integration
  • Real-Time Updates: Efficient cache invalidation
  • Reduced Bundle Size: Tree-shaking eliminates unused procedures

This tRPC architecture provides a modern, type-safe foundation for all API communication while maintaining excellent performance and developer experience.