Back to blog
Apr 24, 2026
11 min read
Waqar Ilyas

Designing a Secure Crypto Wallet Backend with NestJS and MongoDB

A practical architecture guide for building crypto wallet backends with reliable transaction state, real-time updates, security boundaries, and auditability.

Crypto wallet products fail long before the blockchain fails. They fail when a user deposits funds and the app keeps showing an old balance. They fail when a withdrawal sits in a vague “processing” state for hours. They fail when customer support cannot explain whether a transaction is pending, confirmed, failed, retried, or waiting for manual review.

That is the real backend problem in wallet products: trust is a product feature.

I have worked on crypto wallet and private equity products such as CryptoKara and CQR Vault, and the most important lesson is that a wallet backend is not simply an API around blockchain transactions. It is a transaction state machine, a ledger, an audit system, a notification system, and a security boundary.

This article breaks down the architecture I would use for a secure crypto wallet backend with NestJS, MongoDB, blockchain/indexer integrations, and real-time updates.

The Product Problem

Users do not think in block confirmations. They think in trust:

  • Did my deposit arrive?
  • Can I withdraw safely?
  • Why did my balance change?
  • Is this transaction stuck?
  • Can support explain what happened?

The backend must answer those questions consistently. If the app says a transaction is complete but the chain says otherwise, trust drops. If balances are updated from multiple places without a ledger, trust drops. If transaction events arrive twice and the system double-counts them, trust disappears.

The safest mental model is this: the blockchain is the source of settlement truth, but your backend is the source of product truth.

Architecture Overview

flowchart LR
  Mobile[Mobile App] --> API[NestJS API]
  API --> Auth[Auth and Device Trust]
  API --> Wallet[Wallet Service]
  Wallet --> Ledger[Ledger Service]
  Wallet --> Chain[Blockchain Provider or Indexer]
  Chain --> Listener[Chain Event Listener]
  Listener --> Queue[Event Queue]
  Queue --> Ledger
  Ledger --> Mongo[(MongoDB)]
  Ledger --> Notify[Notification Gateway]
  Notify --> Socket[Socket.io / WebSocket]
  Socket --> Mobile
  Ledger --> Audit[Audit Log]

A production wallet backend needs clear service boundaries:

  • API service: validates requests, authenticates users, applies rate limits, and delegates work.
  • Wallet service: manages wallet records, supported assets, addresses, and user-facing wallet operations.
  • Chain listener: watches blockchain/indexer events and normalizes deposits, withdrawals, confirmations, and failures.
  • Ledger service: records balance-affecting events in an append-only way.
  • Notification gateway: sends real-time updates to mobile clients.
  • Audit log: records sensitive actions for internal investigation and compliance support.

The important point is that the mobile app should not directly interpret blockchain complexity. It should receive product-safe states.

Transaction Lifecycle

A wallet transaction should move through explicit states:

stateDiagram-v2
  [*] --> Created
  Created --> PendingBroadcast
  PendingBroadcast --> Broadcasted
  Broadcasted --> Confirming
  Confirming --> Confirmed
  Confirming --> Failed
  Broadcasted --> Retryable
  Retryable --> Broadcasted
  Failed --> ManualReview
  Confirmed --> [*]

Useful states include:

  • Created: user intent exists, but nothing has been broadcast.
  • PendingBroadcast: transaction is queued or being signed.
  • Broadcasted: transaction hash exists.
  • Confirming: transaction is seen but not final enough for the product.
  • Confirmed: settlement threshold is reached.
  • Failed: provider or chain reports failure.
  • Retryable: system can safely retry without duplicate accounting.
  • ManualReview: automated handling is unsafe.

Avoid ambiguous states like processing as the only source of truth. They are easy to display, but they hide operational reality.

Data Model

For MongoDB, I would separate user-facing wallet documents from financial movement records.

Core collections:

  • users: identity, auth status, risk flags.
  • devices: trusted devices, push tokens, last seen metadata.
  • wallets: user wallet records, asset support, address references.
  • assets: supported currencies, chain metadata, precision.
  • transactions: user-facing transaction state and references.
  • ledger_entries: append-only debit/credit records.
  • chain_events: normalized provider/indexer events.
  • audit_logs: sensitive action history.

The ledger is the most important part. Do not treat a balance field as the only truth. A cached balance can exist for speed, but it should be derived from ledger entries or periodically reconciled.

Example ledger entry shape:

type LedgerEntry = {
  id: string;
  userId: string;
  walletId: string;
  asset: "BTC" | "ETH" | "USDT";
  direction: "credit" | "debit";
  amountAtomic: string;
  transactionId: string;
  reason: "deposit" | "withdrawal" | "fee" | "adjustment";
  idempotencyKey: string;
  createdAt: Date;
};

The idempotencyKey matters because blockchain providers, webhooks, queue workers, and retry systems can all produce duplicate events. Your backend must be able to say, “I have already accounted for this exact financial movement.”

Implementing The Ledger In NestJS

