Skip to content

Commit a2fa789

Browse files
rickyromboclaude
andauthored
fix(rewards): resolve the reward manager from chain state, not the config secret (#1015)
## The bug Launchpad reward-code creation derived `rewards_manager_pubkey` from the currently configured launchpad deterministic secret: ```go 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`](api/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](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent e3d1ecc commit a2fa789

4 files changed

Lines changed: 408 additions & 87 deletions

File tree

‎api/v1_create_reward_code.go‎

Lines changed: 26 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ package api
22

33
import (
44
"context"
5-
"crypto/ed25519"
65
"crypto/rand"
76
"fmt"
87
"math/big"
98
"strconv"
109
"time"
1110

11+
"api.audius.co/launchpad"
1212
"api.audius.co/utils"
1313
"connectrpc.com/connect"
1414
v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1"
@@ -17,7 +17,6 @@ import (
1717
"github.com/gagliardetto/solana-go"
1818
"github.com/gofiber/fiber/v2"
1919
"github.com/jackc/pgx/v5"
20-
"github.com/mr-tron/base58"
2120
"go.uber.org/zap"
2221
)
2322

@@ -234,19 +233,18 @@ func (app *ApiServer) createAndInsertRewardCode(ctx context.Context, code, mint
234233
// already exists is reused; only the very first reward for a brand-new
235234
// mint triggers CreateRewardPool).
236235
//
237-
// Three keys are involved:
236+
// Two keys are involved:
238237
// - The per-mint claim authority eth key (secp256k1, from
239238
// DeriveEthAddressForMint). Signs the cometbft envelope and is the
240-
// pool's sole initial authority.
241-
// - The RM ed25519 keypair (from DeriveRewardManagerKeypair). Same
242-
// keypair the solana-relay used to init the Solana reward manager
243-
// state account; its public key IS the rewards_manager_pubkey.
244-
// Signs the CreateRewardPool envelope's rm_owner_signature, which
245-
// proves possession of the RM keypair and prevents pool-creation
246-
// frontrunning.
239+
// pool's sole initial authority. Derived from
240+
// app.config.LaunchpadDeterministicSecret + the mint.
241+
// - The RM ed25519 keypair (from DeriveRewardManagerKeypair). Only
242+
// needed on the pool-creation path, to sign the CreateRewardPool
243+
// envelope's rm_owner_signature. See launchpad.PrepareRewardPool:
244+
// the rewards_manager_pubkey itself is read from indexed Solana state
245+
// rather than derived, because derivation follows the launchpad secret
246+
// and the Solana reward manager account does not.
247247
//
248-
// Both are derived from app.config.LaunchpadDeterministicSecret +
249-
// the mint, so they're available everywhere the secret is configured.
250248
// When the secret is empty, this function is a no-op and returns ""
251249
// (matches existing behavior for dev environments without launchpad
252250
// configuration).
@@ -283,13 +281,6 @@ func (app *ApiServer) createRewardCode(ctx context.Context, code, mint string, a
283281
return "", fmt.Errorf("failed to convert eth claim-authority key: %w", err)
284282
}
285283

286-
// Derive the RM ed25519 keypair matching what the solana-relay used
287-
// to init the Solana reward manager state account. The base58-encoded
288-
// public key IS the rewards_manager_pubkey cometbft carries for this
289-
// mint's pool.
290-
rmKey := utils.DeriveRewardManagerKeypair(app.config.LaunchpadDeterministicSecret, mintPubKey)
291-
rewardsManagerPubkey := base58.Encode(rmKey.Public().(ed25519.PublicKey))
292-
293284
oap := sdk.NewOpenAudioSDK(app.config.AudiusdURL)
294285
oap.SetPrivKey(envelopeKey)
295286

@@ -299,32 +290,22 @@ func (app *ApiServer) createRewardCode(ctx context.Context, code, mint string, a
299290
}
300291
deadline := statusResp.Msg.ChainInfo.CurrentHeight + rewardPoolDeadlineWindow
301292

302-
// First reward against this mint? Create the pool. Pre-existing pool
303-
// is the common case (every subsequent reward for the same mint).
304-
if _, err := oap.Rewards.GetRewardPool(ctx, rewardsManagerPubkey); err != nil {
305-
if connect.CodeOf(err) != connect.CodeNotFound {
306-
return "", fmt.Errorf("failed to look up reward pool for RM %s: %w", rewardsManagerPubkey, err)
307-
}
308-
app.logger.Info("createRewardCode: Creating reward pool",
309-
zap.String("mint", mint),
310-
zap.String("rewards_manager_pubkey", rewardsManagerPubkey),
311-
zap.String("claim_authority", claimAuthority))
312-
if _, createErr := oap.Rewards.CreateRewardPool(ctx, &v1.CreateRewardPool{
313-
RewardsManagerPubkey: rewardsManagerPubkey,
314-
Authorities: []string{claimAuthority},
315-
}, rmKey, deadline); createErr != nil {
316-
// Race window: two concurrent first-reward requests for the
317-
// same brand-new mint can both observe NotFound and both
318-
// submit CreateRewardPool. The second one will fail because
319-
// the pool now exists. Re-fetch and treat "pool exists" as
320-
// success — equivalent to having lost the race cleanly.
321-
// Anything else is a real error.
322-
if _, getErr := oap.Rewards.GetRewardPool(ctx, rewardsManagerPubkey); getErr != nil {
323-
return "", fmt.Errorf("failed to create reward pool: %w", createErr)
324-
}
325-
app.logger.Info("createRewardCode: Lost CreateRewardPool race; pool now exists",
326-
zap.String("rewards_manager_pubkey", rewardsManagerPubkey))
327-
}
293+
// Resolve the reward manager from indexed Solana state and ensure its
294+
// pool exists. Deriving the reward manager from the launchpad secret
295+
// instead would, after a secret rotation, point an already-launched
296+
// mint at a reward manager that has no Solana account.
297+
rewardsManagerPubkey, err := launchpad.PrepareRewardPool(
298+
ctx,
299+
app.logger,
300+
app.pool,
301+
oap.Rewards,
302+
app.config.LaunchpadDeterministicSecret,
303+
mintPubKey,
304+
claimAuthority,
305+
deadline,
306+
)
307+
if err != nil {
308+
return "", err
328309
}
329310

330311
reward, err := oap.Rewards.CreateReward(ctx, &v1.CreateReward{

‎cmd/create_reward_codes/main.go‎

Lines changed: 29 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package main
22

33
import (
44
"context"
5-
"crypto/ed25519"
65
"encoding/csv"
76
"errors"
87
"flag"
@@ -13,14 +12,14 @@ import (
1312
"time"
1413

1514
"api.audius.co/config"
15+
"api.audius.co/launchpad"
1616
"api.audius.co/utils"
1717
"connectrpc.com/connect"
1818
v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1"
1919
"github.com/OpenAudio/go-openaudio/pkg/common"
2020
"github.com/OpenAudio/go-openaudio/pkg/sdk"
2121
"github.com/gagliardetto/solana-go"
2222
"github.com/jackc/pgx/v5/pgxpool"
23-
"github.com/mr-tron/base58"
2423
"go.uber.org/zap"
2524
)
2625

@@ -275,15 +274,10 @@ func processCode(ctx context.Context, logger *zap.Logger, pool *pgxpool.Pool, cf
275274
oap := sdk.NewOpenAudioSDK(cfg.AudiusdURL)
276275
oap.SetPrivKey(privateKey)
277276

278-
// Derive the RM ed25519 keypair matching what the solana-relay used to
279-
// init the Solana reward manager state account. The base58-encoded
280-
// public key IS the rewards_manager_pubkey cometbft carries for this
281-
// mint's pool.
282-
rmKey := utils.DeriveRewardManagerKeypair(cfg.LaunchpadDeterministicSecret, mintPubKey)
283-
rewardsManagerPubkey := base58.Encode(rmKey.Public().(ed25519.PublicKey))
284-
285-
// Ensure pool exists for this mint, then create the reward.
286-
rewardAddress, err := ensurePoolAndCreateReward(ctx, logger, pool, oap, code, amount, claimAuthority, rewardsManagerPubkey, rmKey)
277+
// Ensure pool exists for this mint, then create the reward. The
278+
// rewards_manager_pubkey comes from indexed Solana state, not from
279+
// derivation — see launchpad.PrepareRewardPool.
280+
rewardAddress, err := ensurePoolAndCreateReward(ctx, logger, pool, oap, cfg.LaunchpadDeterministicSecret, code, amount, claimAuthority, mintPubKey)
287281
if err != nil {
288282
return CodeResult{
289283
Code: code,
@@ -323,7 +317,7 @@ func checkCodeExists(ctx context.Context, pool *pgxpool.Pool, code string) (bool
323317
// via the cometbft error string and resolved by reading the previously
324318
// stored reward_address from the local DB — the idempotency guarantee
325319
// the prior implementation provided is preserved.
326-
func ensurePoolAndCreateReward(ctx context.Context, logger *zap.Logger, pool *pgxpool.Pool, oap *sdk.OpenAudioSDK, code string, amount int64, claimAuthority, rewardsManagerPubkey string, rmKey ed25519.PrivateKey) (string, error) {
320+
func ensurePoolAndCreateReward(ctx context.Context, logger *zap.Logger, pool *pgxpool.Pool, oap *sdk.OpenAudioSDK, launchpadDeterministicSecret, code string, amount int64, claimAuthority string, mintPubKey solana.PublicKey) (string, error) {
327321
var statusResp *connect.Response[v1.GetStatusResponse]
328322
if err := retryOperation(func() error {
329323
var err error
@@ -334,37 +328,24 @@ func ensurePoolAndCreateReward(ctx context.Context, logger *zap.Logger, pool *pg
334328
}
335329
deadline := statusResp.Msg.ChainInfo.CurrentHeight + rewardPoolDeadlineWindow
336330

337-
// Pool existence check. The common case (any non-first reward for the
338-
// mint) is "pool exists, skip the create." Brand-new mints fall into
339-
// the create branch exactly once — except for the race where two
340-
// concurrent first-reward requests for the same mint both observe
341-
// NotFound and both submit CreateRewardPool; the second one's tx
342-
// fails, but the post-failure GetRewardPool will now find the pool,
343-
// which we treat as success.
331+
// Resolve the mint's on-chain reward manager and make sure its pool
332+
// exists. The common case (any non-first reward for the mint) is "pool
333+
// exists, skip the create"; see launchpad.PrepareRewardPool for the
334+
// creation path and the rotated-secret guard.
335+
var rewardsManagerPubkey string
344336
if err := retryOperation(func() error {
345-
_, err := oap.Rewards.GetRewardPool(ctx, rewardsManagerPubkey)
346-
if err == nil {
347-
return nil
348-
}
349-
if connect.CodeOf(err) != connect.CodeNotFound {
350-
return err
351-
}
352-
logger.Info("Creating reward pool", zap.String("rewards_manager_pubkey", rewardsManagerPubkey), zap.String("claim_authority", claimAuthority))
353-
if _, createErr := oap.Rewards.CreateRewardPool(ctx, &v1.CreateRewardPool{
354-
RewardsManagerPubkey: rewardsManagerPubkey,
355-
Authorities: []string{claimAuthority},
356-
}, rmKey, deadline); createErr != nil {
357-
// Race: another caller created the pool between our
358-
// GetRewardPool and CreateRewardPool. Verify by re-fetching
359-
// the pool; if it now exists we lost the race cleanly.
360-
// Anything else is a real error.
361-
if _, verifyErr := oap.Rewards.GetRewardPool(ctx, rewardsManagerPubkey); verifyErr != nil {
362-
return createErr
363-
}
364-
logger.Info("Lost CreateRewardPool race; pool now exists",
365-
zap.String("rewards_manager_pubkey", rewardsManagerPubkey))
366-
}
367-
return nil
337+
var err error
338+
rewardsManagerPubkey, err = launchpad.PrepareRewardPool(
339+
ctx,
340+
logger,
341+
pool,
342+
oap.Rewards,
343+
launchpadDeterministicSecret,
344+
mintPubKey,
345+
claimAuthority,
346+
deadline,
347+
)
348+
return err
368349
}); err != nil {
369350
return "", fmt.Errorf("failed to ensure reward pool: %w", err)
370351
}
@@ -413,6 +394,12 @@ func retryOperation(operation func() error) error {
413394
return nil
414395
}
415396

397+
// Deterministic failures: a rotated launchpad secret or a mint with
398+
// no indexed reward manager will fail identically on every attempt.
399+
if errors.Is(err, launchpad.ErrRewardManagerMismatch) || errors.Is(err, launchpad.ErrRewardManagerNotIndexed) {
400+
return err
401+
}
402+
416403
lastErr = err
417404
}
418405

‎launchpad/reward_pool.go‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Package launchpad holds logic shared by the code paths that bind launchpad
2+
// reward codes to a mint's Solana reward manager (the HTTP handler in
3+
// api/v1_create_reward_code.go and the bulk CLI in cmd/create_reward_codes).
4+
package launchpad
5+
6+
import (
7+
"context"
8+
"crypto/ed25519"
9+
"errors"
10+
"fmt"
11+
12+
"api.audius.co/utils"
13+
"connectrpc.com/connect"
14+
v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1"
15+
"github.com/gagliardetto/solana-go"
16+
"github.com/jackc/pgx/v5"
17+
"github.com/mr-tron/base58"
18+
"go.uber.org/zap"
19+
)
20+
21+
var (
22+
// ErrRewardManagerNotIndexed means no InitRewardManager instruction has
23+
// been indexed for the mint: either the coin was never launched, or the
24+
// Solana indexer hasn't caught up yet. Either way there is no reward
25+
// manager to bind rewards to, and guessing one by derivation is exactly
26+
// the mistake this package exists to prevent.
27+
ErrRewardManagerNotIndexed = errors.New("no indexed reward manager for mint")
28+
29+
// ErrRewardManagerMismatch means the reward manager derived from the
30+
// currently configured launchpad secret is not the reward manager that
31+
// exists on Solana for this mint. That happens when the launchpad
32+
// deterministic secret is rotated after the coin was launched: the
33+
// Solana reward manager account was created once, at launch, with the
34+
// secret that was live then, and it can never move. Creating a pool for
35+
// the newly derived key would produce a pool whose reward manager has no
36+
// Solana account, so rewards written to it can never be redeemed.
37+
ErrRewardManagerMismatch = errors.New("derived reward manager does not match the mint's on-chain reward manager")
38+
)
39+
40+
// Querier is the subset of pgx pool behavior needed to resolve a mint's
41+
// reward manager. Both *pgxpool.Pool and *dbv1.DBPools satisfy it.
42+
type Querier interface {
43+
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
44+
}
45+
46+
// RewardPoolClient is the subset of *sdk.Rewards used to look up and create
47+
// reward pools. It is an interface so the pool-creation guard below can be
48+
// exercised without a live validator.
49+
type RewardPoolClient interface {
50+
GetRewardPool(ctx context.Context, rewardsManagerPubkey string) (*v1.GetRewardPoolResponse, error)
51+
CreateRewardPool(ctx context.Context, msg *v1.CreateRewardPool, rmKey ed25519.PrivateKey, deadlineBlockHeight int64) (string, error)
52+
}
53+
54+
// ResolveRewardManager returns the reward manager state pubkey that actually
55+
// exists on Solana for mint, read from sol_reward_manager_inits — rows the
56+
// Solana indexer writes from observed InitRewardManager instructions. This is
57+
// ground truth and, unlike derivation from the launchpad deterministic
58+
// secret, is immune to that secret being rotated. It is the same source
59+
// redemption reads (see api/v1_coins_post_redeem.go), which is why redemption
60+
// is unaffected by a rotation while creation is not.
61+
//
62+
// A mint has exactly one reward manager in practice; the ordering only makes
63+
// the result deterministic if the indexer ever records more than one, in
64+
// which case the earliest init is the one with the pool history.
65+
func ResolveRewardManager(ctx context.Context, db Querier, mint string) (string, error) {
66+
var rewardManagerState string
67+
err := db.QueryRow(ctx, `
68+
SELECT reward_manager_state
69+
FROM sol_reward_manager_inits
70+
WHERE mint = @mint
71+
ORDER BY slot, signature, instruction_index
72+
LIMIT 1
73+
`, pgx.NamedArgs{"mint": mint}).Scan(&rewardManagerState)
74+
if errors.Is(err, pgx.ErrNoRows) {
75+
return "", fmt.Errorf("%w (mint %s): the coin was never launched, or the Solana indexer has not caught up", ErrRewardManagerNotIndexed, mint)
76+
}
77+
if err != nil {
78+
return "", fmt.Errorf("failed to look up reward manager for mint %s: %w", mint, err)
79+
}
80+
return rewardManagerState, nil
81+
}
82+
83+
// PrepareRewardPool resolves the mint's real reward manager, ensures a
84+
// cometbft reward pool exists for it, and returns the rewards_manager_pubkey
85+
// the caller should use for CreateReward.
86+
//
87+
// Creating a pool requires an rm_owner_signature, which requires the reward
88+
// manager PRIVATE key — and only derivation can produce that. So on the
89+
// creation path the keypair is derived and its public half is checked against
90+
// the reward manager that Solana actually has. A mismatch means the launchpad
91+
// secret has been rotated since this mint launched, which is precisely the
92+
// case where creating a pool is wrong, so it fails loudly rather than falling
93+
// back to the derived key.
94+
//
95+
// For an already-launched mint the mismatch is moot: looking up the real
96+
// reward manager means GetRewardPool finds the existing pool and the creation
97+
// branch is never entered. For a mint launched under the current secret the
98+
// derived key equals the real one and creation proceeds as before.
99+
func PrepareRewardPool(
100+
ctx context.Context,
101+
logger *zap.Logger,
102+
db Querier,
103+
client RewardPoolClient,
104+
launchpadDeterministicSecret string,
105+
mint solana.PublicKey,
106+
claimAuthority string,
107+
deadlineBlockHeight int64,
108+
) (string, error) {
109+
rewardsManagerPubkey, err := ResolveRewardManager(ctx, db, mint.String())
110+
if err != nil {
111+
return "", err
112+
}
113+
114+
if _, err := client.GetRewardPool(ctx, rewardsManagerPubkey); err == nil {
115+
// Pool already exists — the common case for any mint that has ever
116+
// had a reward.
117+
return rewardsManagerPubkey, nil
118+
} else if connect.CodeOf(err) != connect.CodeNotFound {
119+
return "", fmt.Errorf("failed to look up reward pool for reward manager %s: %w", rewardsManagerPubkey, err)
120+
}
121+
122+
// Pool creation path: we need the reward manager private key, so derive
123+
// the keypair and verify it corresponds to the on-chain reward manager.
124+
rmKey := utils.DeriveRewardManagerKeypair(launchpadDeterministicSecret, mint)
125+
derivedPubkey := base58.Encode(rmKey.Public().(ed25519.PublicKey))
126+
if derivedPubkey != rewardsManagerPubkey {
127+
return "", fmt.Errorf(
128+
"%w: mint %s has on-chain reward manager %s but the configured launchpad secret derives %s; refusing to create a reward pool for a reward manager with no Solana account",
129+
ErrRewardManagerMismatch, mint, rewardsManagerPubkey, derivedPubkey,
130+
)
131+
}
132+
133+
logger.Info("creating reward pool",
134+
zap.String("mint", mint.String()),
135+
zap.String("rewards_manager_pubkey", rewardsManagerPubkey),
136+
zap.String("claim_authority", claimAuthority))
137+
138+
if _, createErr := client.CreateRewardPool(ctx, &v1.CreateRewardPool{
139+
RewardsManagerPubkey: rewardsManagerPubkey,
140+
Authorities: []string{claimAuthority},
141+
}, rmKey, deadlineBlockHeight); createErr != nil {
142+
// Race window: two concurrent first-reward requests for the same
143+
// brand-new mint can both observe NotFound and both submit
144+
// CreateRewardPool. The second one fails because the pool now
145+
// exists. Re-fetch and treat "pool exists" as success — equivalent
146+
// to having lost the race cleanly. Anything else is a real error.
147+
if _, getErr := client.GetRewardPool(ctx, rewardsManagerPubkey); getErr != nil {
148+
return "", fmt.Errorf("failed to create reward pool: %w", createErr)
149+
}
150+
logger.Info("lost CreateRewardPool race; pool now exists",
151+
zap.String("rewards_manager_pubkey", rewardsManagerPubkey))
152+
}
153+
154+
return rewardsManagerPubkey, nil
155+
}

0 commit comments

Comments
 (0)