What Is a Digital Wallet?
A digital wallet stores funds and enables transfers between users and merchants. Examples: PayPal, Venmo, Apple Pay, Google Pay, Cash App. Core challenges: exactly-once transactions (no double charges or double credits), consistent balance across concurrent operations, and preventing fraud while maintaining low latency.
System Requirements
Functional
- Add funds via bank transfer or credit card
- Send money to another user
- Pay merchants
- View transaction history
- Link and manage bank accounts
Non-Functional
- 100M users, 10K transactions/second
- No double charges, no lost transfers
- Transaction finality within 5 seconds
- Compliance: PCI-DSS for card data, AML for large transfers
Core Data Model — Double-Entry Accounting
Never update a balance directly. Use a ledger (journal) of immutable transactions:
accounts: id, user_id, currency, balance (cached sum)
transactions: id, idempotency_key, status, created_at
ledger_entries: id, transaction_id, account_id, amount, entry_type(debit/credit)
Every transaction creates exactly two ledger entries: one debit (balance decreases) and one credit (balance increases). The sum of all entries for an account equals its balance. This double-entry model means the ledger always balances — total debits = total credits across all accounts. Auditors can verify correctness by recomputing balances from the ledger.
Transfer Implementation
def transfer(sender_id, receiver_id, amount, idempotency_key):
# Idempotency: check if this transfer already happened
if Transaction.exists(idempotency_key):
return Transaction.get(idempotency_key)
with db.transaction():
# Lock both accounts (order by ID to prevent deadlock)
sender = Account.lock(min(sender_id, receiver_id))
receiver = Account.lock(max(sender_id, receiver_id))
if sender.balance < amount:
raise InsufficientFunds()
txn = Transaction.create(idempotency_key=idempotency_key)
LedgerEntry.create(txn, sender, -amount, 'debit')
LedgerEntry.create(txn, receiver, +amount, 'credit')
sender.balance -= amount # update cached balance
receiver.balance += amount
txn.status = 'completed'
return txn
Lock ordering (min account ID first) prevents deadlocks when two concurrent transfers involve the same two accounts in opposite directions.
Idempotency
The caller provides an idempotency_key (UUID) with each transfer. If the network times out and the caller retries, the second call sees the existing transaction and returns it — no double transfer. The idempotency check must be inside the same transaction as the transfer to prevent TOCTOU (time-of-check-time-of-use) race conditions.
External Transfers (Bank Deposits/Withdrawals)
Bank transfers via ACH take 1-3 business days and can be reversed (insufficient funds, fraudulent). Wallet credits are “pending” until ACH settles. Two-phase approach: credit the wallet with “pending” status immediately (good UX), set a hold on spending (cannot withdraw), lift the hold when ACH confirms settlement. On ACH reversal: debit the wallet and notify the user.
Fraud Detection
Rules evaluated in real-time before each transaction:
- Velocity: more than 10 transfers in 1 hour → flag for review
- Amount: single transfer over $10,000 → AML report (CTR)
- Geo anomaly: transfer from country never seen for this user
- ML model: predict fraud probability from 50+ features
Flagged transactions are held for manual review. High-confidence fraud: auto-decline and freeze account.
Scaling Balances
The cached balance column (accounts.balance) is a hotspot for high-volume wallets (a merchant receiving thousands of payments per second). Solutions: (1) Optimistic locking: use a version column; retry on conflict — works for moderate contention. (2) Balance sharding: split the balance across N virtual sub-accounts; aggregate for read, distribute writes. (3) Asynchronous balance update: update ledger entries synchronously, update cached balance asynchronously from Kafka — accept slight staleness for display balance.
Interview Tips
- Double-entry ledger is the correct financial data model — mention it explicitly.
- Lock ordering by account ID prevents deadlocks.
- Idempotency key is the primary exactly-once mechanism.
- Pending state for external transfers handles ACH reversal risk.
{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What is double-entry accounting and why is it the correct model for a digital wallet ledger?”,
“acceptedAnswer”: { “@type”: “Answer”, “text”: “Double-entry accounting records every financial event as two equal and opposite entries: a debit on one account and a credit on another. For a $100 transfer from Alice to Bob: debit Alice's account $100 (balance decreases), credit Bob's account $100 (balance increases). The sum of all debits must equal the sum of all credits across the entire ledger — this is the accounting equation and serves as an internal consistency check. Why use it for a wallet: (1) Immutable audit trail — every balance change is backed by a ledger entry; you can recompute any account's balance from scratch by summing its entries. (2) Correctness verification — if total debits != total credits, something is wrong and can be detected automatically. (3) No lost money — money cannot appear or disappear; it only moves between accounts. (4) Regulatory compliance — double-entry is the international standard for financial record-keeping. The alternative (just updating a balance column) loses the history and makes debugging financial discrepancies nearly impossible.” }
},
{
“@type”: “Question”,
“name”: “How do you prevent deadlocks when locking two accounts for a transfer?”,
“acceptedAnswer”: { “@type”: “Answer”, “text”: “Deadlock occurs when two concurrent transfers both lock the same two accounts in opposite orders. Transfer A-to-B: lock A, then try to lock B. Transfer B-to-A: lock B, then try to lock A. Both hold one lock and wait for the other — deadlock. Solution: always acquire locks in a canonical order — sort account IDs and always lock the lower ID first. Transfer A-to-B: lock min(A,B), then lock max(A,B). Transfer B-to-A: also locks min(A,B), then max(A,B). Both transfers lock in the same order; one proceeds while the other waits. No circular dependency, no deadlock. Implementation: sort the account IDs at the start of the transfer function, then issue SELECT … FOR UPDATE in ID order. This works because database row-level locks are acquired sequentially. Additional defense: set a lock timeout (SET lock_timeout = 5000 in PostgreSQL) so a transfer waiting too long fails with an error rather than hanging indefinitely. Retry on timeout with exponential backoff.” }
},
{
“@type”: “Question”,
“name”: “How do you handle the idempotency key to prevent double transfers on network retries?”,
“acceptedAnswer”: { “@type”: “Answer”, “text”: “When a transfer request times out, the client does not know if the server processed it before the timeout. If the client retries without idempotency, the transfer executes twice — double-charging. Idempotency key solution: the client generates a unique UUID before the first attempt and sends it as a header: Idempotency-Key: {uuid}. The server stores the transfer result keyed by this UUID. On the first request: execute the transfer, store (uuid → result) in the DB (with a unique constraint on the idempotency_key column). Return the result. On a retry with the same UUID: the unique constraint triggers; the server fetches and returns the stored result without executing the transfer again. The idempotency check MUST happen inside the same database transaction as the transfer — not as a pre-check before the transaction. A pre-check (read → check → write) has a TOCTOU race: two concurrent requests both pass the pre-check, both proceed to execute. Only the DB-level unique constraint (inside the transaction) prevents duplicates atomically. TTL: idempotency keys expire after 24 hours — after which a retry is treated as a new transfer.” }
}
]
}