KettlePay is a settlement app for high-volume watch and diamond dealers — people closing six-figure deals who’ve been de-risked by their banks and burned by crypto’s sharp edges. As founding design engineer I owned it end to end: defining who the user is, the information architecture, every interaction and line of copy, the motion — and shipping the React Native that runs it. Through its early months it compounded at roughly 300% month over month — it now moves $300M+ a month.
I wrote down the user before I drew a screen
Where it started
The first artifact wasn’t a flow — it was a document describing exactly who this is for. Most users are 20-to-60-year-old dealers who have never used a fintech app, let alone a crypto one. Two of them sit at every table: one is comfortable with crypto but lives in fear of a frozen wallet; the other is fiat-native, has been pushed out of traditional banking for being a dealer, and distrusts crypto and us in equal measure. The one thing they share — they will not learn crypto vocabulary — pre-decided almost every call that followed.
Image placeholderPersona comparison — A vs BPersona A: crypto-comfortable, settles in stablecoins, biggest fear is a frozen wallet. Persona B: fiat-native, de-risked from traditional banks for being a dealer, distrusts crypto and us. Shared truth: won't learn crypto vocabulary.
A pipe, not a wallet
The bet
The obvious build is a crypto Venmo. But person-to-person transfers are a rounding error here; the real job is money in, money out — a two-port pipe. So KettlePay isn’t a wallet you live inside, it’s a settlement tool you open to close a deal and leave. Balances are held as dollars, the chain stays invisible, and the whole app orbits the in-and-out flows instead of a social feed.
Image placeholderThe two-port pipeMoney in (deposit, receive) → a single dollar balance → money out (send, withdraw). Person-to-person transfer is negligible. Show the in → balance → out flow, not a social feed.
Crypto does the work, never shows its face
The hard part
Dollars, not tokens. “Bitcoin”, not “BTC”. No gas, no seed-phrase theatre, no wallet addresses standing in for people where it can be avoided. And where crypto’s reality genuinely leaks — a cross-chain send that reads “Sent” the instant it leaves but takes minutes to actually arrive — the interface tells the truth about delivery instead of pretending it’s instant.
Three tabs, and everything else is a flow
Architecture
The app is three tabs — Home, Activity, Me — and nothing more. The money actions aren’t destinations you navigate to; they’re full-screen flows that launch from Home and drop the tab bar so you always know you’re mid-action. Activity is the ledger and the in/out chart. Keeping the surface this small is what lets a first-time user trust it.
Image placeholderInformation architectureThree tabs: Home, Activity, Me. The four money actions (Send, Receive, Deposit, Withdraw) launch from Home as full-screen modals that hide the tab bar. Activity = transaction history + in/out chart. Diagram the tab structure and how the action flows branch off Home.
Trust is the product
Designing for the anxious first-timer
Persona B is watching their first six-figure transfer like a hawk. So the app is built to be trusted at a glance: it should feel like an account, not a wallet; there are no surprise states; the balance is always legible; and there’s always a plain-English read on what just happened and what comes next. For this user, confidence — not features — is what makes them stay.
Learn the app once
One interaction grammar
Every money flow shares one grammar: the same keypad, the same confirm, the same success screen — send, receive, withdraw, all the same shape. Action screens drop the tab bar so the mode is unmistakable. Send to someone new and it asks, once and reversibly, whether to save them; nothing is filed away silently. And numbers are sized so a seven-figure balance never overflows.
Image placeholderUser-flow graph — SendPick a counterparty (saved, or paste/scan an address) → choose network + token → enter the amount on the shared keypad → confirm → success. A new counterparty triggers a one-time, reversible 'save this person?' branch. Graph the steps and that branch.
Send
Crypto out — to a saved counterparty or a pasted address.
Receive
A QR and address; whatever lands is converted to USD on arrival.
Withdraw
The USD balance back out to a linked bank account.
Pull-to-refresh: a rendering problem, not an animation one
Engineering deep-dive
Matching Home’s branded pull-to-refresh on Activity looked trivial and wasn’t — the problem was what I was animating. Three tries to get it right:
Attempt 1 · The naive build
Animate the gap open
To open the ~52px gap I animated the height of a spacer at the top of the list. Every frame that changed the scroll view’s contentSize, so React Native re-ran Yoga layout across the whole list on the JS thread — and the indicator was a state-driven component re-rendering alongside it. JS collapsed to ~31fps and the pull visibly stuttered.
Attempt 2 · Off layout, off the JS thread
translateY, not height
I replaced the height animation with a translateY transform on an overflow-hidden wrapper — GPU-composited, so contentSize never changes and nothing re-lays-out. The gesture and the arrow→spinner morph run in a Reanimated worklet off shared values, so the indicator never re-renders. Then React.memo on the row plus a stable keyExtractor/renderItem stopped all ~60 rows re-rendering when the refreshing flag flips. JS holds 120fps; only the final data-commit blips.
Attempt 3 · Shipped
One hook, one grammar
Converged into a single usePullRefresh hook + PullIndicator, shared by Home and Activity: a 52px reveal, a trigger threshold at 7% of screen height, 280ms timing, a haptic latch when you cross it, a re-entrancy guard, and a minimum spinner duration so it never flashes. The same branded arrow→spinner everywhere.
Attempt 1 · The naive build
Animate the gap open
To open the ~52px gap I animated the height of a spacer at the top of the list. Every frame that changed the scroll view’s contentSize, so React Native re-ran Yoga layout across the whole list on the JS thread — and the indicator was a state-driven component re-rendering alongside it. JS collapsed to ~31fps and the pull visibly stuttered.
Attempt 2 · Off layout, off the JS thread
translateY, not height
I replaced the height animation with a translateY transform on an overflow-hidden wrapper — GPU-composited, so contentSize never changes and nothing re-lays-out. The gesture and the arrow→spinner morph run in a Reanimated worklet off shared values, so the indicator never re-renders. Then React.memo on the row plus a stable keyExtractor/renderItem stopped all ~60 rows re-rendering when the refreshing flag flips. JS holds 120fps; only the final data-commit blips.
Attempt 3 · Shipped
One hook, one grammar
Converged into a single usePullRefresh hook + PullIndicator, shared by Home and Activity: a 52px reveal, a trigger threshold at 7% of screen height, 280ms timing, a haptic latch when you cross it, a re-entrancy guard, and a minimum spinner duration so it never flashes. The same branded arrow→spinner everywhere.
UI first, features later
How it was built
I built the interface fully before the features filled in behind it — not a pile of features, but a UI built whole, with the engineering arriving into a shape that was already proven. Designing the entire surface first surfaced the real grammar and the edge cases early, instead of negotiating the UI screen-by-screen against a backend.
The throughline
Role & philosophy
Founding design engineer means owning the whole line — userbase strategy, IA, interaction, copy, motion, and the React Native that ships it. The throughline: make crypto invisible while it’s load-bearing, let the button tell the truth, one concept per screen and one grammar across them, and document the reasoning — not just the verdict.