The ledger should be a dedicated service, not helper logic hidden inside a controller. The controller receives a request, the wallet service validates the action, and the ledger service records the financial movement.

Start with MongoDB indexes that enforce idempotency:

// ledger-entry.schema.ts
@Schema({ timestamps: true })
export class LedgerEntry {
  @Prop({ required: true, index: true })
  userId: string;

  @Prop({ required: true, index: true })
  walletId: string;

  @Prop({ required: true })
  asset: string;

  @Prop({ required: true, enum: ["credit", "debit"] })
  direction: "credit" | "debit";

  @Prop({ required: true })
  amountAtomic: string;

  @Prop({ required: true, index: true })
  transactionId: string;

  @Prop({ required: true, unique: true })
  idempotencyKey: string;
}

Then make ledger writes idempotent:

async createEntry(input: CreateLedgerEntryInput) {
  try {
    return await this.ledgerModel.create(input);
  } catch (error) {
    if (isDuplicateKeyError(error)) {
      return this.ledgerModel.findOne({
        idempotencyKey: input.idempotencyKey,
      });
    }

    throw error;
  }
}

This is the difference between “we hope the webhook only arrives once” and “the system is safe when the webhook arrives twice.”

For withdrawals, I prefer a command-style method:

async requestWithdrawal(userId: string, dto: WithdrawalDto) {
  const wallet = await this.wallets.findOwnedWallet(userId, dto.walletId);
  await this.risk.assertCanWithdraw(userId, dto);

  const available = await this.ledger.getAvailableBalance(wallet.id, dto.asset);
  if (available < BigInt(dto.amountAtomic)) {
    throw new BadRequestException("Insufficient available balance");
  }

  const tx = await this.transactions.create({
    userId,
    walletId: wallet.id,
    asset: dto.asset,
    amountAtomic: dto.amountAtomic,
    state: "PendingBroadcast",
    destinationAddress: dto.destinationAddress,
  });

  await this.ledger.createEntry({
    userId,
    walletId: wallet.id,
    asset: dto.asset,
    direction: "debit",
    amountAtomic: dto.amountAtomic,
    transactionId: tx.id,
    idempotencyKey: `withdrawal:${tx.id}:debit`,
  });

  await this.queue.add("broadcast-withdrawal", { transactionId: tx.id });
  return tx;
}

Notice the important details:

  • Balance is checked before queuing broadcast.
  • The debit is recorded once using a deterministic idempotency key.
  • Broadcasting happens asynchronously.
  • The transaction has a visible state before the chain call happens.

This gives the user immediate feedback without pretending settlement has completed.

Real-Time Updates

Real-time wallet updates are not a decoration. They reduce support load and user anxiety.

For a NestJS backend, a Socket.io gateway can publish transaction changes by user and wallet:

server.to(`user:${userId}`).emit("wallet.transaction.updated", {
  transactionId,
  state,
  asset,
  amount,
  confirmations,
  updatedAt,
});

Do not emit raw provider payloads. Emit product events with stable names and safe fields.

Recommended events:

  • wallet.balance.updated
  • wallet.transaction.created
  • wallet.transaction.updated
  • wallet.transaction.requires_review
  • wallet.deposit.detected
  • wallet.withdrawal.confirmed

The mobile app should also refetch the transaction detail after important events. WebSockets are for freshness; the API remains the source of complete state.

Implementing The Chain Listener

A chain listener should normalize provider-specific data into your internal transaction model.

async handleDepositDetected(event: ProviderDepositEvent) {
  const normalized = this.normalizeDeposit(event);

  const wallet = await this.wallets.findByAddress(
    normalized.chain,
    normalized.toAddress,
  );

  if (!wallet) {
    await this.chainEvents.storeUnmatched(normalized);
    return;
  }

  const tx = await this.transactions.upsertByChainReference({
    chain: normalized.chain,
    txHash: normalized.txHash,
    logIndex: normalized.logIndex,
    userId: wallet.userId,
    walletId: wallet.id,
    asset: normalized.asset,
    amountAtomic: normalized.amountAtomic,
    state: normalized.confirmations >= 12 ? "Confirmed" : "Confirming",
  });

  if (tx.state === "Confirmed") {
    await this.ledger.createEntry({
      userId: wallet.userId,
      walletId: wallet.id,
      asset: normalized.asset,
      direction: "credit",
      amountAtomic: normalized.amountAtomic,
      transactionId: tx.id,
      idempotencyKey: `deposit:${normalized.chain}:${normalized.txHash}:${normalized.logIndex}`,
    });
  }

  await this.notifications.transactionUpdated(tx);
}

The listener does not trust the provider blindly. It normalizes the event, maps it to a wallet, upserts a transaction, creates ledger entries only when product rules are satisfied, and emits a product-safe notification.

Security Boundaries

A wallet backend must be explicit about what it never handles.

If the product is non-custodial, the backend should not store private keys. If it is custodial, key management becomes a separate infrastructure problem involving KMS/HSM policies, withdrawal approvals, and strict internal controls.

Minimum backend controls:

  • Strong authentication and refresh token rotation.
  • Device trust checks for withdrawal actions.
  • Rate limits by user, IP, device, and action type.
  • Withdrawal velocity limits and manual review thresholds.
  • Audit logs for address changes, withdrawals, failed auth, and admin actions.
  • Separate permissions for support, finance, engineering, and admin users.
  • Structured logs that never expose secrets, seed phrases, private keys, or full tokens.

The safest wallet system assumes every sensitive action needs to be explainable later.

Balance Calculation: The Place Bugs Become Expensive

One of the most dangerous shortcuts in wallet products is treating wallet.balance as a normal mutable field. It feels simple early on: deposit comes in, increment balance; withdrawal happens, decrement balance. The problem is that financial systems need history, reversibility, and auditability.

Instead, balances should be derived from a ledger or at least reconciled against one.

A practical model is:

  • Ledger entries are append-only.
  • Cached balances are updated for fast reads.
  • Reconciliation jobs verify cached balances against ledger totals.
  • Support tools show both transaction state and ledger impact.

For example, a deposit should not simply say:

wallet.balance += deposit.amount;

It should create a ledger credit with an idempotency key tied to the chain event. The cached balance can be updated in the same transaction or in a controlled worker, but the ledger entry is the record that explains why money moved.

This matters when things go wrong. If a provider sends the same webhook twice, the idempotency key prevents double credit. If a chain reorg or failed withdrawal creates a reversal scenario, the system can add a compensating entry instead of silently editing history. If a user contacts support, the team can explain the exact sequence of events.

Deposits, Withdrawals, And Internal Transfers

Different wallet movements have different risks.

Deposits are externally initiated. The system needs to detect them, wait for enough confirmations, map them to the right user wallet, and decide when funds become available. A product might show “detected” quickly but only mark the balance available after the confirmation threshold is met.

Withdrawals are user initiated and higher risk. Before broadcast, the backend should validate balance availability, risk limits, destination address rules, device trust, and user authentication freshness. After broadcast, the transaction should be tracked until it is confirmed or moved into review.

Internal transfers are product initiated. If the sender and receiver are both inside the platform, you may not need an on-chain transaction for every movement. But the ledger still needs debit and credit entries so the system remains explainable.

Treating these flows separately helps avoid a common mistake: forcing every money movement through one generic transaction path and then adding exceptions until nobody understands it.

Admin And Support Tools

Wallet products need internal tooling from the beginning. This does not mean giving support staff dangerous powers. It means giving them visibility.

Useful support views:

  • User wallet overview by asset.
  • Transaction timeline with state changes.
  • Chain event payload references.
  • Confirmation count and transaction hash.
  • Ledger entries connected to the transaction.
  • Risk flags and manual review status.
  • Last real-time event sent to the user.

Dangerous operations, such as manual adjustments, should require elevated permission, audit logging, and ideally a two-step review process. The goal is not to let support “fix balances” casually. The goal is to let the team understand the system without asking engineers to query production data every time a transaction looks stuck.

How This Architecture Solves The Trust Problem

The architecture solves the original problem by giving every layer a job.

The blockchain/indexer layer observes settlement reality. The chain listener normalizes messy external events. The ledger records product-safe financial movements. The transaction state machine gives users and support a shared vocabulary. The WebSocket layer reduces anxiety by making state changes visible quickly. The audit log makes sensitive actions explainable later.

This is how a wallet app earns trust in small moments. A user does not need to understand mempools, provider delays, or confirmation thresholds. They need the app to tell the truth clearly and consistently.

Failure Modes

A mature wallet backend is designed around failure.

Common failures:

  • Chain congestion delays confirmations.
  • Provider webhooks arrive late or twice.
  • Indexer reports an event before the product considers it final.
  • A withdrawal broadcast succeeds but the response times out.
  • Cached balances drift from ledger truth.
  • A provider outage blocks fresh chain reads.

Handle these with:

  • Idempotent event processing.
  • Transaction reconciliation jobs.
  • Explicit retry states.
  • Confirmation thresholds per asset/chain.
  • Admin tools for safe manual review.
  • User-facing copy that distinguishes “detected”, “confirming”, and “available”.

Production Checklist

  • Every balance-affecting action creates ledger entries.
  • Every external event has an idempotency key.
  • Transaction states are explicit and visible to support.
  • WebSocket events are product-safe and versioned.
  • Withdrawal actions have rate limits and risk checks.
  • Provider payloads are stored for debugging but not trusted blindly.
  • Reconciliation jobs compare chain/indexer truth with internal ledger state.
  • Logs and analytics never contain secrets.
  • Manual review flows exist before launch, not after the first incident.

Closing Thought

Crypto wallet engineering is not about showing a balance on a screen. It is about protecting user trust while multiple unreliable systems interact: mobile devices, APIs, providers, blockchains, queues, databases, and humans.

The best wallet backends are boring in the right places. They make every financial movement traceable, every transaction state explainable, and every failure recoverable.