DEV Community

Cover image for How I Debugged Expensify Bugs & Got Paid
Tushar Bhendarkar
Tushar Bhendarkar

Posted on Originally published at tusharbhendarkar.com

How I Debugged Expensify Bugs & Got Paid

Expensify paid me $500 for fixing two bugs in their app. I don't work there. Nobody assigned me the work. I found the bugs on GitHub, wrote up the root causes, and my fixes were merged into a React Native app used by more than 10 million people.

The money is the hook, but it's not the story. The story is what it takes to get a fix merged into a codebase that size when nobody knows who you are. Here is how both bugs went down, plus two investigations that never became PRs but taught me just as much. (The contracts, payments and review are in the Expensify case study.)

How Expensify's bounty system works

Expensify runs one of the most open contributor programs of any production app. Bugs are filed as public GitHub issues, most carry a bounty (typically $250), and anyone can submit a proposal: a written explanation of the root cause and the fix you intend to make. If your proposal is approved, you implement it, go through review like any employee would, and get paid when it ships.

The catch: proposals compete. The way you win is not by being first, it's by being the person who actually understood the bug.

Bug 1: The onboarding modal that wouldn't go away

The symptom: a user connects Xero (accounting software) to their Expensify account, has two-factor authentication disabled, and refreshes the page. The onboarding modal pops up behind the 2FA setup prompt. This user finished onboarding long ago. The modal had no business being there (issue #46026).

The obvious place to look was the modal component. That was the wrong place.

The network tab told the real story. On app load, the client calls an OpenApp API to fetch initial user data into Onyx, Expensify's client-side state store. When Xero is connected and 2FA is disabled, the server refuses with error code 666 ("Your organization requires Two Factor Authentication") and returns empty data. That means the nvp_onboarding key, the flag saying "this user finished onboarding", never arrives on the client.

From there the dominoes fall in order: the hasCompletedGuidedSetupFlow selector reads the missing key and returns false, the navigation root sees an "un-onboarded" user, and it calculates the onboarding route as the app's initial state. The UI was doing exactly what it was told. The data was the problem.

Data flow of Expensify issue 46026: OpenApp returns error 666 with empty data, the onboarding flag never arrives, and navigation starts at onboarding. The fix makes the initial state aware of the pending 2FA requirement.
Every step did what it was told. The missing onboarding flag, not the modal, was the bug.

My fix (PR #46750, three files) made the initial-state calculation aware of the pending 2FA gate: if the 2FA requirement modal is about to show, don't treat missing onboarding data as "user needs onboarding". Merged, $250.

The lesson: when a screen does something impossible, ask what data it was missing, not which component misbehaved.

Bug 2: The chat that vanished when you pressed the down arrow

The symptom: in Search, navigating chats with the keyboard scrolled the selected chat out of view whenever the chats were large (issue #48902). An accessibility bug: if you can't see what's focused, keyboard navigation is useless.

The cause lived in React Native's SectionList virtualization. The shared list component computes getItemLayout offsets by hand, and the list header and footer were accounted for differently than SectionList expects. Combine that with SectionList's default behavior of keeping exactly one item above the focused one, and items that can grow taller than the screen, and the focused chat ends up off-screen.

Here is the part that made this bug hard: the component, BaseSelectionList, is shared by a huge surface of the app. The reviewer suggested only pushing section header layouts conditionally. I tested that branch across my accounts and found it broke the virtualized list entirely on one of them, and I posted a video proving it (the discussion). We converged on viewOffsetToKeepFocusedItemInView: admittedly a workaround, and I said so in the thread, but it was the change whose failure modes we could actually enumerate.

My fix (PR #51754) shipped. Merged, $250.

The lesson: in a shared component, the best fix is not the most elegant one. It's the one whose blast radius you can defend in review.

The investigations that never became PRs

Two more bugs ate my evenings without paying out, and they were still worth it.

The back button that went home(issue #47562): creating a workspace from the search page, then pressing back, dumped you at the inbox instead of where you came from. The existing code "fixed" this with Navigation.resetToHome(), which is a mask, not a fix. I traced the real cause into the core routing logic: an extra route was being pushed into the root navigation state because a flag (isCentralPaneAndBottomTabMandatory) stayed true during full-screen navigator actions. I proposed both the targeted fix and the deeper flag change, with file-and-line links for every claim.

The mis-styled system message(issue #47487): a system message ("exported this report to CSV") rendered faded with an error indicator. Everyone's instinct said frontend styling bug. I clicked "Reply in thread" and watched the backend return an error for exactly this message type and no other. The styling was the app correctly rendering a server-side data problem. That analysis redirected the fix to the PHP backend and saved someone a week of patching the wrong layer.

What transfers to any large codebase

  • Reproduce before you read code. Both merged fixes started with a reliable reproduction, not a theory.
  • Write the root cause down with evidence. Every proposal linked the exact lines involved. Maintainers can verify a claim in minutes instead of re-deriving it.
  • Trace the data, not the symptom. One bug looked like a modal problem and was a missing API payload. Another looked like CSS and was a backend error. The same rule explains why AI chatbots give wrong answers: the model is rarely the problem, the data it receives is.
  • Respect the blast radius. When a component is shared everywhere, choose the fix you can test the edges of, and show your testing.

If you want to break into a large open source codebase, find a project that pays for fixed bugs and treat the proposal like the product. The bounty is $250. The proof that you can walk into 10 million users' worth of unfamiliar code and leave it better is worth a lot more.

Common questions

How does the Expensify bug bounty program work?
Expensify files bugs as public GitHub issues, and most carry a bounty, typically $250. Anyone can submit a proposal explaining the root cause and the intended fix. If the proposal is approved, you implement it, go through code review like an employee would, and get paid when the fix ships.

How much does Expensify pay for a bug fix?
Most issues carry a bounty of about $250. The two fixes in this post paid $250 each, $500 in total.

Do you need to work at Expensify to fix their bugs?
No. Proposals are open to anyone. Both fixes here came from an outside contributor, working as a contractor through Upwork.

How do you get a proposal accepted on a large open source project?
Reproduce the bug reliably first, write the root cause down with links to the exact lines involved, trace the data rather than the screen where the symptom appears, and choose a fix whose side effects you can test and explain.


This post first appeared on tusharbhendarkar.com. I write about the engineering behind what I ship. If something here is broken for you, email me at tusharbhendarkar44@gmail.com.

Top comments (1)

Collapse
 
davekurian profile image
Dave Kurian •

When a screen looks impossible, tracing the data before the component is exactly the right move. For the SectionList case, I’d keep the regression around the focus target’s visibility after each keyboard move, with a row taller than the viewport and nonzero header/footer offsets; that exercises the layout assumptions you described. Did PR #51754 turn those cases into a lasting test, or was the video the key review evidence?