AnyPay, pay anything from any wallet

Cake LabsSenior Mobile Engineer2025 to 202616 merged PRs

A dead-end error dialog became a flow that reads almost any payment request, finds a wallet that can pay it, switches to it or swaps, and asks which chain when the address cannot say. Then a redesign with 134 review comments, and a service layer a week later.

Context

Before August 2025, if you pasted or scanned a payment request that the wallet you had open could not pay, Cake Wallet showed an “unmatched currencies” dialog and stopped. A person with five wallets and a Lightning invoice in hand got a dead end.

I replaced the dialog with a flow, and then spent a year making the flow honest about what it knew. Sixteen merged changes, about 14,900 lines added and 5,200 removed, across 303 file touches. The last two were the largest and the most argued over.

Problem

A payment request can be a bare address on any of fifteen chains, a payment URI, an ERC-681 request naming a chain and a token, a Solana Pay URI with a mint, a Tron URI, a Lightning invoice, a Lightning offer or an LNURL. The app has only the string.

Then the real problem underneath: a bare 0x address is valid on Ethereum, Polygon, Base, Arbitrum and BNB Smart Chain, and says nothing about which one the payer meant.

Constraints

The flow touches everyone else’s surface: the send page, the swap page, eleven exchange providers, the currency picker, the receive QR builder, deep links, the URL schemes on two platforms and the wallet switcher.

Cake’s Monero-only build has no EVM code at all, so the flow has to work when half the chains are missing.

The app had been generating its own QR codes for years, two generations of them, and both had to keep scanning.

Decisions and trade-offs

Detect with a purpose-built, specificity-ordered pattern list rather than reusing the address validator (August 2025). The validator’s patterns are deliberately broad because the caller already knows the currency. Detection has only the string. A reviewer asked twice for reuse. I kept them separate, ordered from most specific to broadest, with Solana’s base58 pattern last because it would swallow several others, and shipped 242 lines of tests with the detector. The cost is two pattern sets to keep in step.

One result type is the whole product (August 2025). The view model returns one of six outcomes: the current wallet can pay, exactly one other wallet can, several can, none can, undetectable, or error. The screens switch on that. Everything that came later fit into that union.

Quote swaps receive-side (September 2025). The easy version asks what you will spend. That is wrong for paying an invoice, where the recipient’s amount is the fixed quantity. Receive-side quoting meant teaching all eleven exchange providers a quote path they did not have, and it is why a provider log exists behind a developer page: with eleven providers each failing differently, a swap that will not quote is otherwise undiagnosable.

Ask which chain instead of guessing (November 2025, refined three times). Defaulting a 0x address to mainnet is the one-line version and silently sends on the wrong chain. Asking costs a step. I asked, then narrowed when the question is needed: the chain you are already on when nothing else is known, the URI’s chain whenever the URI names one, and, following the spec, the current network when a chain id is absent.

Read a raw token amount with the token’s decimals, and keep the old reading behind a plausibility bound (August 2026). A scanned QR for 1 USDT was being paid as 0.000000000001 USDT, because every raw value was read as 18 decimals. Reading with the token’s real decimals fixes new QRs and breaks every QR the app itself had generated before. Both had to work at once. The bound is one billion whole tokens, beyond any real payment, and when it trips the code logs that it took the legacy branch. A test pins the exact flip point.

Separate the decision from the UI, but only after the feature worked (August 2026). I shipped the redesign coupled to the send and swap pages, with a reviewer saying so on the merge and merging it anyway because the feature mattered. The next change pulled the decision into a pure router that takes a request, a wallet snapshot and a token resolution and returns one of six sealed decisions, with no context and no async, which is what made 718 lines of unit tests possible. Two large changes a week apart instead of one, and a week of shipped code the reviewers had already called the wrong shape. I would do it in that order again.

An unknown token is a refusal, not a fallback. When the resolver cannot identify a requested token, the router returns “unsupported token” rather than paying the chain’s native coin. Same principle as the WalletConnect work: show the thing you do not understand rather than substituting something you do.

What was hard

The review on the redesign. 134 inline comments, 83 review events, 45 commits, 33 days, three reviewers. It ran from missing semantic labels to whether the flow should exist in that shape at all. I conceded most of it, deferred a named set to the follow-up, and refused about six things with the mechanism rather than an opinion: why an error path must log rather than throw in a build with no EVM code, why static in-memory token lists are not database work, why a parse failure deliberately lands in the address field for the validator to reject.

Two generations of the app’s own QR codes. Old ones omitted mainnet’s chain id and encoded every token at 18 decimals. Fixing both meant the parser has to read the old shape correctly forever, which is why one of the changes is mostly a 344-line cross-generation test matrix.

Wallet switching mid-flow. The central move is “this belongs to a different wallet, let me switch you”, which means loading another wallet, selecting a chain on it, refreshing its balance, and landing back on a send form with the right currency, without any failure in that chain leaving someone on the wrong network. The alert on a failed switch came from a reviewer pointing out that a silent failure could do exactly that.

Result

Paste or scan almost any payment request and pay it from whatever wallet you have open. If the active wallet can pay, the form is filled in. If another of your wallets can, the app offers to switch and does the chain selection and balance refresh for you. If none can, it offers a swap that pays the recipient directly, quoted by what they must receive, priced in your fiat currency too, and funded from assets you hold.

When a request names an EVM address without a chain, the app asks rather than guesses. When it names a token it cannot identify, or a chain it does not support, it says so instead of sending the native coin. Lightning invoices pay from Lightning. And the QR codes the app itself generates now carry the token and the chain, so the next wallet to scan one does not have to guess either.

There are no usage numbers in this story because none exist in the repo, and I will not make one up.

All case studies