Design Digital Wallet
A wallet is a row of glass jars on a kitchen shelf and a notebook taped to the wall beside them. Each jar has a name on it — Alice, Bob, Carol — and inside the jar are pennies, not dollars. The notebook is the ledger. Nothing happens to any jar without a line being written in the notebook first, and the lines are numbered in pen so nobody can renumber them later. If you tip a jar over and pennies spill, the notebook still says the truth. The jar is just the count. The notebook is the story.

The reason every modern wallet — PayPal, Venmo, Cash App, the bank app on your phone — uses pennies and not dollars goes back to a problem the COBOL programmers at IBM hit in the 1960s. Floating-point numbers, the kind a computer uses for the value 19.99, do not store 19.99. They store the nearest binary fraction, which is something like 19.989999999999998. Add a hundred of those together and you have lost a cent. Add a million and you have lost a dollar. Bank software cannot lose dollars. The fix that stuck, and that every payments engineer still uses today, is to count the smallest unit — cents for dollars, paise for rupees, satoshis for bitcoin — as a plain integer and never let a decimal point near the math. Our jar holds 5000. The label says $50.00 because we only ever divide by 100 when we print.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Event {
Deposit { user: &'static str, cents: u64 },
Withdraw { user: &'static str, cents: u64 },
Transfer { from: &'static str, to: &'static str, cents: u64 },
}
#[derive(Debug, Clone)]
struct Txn {
id: u64,
note: String,
}
struct Wallet {
balances: HashMap<&'static str, u64>,
log: Vec<Txn>,
next_id: u64,
}Three things describe the whole wallet. Event is the list of things a user can ask for — deposit cash into a jar, take cash out of a jar, move cash from one jar to another. Txn is one line in the notebook — a number and a description of what happened. Wallet is the kitchen shelf — a map from name to penny count, the notebook itself as a vector of lines, and the next free line number. The penny count is a u64 because it never goes negative and it never has a fractional part. The compiler refuses to let you write bal -= cents if cents is bigger than bal, because u64 underflow is a hard error in debug builds — which is the very check the bank wants you to fail loudly on.
impl Wallet {
fn new(seeds: &[(&'static str, u64)]) -> Self {
let mut balances = HashMap::new();
for (user, cents) in seeds {
balances.insert(*user, *cents);
}
Self { balances, log: Vec::new(), next_id: 1 }
}
fn record(&mut self, note: String) {
let id = self.next_id;
self.next_id += 1;
self.log.push(Txn { id, note });
}
fn apply(&mut self, event: Event) -> Result<(), String> {
match event {
Event::Deposit { user, cents } => {
let bal = self.balances.get_mut(user).ok_or("no such user")?;
*bal += cents;
self.record(format!("deposit {} -> {}", fmt_money(cents), user));
Ok(())
}
Event::Withdraw { user, cents } => {
let bal = self.balances.get_mut(user).ok_or("no such user")?;
if *bal < cents {
return Err(format!("overdraft: {} has {}, asked {}", user, fmt_money(*bal), fmt_money(cents)));
}
*bal -= cents;
self.record(format!("withdraw {} from {}", fmt_money(cents), user));
Ok(())
}
Event::Transfer { from, to, cents } => {
let src = *self.balances.get(from).ok_or("no such sender")?;
if !self.balances.contains_key(to) {
return Err("no such recipient".to_string());
}
if src < cents {
return Err(format!("overdraft: {} has {}, asked {}", from, fmt_money(src), fmt_money(cents)));
}
*self.balances.get_mut(from).unwrap() -= cents;
*self.balances.get_mut(to).unwrap() += cents;
self.record(format!("transfer {} from {} to {}", fmt_money(cents), from, to));
Ok(())
}
}
}
}apply is the only door into the shelf. Every event walks through the same match and either succeeds — at which point the jars move and a line gets written — or fails — at which point nothing moves and no line gets written. The deposit branch grabs a mutable reference to the jar and adds to it. The withdraw branch checks the balance first and returns an error if the jar would go negative. The transfer branch is two checks before any change — sender must have the cash, recipient must exist — and then both jars move together. The record call is the last thing in every success path, so the notebook line number only ticks up when something real happened. A rejected withdraw does not burn a line number, which matches how a real bank works: a declined card is not a transaction, it is a non-event.
The thing easy to miss is that apply returns Result, not a panic. The caller decides what to do with a rejection — log it, show the user a message, retry later. The wallet itself never crashes on bad input. This is the same pattern banks have run since John Reed's team at Citibank built the first online deposit system in the late 1970s: a request comes in, you validate, you commit or you decline, you return the verdict. The system is still up tomorrow either way.
fn fmt_money(cents: u64) -> String {
format!("${}.{:02}", cents / 100, cents % 100)
}
fn show(wallet: &Wallet) {
let mut users: Vec<&&str> = wallet.balances.keys().collect();
users.sort();
print!(" balances:");
for user in users {
print!(" {}={}", user, fmt_money(wallet.balances[user]));
}
println!();
println!(" last 3 txns:");
let start = wallet.log.len().saturating_sub(3);
for txn in &wallet.log[start..] {
println!(" #{:03} {}", txn.id, txn.note);
}
}Printing is where pennies turn back into dollars. fmt_money divides by 100 for the whole part and mods by 100 for the cents, and the {:02} pads a single-digit cent count with a leading zero so 5 cents shows as $0.05 and not $0.5. show sorts the user names so the output is the same every run no matter what order Rust's hash map happens to iterate today — without the sort, the same code on two machines could print Alice first or Bob first, and the snapshot test would flap.

The driver in main seeds the three jars and runs through eight scripted events. Watch the fourth one — Carol asks to withdraw 10000 cents but only has 3500. The event prints, the rejection prints with the reason, the balances stay exactly where they were, and the last three notebook lines do not change. The next event then runs against the same balances Carol started with.
fn main() {
let mut wallet = Wallet::new(&[
("Alice", 5000),
("Bob", 2000),
("Carol", 1000),
]);
println!("opening: Alice=$50.00, Bob=$20.00, Carol=$10.00");
println!();
let events = [
Event::Deposit { user: "Alice", cents: 1500 },
Event::Withdraw { user: "Bob", cents: 500 },
Event::Transfer { from: "Alice", to: "Carol", cents: 2500 },
Event::Withdraw { user: "Carol", cents: 10000 },
Event::Transfer { from: "Bob", to: "Alice", cents: 750 },
Event::Deposit { user: "Carol", cents: 3000 },
Event::Transfer { from: "Carol", to: "Bob", cents: 1200 },
Event::Withdraw { user: "Alice", cents: 2000 },
];
for event in events {
println!("event: {:?}", event);
match wallet.apply(event) {
Ok(()) => println!(" ok"),
Err(why) => println!(" rejected: {}", why),
}
show(&wallet);
println!();
}
}opening: Alice=$50.00, Bob=$20.00, Carol=$10.00
event: Deposit { user: "Alice", cents: 1500 }
ok
balances: Alice=$65.00 Bob=$20.00 Carol=$10.00
last 3 txns:
#001 deposit $15.00 -> Alice
event: Withdraw { user: "Bob", cents: 500 }
ok
balances: Alice=$65.00 Bob=$15.00 Carol=$10.00
last 3 txns:
#001 deposit $15.00 -> Alice
#002 withdraw $5.00 from Bob
event: Transfer { from: "Alice", to: "Carol", cents: 2500 }
ok
balances: Alice=$40.00 Bob=$15.00 Carol=$35.00
last 3 txns:
#001 deposit $15.00 -> Alice
#002 withdraw $5.00 from Bob
#003 transfer $25.00 from Alice to Carol
event: Withdraw { user: "Carol", cents: 10000 }
rejected: overdraft: Carol has $35.00, asked $100.00
balances: Alice=$40.00 Bob=$15.00 Carol=$35.00
last 3 txns:
#001 deposit $15.00 -> Alice
#002 withdraw $5.00 from Bob
#003 transfer $25.00 from Alice to Carol
event: Transfer { from: "Bob", to: "Alice", cents: 750 }
ok
balances: Alice=$47.50 Bob=$7.50 Carol=$35.00
last 3 txns:
#002 withdraw $5.00 from Bob
#003 transfer $25.00 from Alice to Carol
#004 transfer $7.50 from Bob to Alice
event: Deposit { user: "Carol", cents: 3000 }
ok
balances: Alice=$47.50 Bob=$7.50 Carol=$65.00
last 3 txns:
#003 transfer $25.00 from Alice to Carol
#004 transfer $7.50 from Bob to Alice
#005 deposit $30.00 -> Carol
event: Transfer { from: "Carol", to: "Bob", cents: 1200 }
ok
balances: Alice=$47.50 Bob=$19.50 Carol=$53.00
last 3 txns:
#004 transfer $7.50 from Bob to Alice
#005 deposit $30.00 -> Carol
#006 transfer $12.00 from Carol to Bob
event: Withdraw { user: "Alice", cents: 2000 }
ok
balances: Alice=$27.50 Bob=$19.50 Carol=$53.00
last 3 txns:
#005 deposit $30.00 -> Carol
#006 transfer $12.00 from Carol to Bob
#007 withdraw $20.00 from AliceRead the output from the top and trace the dollars yourself. Opening totals: 50 + 20 + 10 = 80. Alice deposits 15, total goes to 95. Bob withdraws 5, total goes to 90. Alice transfers 25 to Carol — total stays 90, because a transfer is a closed loop inside the shelf. Carol's overdraft attempt is rejected and the total is still 90. Bob sends 7.50 to Alice, Carol deposits 30, Carol sends 12 to Bob, Alice withdraws 20. Final totals: 27.50 + 19.50 + 53.00 = 100. The deposits added 45 and the withdrawals took 25, so the shelf grew by 20 — which is what the math says.
One question worth asking — what happens if you change the order of the two balance lookups in the transfer branch, so the recipient gets credited before the sender is debited and the sender turns out to be broke? Look at the code. The check if src < cents runs before either jar moves, so the early return fires and nothing changes. But if a future engineer rewrites that to credit first and check after, the wallet would briefly hold more money than exists, and a second concurrent transfer reading the inflated balance could overdraft. The order of checks is the rule.
The thing this wallet cannot do is survive two events at the same instant. The apply method takes &mut self, so the Rust compiler will not let two threads call it on the same wallet — which is fine for one shelf in one kitchen, but the moment the bank has ten thousand shelves and a million customers, the next bottleneck is how to let many wallets process events at once without losing pennies in the cracks between them.