Skip to content

fix(rewards): resolve the reward manager from chain state, not the config secret - #1015

Merged
rickyrombo merged 1 commit into
mainfrom
fix/reward-manager-from-chain-state
Aug 11, 2026
Merged

fix(rewards): resolve the reward manager from chain state, not the config secret#1015
rickyrombo merged 1 commit into
mainfrom
fix/reward-manager-from-chain-state

Conversation

@rickyrombo

Copy link
Copy Markdown
Contributor

The bug

Launchpad reward-code creation derived rewards_manager_pubkey from the currently configured launchpad deterministic secret:

rmKey := utils.DeriveRewardManagerKeypair(cfg.LaunchpadDeterministicSecret, mintPubKey)
rewardsManagerPubkey := base58.Encode(rmKey.Public().(ed25519.PublicKey))

The Solana reward manager account is created once, at coin launch, using whatever secret was live at the time — and it can never move. So if that secret is later rotated, an already-launched mint starts deriving a reward manager that has no Solana account.

The pool-creation branch then turns that into durable bad state. Its comment says "first reward against this mint? create the pool", but the condition it actually tests is "no pool exists for this derived reward manager". Those mean the same thing only while the secret never changes. After a rotation an established mint looks brand-new, and the code quietly creates a parallel pool bound to a reward manager that doesn't exist on chain.

Rewards written to that pool are not lost — redemption resolves the reward manager by mint, independently — but core then records a reward manager that doesn't correspond to the mint's actual Solana account.

The fix

Resolve it from sol_reward_manager_inits, which the Solana indexer writes from observed InitRewardManager instructions. That's ground truth, and it's unaffected by the secret rotating. It's also the source v1_coins_post_redeem.go already reads — which is why redemption was unaffected while creation wasn't.

Creating a pool still needs the reward manager private key for rm_owner_signature, and only derivation produces that. So the creation path derives the keypair and checks its public half against the reward manager Solana actually has. A mismatch is precisely the rotated-mint case, and it now fails loudly instead of creating a pool nothing can redeem against.

In practice that check rarely fires, which is the point:

  • established mint — resolving the real reward manager finds the existing pool, so the creation branch is never entered
  • mint launched under the current secret — derived equals real, creation proceeds as before
  • rotated mint with no pool — the one genuinely broken case, and the only one that errors

Both call sites (the HTTP handler and the bulk CLI) duplicated this logic; they now share launchpad.PrepareRewardPool so they can't drift.

Tests

launchpad/reward_pool_test.go covers:

  • a mint launched under an earlier secret reuses its real pool and creates nothing
  • a rotated mint with no existing pool fails with ErrRewardManagerMismatch and creates nothing
  • a mint with no indexed reward manager fails with ErrRewardManagerNotIndexed without touching cometbft
  • a mint launched under the current secret still creates its pool

Verified red against the previous behavior: reverting to derive-then-create fails three of these, including with an established mint must never look brand-new; creating a pool here is the phantom-pool bug.

Notes for review

  • Reads via app.pool rather than app.writePool — the latter is nil when WriteDbUrl is unset, and this is the same table and pool the redeem path already reads.
  • No backfill. Rewards already bound to a derived reward manager are untouched; this only stops new ones.

🤖 Generated with Claude Code

…nfig secret

Launchpad reward-code creation derived rewards_manager_pubkey from the
currently configured launchpad deterministic secret. The Solana reward manager
account is created once, at coin launch, with whatever secret was live then,
and it can never move — so once that secret is rotated, an already-launched
mint derives a reward manager that has no Solana account.

The pool-creation branch then made it worse. Its comment reads "first reward
against this mint? create the pool", but the condition it actually tests is
"no pool exists for this DERIVED reward manager". Those coincide only while the
secret never changes. After a rotation an established mint looks brand-new, and
the code silently creates a parallel pool bound to a reward manager that does
not exist on chain.

Resolve it instead from sol_reward_manager_inits, which the Solana indexer
writes from observed InitRewardManager instructions. That is ground truth and
is unaffected by the secret rotating. It is also the source redemption already
reads, which is why redemption is unaffected while creation was not.

Creating a pool still needs the reward manager PRIVATE key, which only
derivation can produce, so the creation path derives the keypair and checks its
public half against the reward manager Solana actually has. A mismatch is
exactly the rotated-mint case and now fails loudly instead of creating a pool
nothing can redeem against. In practice the check rarely fires: resolving the
real reward manager means an established mint finds its existing pool and never
enters the creation branch at all, while a mint launched under the current
secret derives the matching key and proceeds as before.

Shared by both call sites — the HTTP handler and the bulk CLI duplicated this
logic — so they cannot drift.

Tests cover the rotated-mint case (no pool created, loud error), an unindexed
mint, and a current-secret mint still creating its pool. Verified red against
the previous behavior: the rotated-mint cases fail with "an established mint
must never look brand-new".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rickyrombo
rickyrombo merged commit a2fa789 into main Aug 11, 2026
2 checks passed
@rickyrombo
rickyrombo deleted the fix/reward-manager-from-chain-state branch August 11, 2026 02:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant