skip to content
Skill Issue Dev | Dax the Dev
search
Part of series: Zera Origin Stories

Stuck Sell, Post-Graduation: Fixing a Trapped-Funds Bug Without a Redeploy

Print view

Sections

The worst kind of bug in DeFi is the one where users can deposit but can’t withdraw. ZeraSwap shipped one — quietly — for users holding bonding-curve positions on launchpad tokens that had already graduated. They couldn’t sell. They could see the balance, the AMM page existed, the price was real, but every sell attempt was blocked by an is_active check on the wrong account.

The fix landed at 6eafc74Fix stuck sell path for users on graduated launchpad tokens on 2026-04-19. No on-chain redeploy. The whole fix is a frontend orchestration on top of existing instructions.

This post is about why the bug existed and why I chose not to fix it on-chain.

The original sin: internal balances

When you bought a token on the bonding curve, the launchpad program didn’t mint compressed tokens to your wallet. It accumulated an internal_balance on a UserPosition PDA. This was deliberate — minting compressed tokens for every microcap pump.fun-style trade would have wrecked the cost calculus that makes compressed tokens viable in the first place. Internal balances are a single u64 update in a PDA. Compressed-token mints are a Light Protocol state-tree write. The latter is dramatically more expensive.

The trade-off was: at graduation time, the launchpad would convert internal balances to real compressed tokens via a withdraw_token + compress flow. Anyone who held an internal balance up to that moment got the conversion for free.

The bug: the launch program’s sell_token is gated by is_active. After graduation, is_active = false. The intended sell path is the AMM. But the AMM expects you to hold real compressed tokens, and a small cohort of users still had internal_balance > 0 because they hadn’t traded since graduation — meaning the conversion never fired for them.

Post-graduation, sell_token is blocked by is_active check and AMM swap_tokens_for_sol burn fails because users hold internal UserPosition.token_balance rather than actual compressed tokens. (6eafc74 commit message)

Two ways to fix it, picked the second

Option A: redeploy the launchpad program with a force_convert_on_sell branch. This is the obvious fix. It’s also the wrong fix. A program redeploy:

  • Costs me real SOL on mainnet.
  • Risks a regression on the entire 12-launch live ecosystem.
  • Requires every active client to re-fetch the IDL.
  • Can’t be reversed cleanly.

Option B: a frontend-only conversion path. This is what I shipped. Three steps, all using existing on-chain instructions:

  1. Call the launchpad’s existing withdraw_token instruction. It mints SPL tokens from the internal_balance to the user’s ATA, creating the ATA if needed.
  2. Call Light Protocol’s compress to convert the SPL ATA balance into real compressed tokens.
  3. Hand control to the existing AMM swap_tokens_for_sol flow, which now sees compressed tokens and works as designed.

From the diff:

// sdk/src/launchpad_client.ts
async convertInternalTokensToCompressed(
  user: PublicKey,
  tokenMint: PublicKey,
  amount: bigint,
  compressed: CompressedTokenHelper,
): Promise<string[]> {
  const txSigs: string[] = [];

  // Step 0 (rare): create the Light token pool if it doesn't exist.
  // Has to be its own tx because compress ix build-time requires the pool.
  const poolRegistered = await compressed.isTokenPoolRegistered(tokenMint);
  if (!poolRegistered) {
    const createPoolIx = await compressed.buildCreateTokenPoolInstruction(user, tokenMint);
    txSigs.push(await this.buildAndSendTransaction([createPoolIx]));
  }

  // Atomic tx: ensure ATA, withdraw_token (mint SPL), compress (burn SPL → cToken).
  const convertIxs: TransactionInstruction[] = [];
  if (!ataInfo) convertIxs.push(createAssociatedTokenAccountInstruction(...));
  convertIxs.push(await this.program.methods.withdrawToken(new BN(amount.toString())).accounts({...}).instruction());
  convertIxs.push(await compressed.buildCompressInstruction(user, tokenMint, amount, user, ata));

  txSigs.push(await this.buildAndSendTransaction(convertIxs));
  return txSigs;
}

The compute budget gotcha

Light Protocol operations need more than the default 200K compute units. The same diff bumps every transaction the launchpad client builds:

// Light Protocol operations need more than the default 200K CUs
transaction.add(
  ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }),
);

This is the kind of thing that’s “obvious” once you’ve spent half a day staring at Custom program error: Program failed to complete logs and finally noticed the CU exhaustion in the simulation output. Mine that lesson once, write it down, never lose it again.

The follow-up: SDK exports

I shipped the conversion path before the SDK exports were in place, which broke the Cloudflare build. Fix landed in 4224352Add compress/decompress helpers to CompressedTokenHelper eight minutes after the parent commit. The launchpad_client was importing compressed.isTokenPoolRegistered, compressed.buildCreateTokenPoolInstruction, compressed.buildCompressInstruction — none of which I’d actually exported on the helper class.

Eight minutes is not a flex. CF Pages caught what my local typecheck didn’t because I’d checked into the repo without re-running the SDK build. The lesson: any commit that adds a new public method on the SDK has to re-build the SDK barrel. CI for that is on my TODO list.

Trade-offs

Why not migrate every stuck user automatically with a cron? Two reasons. First, signing transactions on behalf of users without their explicit click is a regulatory and security minefield. Second, “stuck” is a reversible state — a user can trigger the conversion themselves. Forcing it for them spends gas they may not want to spend if they’re holding for a longer time horizon than I am.

Why not deprecate internal balances entirely? Because they’re the entire economic argument for the launchpad. Deprecating them means every microcap trade pays Light Protocol state-tree write costs, and the flatness of the bonding curve breaks. The internal-balance design is correct; the conversion path was just incomplete.

Why frontend instead of a relayer service? Because a relayer service is another piece of infrastructure to operate, monitor, and pay for. The frontend conversion is exactly two transactions worst-case (create pool + atomic convert), entirely user-signed, and it requires zero new servers.

What this taught me

The cheapest fix is the one that doesn’t touch on-chain code. If your design lets you compose a fix entirely out of existing instructions on the frontend, it should always win over a redeploy. The ZeraSwap design happened to be composable enough that the stuck-sell case had a cheap exit. That wasn’t free — it cost me a state_tree field I’d been religious about from the original AMM commit, and it cost me writing the convertInternalTokensToCompressed orchestration in the SDK. But it didn’t cost a redeploy or a regression test marathon.

The other thing this taught me: the moment you have a launchpad with a graduation flow, you have at least three “intermediate” account states that look broken to users. Document every one of them in the admin page’s docs tab. I should have done this on day one of the prediction markets sprint. I did it on day 60, after a Discord ping with the words “I can’t sell.”

Further reading

Hire me — book a 30-min call $ book →