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.
{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “Why is double-entry bookkeeping used in financial systems?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Double-entry bookkeeping records every money movement as two entries: a debit from one account and a credit to another. The sum of all entries must always be zero (debits = credits). This self-balancing property makes errors detectable: if the ledger is out of balance, a transaction was recorded incorrectly. For a digital wallet: when user A sends $100 to user B, a -$100 entry is added to A’s account and a +$100 entry to B’s account. The wallet balance is the sum of all entries for that account. Benefits: complete audit trail (every balance change is recorded), recoverability (balances can be recomputed from the ledger if the balance cache is corrupted), and regulatory compliance (financial systems are required to maintain immutable audit trails).”
}
},
{
“@type”: “Question”,
“name”: “How do you prevent deadlocks when acquiring database locks for transfers?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Deadlock scenario: transaction T1 transfers from account A to B (locks A then tries to lock B). Transaction T2 transfers from B to A (locks B then tries to lock A). Both hold one lock and wait for the other — deadlock. Solution: always acquire locks in a consistent order, such as ascending account_id. T1: lock min(A,B) first, then max(A,B). T2: same order. Both transactions now try to lock the same account first, so one will wait for the other to complete instead of deadlocking. Implementation: before starting the transaction, sort [sender_id, recipient_id] and acquire locks in that order. This eliminates the circular wait condition required for a deadlock. Apply this pattern to any system that acquires multiple row-level locks in a transaction.”
}
},
{
“@type”: “Question”,
“name”: “How does the idempotency key prevent duplicate transactions in a wallet?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “When a client sends a transfer request, it includes an idempotency_key (UUID) generated for that specific transfer. The server stores this key in the transactions table with a unique constraint. On the first request: no key found, process the transfer, store the result with the key. On a retry (network timeout caused the client to retry): key found, return the stored result without reprocessing. The key check and the transaction must be atomic. Use INSERT INTO transactions (idempotency_key, result) VALUES (…) ON CONFLICT (idempotency_key) DO NOTHING. Then SELECT to get the result (either the just-inserted row or the existing one). If two concurrent requests arrive with the same key simultaneously, only one INSERT succeeds; the other gets a unique constraint violation and reads the existing result. Keys expire after 24 hours to bound storage.”
}
},
{
“@type”: “Question”,
“name”: “How do you handle cross-shard transfers in a sharded wallet system?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “When wallets are sharded by account_id, two accounts in a transfer may live on different shards. A single database transaction cannot span shards. Options: (1) Two-phase commit (2PC): a coordinator acquires locks on both shards, executes the transfer, and commits on both. Consistent but slow (two round trips) and blocks resources during the prepare phase. (2) Saga pattern: debit sender shard first; if successful, credit recipient shard. If the credit fails (shard down): compensate by crediting the sender back. Maintain a saga log for recovery. More complex but avoids distributed locks. (3) Move hot accounts to the same shard: for frequent trading pairs (e.g., a business sending to many users), co-locate on the same shard. (4) Intermediate account: route transfers through a platform-owned intermediate account on a single shard — two local transactions instead of one distributed one.”
}
},
{
“@type”: “Question”,
“name”: “How do you detect suspicious activity like money laundering in a wallet system?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Rule-based detection: (1) Velocity: more than $10,000 in 24 hours triggers a Currency Transaction Report (CTR) to FinCEN (US law). Track daily totals per user in Redis; alert when the threshold is crossed. (2) Structuring: multiple transactions just below $10,000 to avoid CTR reporting — flag patterns of $9,500, $9,800, $9,200 from the same user. (3) Unusual patterns: a dormant account suddenly sending to 50 recipients, a new account receiving large amounts immediately and cashing out. ML-based: a transaction scoring model takes features (time of day, recipient age, amount relative to history, geographic anomaly) and outputs a fraud probability score. High scores queue for manual review. Freeze flagged accounts pending review. Log all flagged events for regulatory audits. Implement SAR (Suspicious Activity Report) filing workflow for the compliance team.”
}
}
]
}
Asked at: Coinbase Interview Guide
Asked at: Stripe Interview Guide
Asked at: Shopify Interview Guide
Asked at: Airbnb Interview Guide