System Design: Digital Wallet Service (Venmo/CashApp) — Transfers, Ledger, and Consistency

Core Requirements

A digital wallet holds a user’s balance and enables peer-to-peer transfers. Key operations: add money (top-up from a bank account or card), send money (transfer to another user), withdraw (send to a linked bank account), view transaction history. The core challenge: money transfers must be exactly-once and consistent. Double-spending, lost money, or duplicate credits are catastrophic and irreversible without manual intervention. At scale: Venmo processes millions of transactions per day; CashApp handles billions in daily volume.

Ledger Architecture

The wallet balance is not stored directly. Instead, store an immutable ledger of entries, and compute the balance by summing all entries. Each money movement creates two ledger entries (double-entry bookkeeping): a debit from the sender and a credit to the recipient. This is the same principle used by all banks and financial systems. Benefits: the ledger is append-only (no updates, only inserts) — reducing the chance of data corruption. Any balance can be recomputed from the ledger. The audit trail is complete by design.

-- Ledger table (append-only)
CREATE TABLE ledger_entries (
    entry_id    BIGSERIAL PRIMARY KEY,
    account_id  BIGINT    NOT NULL,
    amount      NUMERIC(18,2) NOT NULL,  -- positive=credit, negative=debit
    currency    CHAR(3)   NOT NULL,
    txn_id      UUID      NOT NULL,
    entry_type  VARCHAR   NOT NULL,  -- TRANSFER_DEBIT, TRANSFER_CREDIT, TOP_UP, WITHDRAWAL
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Current balance (materialized for performance)
CREATE TABLE wallet_balances (
    account_id  BIGINT PRIMARY KEY,
    balance     NUMERIC(18,2) NOT NULL DEFAULT 0,
    version     BIGINT        NOT NULL DEFAULT 0  -- for optimistic locking
);

Atomic Transfer with Optimistic Locking

A transfer must atomically debit the sender and credit the recipient. Both must succeed or neither should. Within a single database: wrap both updates in a transaction. Across databases (distributed wallets): use the saga pattern or two-phase commit. For a single-region wallet service: put all wallet balances in one database and use a transaction. Optimistic locking prevents overdrafts in a concurrent environment:

def transfer(sender_id, recipient_id, amount, idempotency_key):
    # Check for duplicate transaction
    if txn_exists(idempotency_key):
        return get_txn(idempotency_key)

    with db.transaction():
        # Lock both accounts (always lock in consistent order to avoid deadlocks)
        sender = db.query("SELECT * FROM wallet_balances WHERE account_id = %s FOR UPDATE",
                          min(sender_id, recipient_id))
        recip  = db.query("SELECT * FROM wallet_balances WHERE account_id = %s FOR UPDATE",
                          max(sender_id, recipient_id))

        if sender.balance < amount:
            raise InsufficientFundsError()

        # Debit sender
        db.execute("UPDATE wallet_balances SET balance = balance - %s WHERE account_id = %s",
                   amount, sender_id)
        # Credit recipient
        db.execute("UPDATE wallet_balances SET balance = balance + %s WHERE account_id = %s",
                   amount, recipient_id)
        # Append ledger entries
        db.execute("INSERT INTO ledger_entries (account_id, amount, txn_id, entry_type) VALUES (%s, -%s, %s, %s)",
                   sender_id, amount, idempotency_key, 'TRANSFER_DEBIT')
        db.execute("INSERT INTO ledger_entries (account_id, amount, txn_id, entry_type) VALUES (%s, %s, %s, %s)",
                   recipient_id, amount, idempotency_key, 'TRANSFER_CREDIT')

Idempotency

Network retries can cause duplicate transfer requests. Without idempotency: two debits from the sender. Solution: the client generates a UUID idempotency_key for each transfer. The server stores a transactions table with a unique constraint on idempotency_key. Before processing: check if the key exists. If yes: return the existing transaction result. If no: process and store atomically. The check-then-insert must be atomic (use INSERT … ON CONFLICT DO NOTHING with RETURNING, then re-fetch if insert returned nothing). Store the idempotency_key in the transactions table with a 24-hour TTL on a Redis check for fast deduplication before hitting the DB.

Scaling the Ledger

At high volume (millions of transactions/day), the ledger table grows large. Partitioning: partition the ledger by account_id range or by created_at month. Balance queries only need the current balance (from wallet_balances) — the ledger is only read for transaction history. Archive old ledger entries to cold storage (S3 Glacier) after 7 years (regulatory retention). The wallet_balances table is small (one row per user) and fast. Shard the wallet_balances table by account_id when a single database can no longer handle the write throughput. Cross-shard transfers require a distributed transaction protocol (two-phase commit via a coordinator, or the saga pattern with compensating transactions).

Interview Tips

  • Always lock accounts in a consistent order (e.g., by account_id ascending) to prevent deadlocks when two concurrent transfers involve the same pair of accounts in opposite directions.
  • Currency handling: store amounts as integers (cents) or NUMERIC(18,2) — never floating point. Floating-point arithmetic causes rounding errors in financial calculations.
  • Regulatory: transactions over $10,000 require Bank Secrecy Act (BSA) reporting (CTR). Track aggregated daily totals per user for compliance alerts.

Asked at: Coinbase Interview Guide

Asked at: Stripe Interview Guide

Asked at: Shopify Interview Guide

Asked at: Airbnb Interview Guide

Scroll to Top