From 96a55608e3ecc61f8f38fe28bdde749246670deb Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:30:24 -0700 Subject: [PATCH] fix(rewards): resolve the reward manager from chain state, not the config secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/v1_create_reward_code.go | 71 +++++------- cmd/create_reward_codes/main.go | 71 +++++------- launchpad/reward_pool.go | 155 +++++++++++++++++++++++++ launchpad/reward_pool_test.go | 198 ++++++++++++++++++++++++++++++++ 4 files changed, 408 insertions(+), 87 deletions(-) create mode 100644 launchpad/reward_pool.go create mode 100644 launchpad/reward_pool_test.go diff --git a/api/v1_create_reward_code.go b/api/v1_create_reward_code.go index 74c1d2c3..f30b18c2 100644 --- a/api/v1_create_reward_code.go +++ b/api/v1_create_reward_code.go @@ -2,13 +2,13 @@ package api import ( "context" - "crypto/ed25519" "crypto/rand" "fmt" "math/big" "strconv" "time" + "api.audius.co/launchpad" "api.audius.co/utils" "connectrpc.com/connect" v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" @@ -17,7 +17,6 @@ import ( "github.com/gagliardetto/solana-go" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" - "github.com/mr-tron/base58" "go.uber.org/zap" ) @@ -234,19 +233,18 @@ func (app *ApiServer) createAndInsertRewardCode(ctx context.Context, code, mint // already exists is reused; only the very first reward for a brand-new // mint triggers CreateRewardPool). // -// Three keys are involved: +// Two keys are involved: // - The per-mint claim authority eth key (secp256k1, from // DeriveEthAddressForMint). Signs the cometbft envelope and is the -// pool's sole initial authority. -// - The RM ed25519 keypair (from DeriveRewardManagerKeypair). Same -// keypair the solana-relay used to init the Solana reward manager -// state account; its public key IS the rewards_manager_pubkey. -// Signs the CreateRewardPool envelope's rm_owner_signature, which -// proves possession of the RM keypair and prevents pool-creation -// frontrunning. +// pool's sole initial authority. Derived from +// app.config.LaunchpadDeterministicSecret + the mint. +// - The RM ed25519 keypair (from DeriveRewardManagerKeypair). Only +// needed on the pool-creation path, to sign the CreateRewardPool +// envelope's rm_owner_signature. See launchpad.PrepareRewardPool: +// the rewards_manager_pubkey itself is read from indexed Solana state +// rather than derived, because derivation follows the launchpad secret +// and the Solana reward manager account does not. // -// Both are derived from app.config.LaunchpadDeterministicSecret + -// the mint, so they're available everywhere the secret is configured. // When the secret is empty, this function is a no-op and returns "" // (matches existing behavior for dev environments without launchpad // configuration). @@ -283,13 +281,6 @@ func (app *ApiServer) createRewardCode(ctx context.Context, code, mint string, a return "", fmt.Errorf("failed to convert eth claim-authority key: %w", err) } - // Derive the RM ed25519 keypair matching what the solana-relay used - // to init the Solana reward manager state account. The base58-encoded - // public key IS the rewards_manager_pubkey cometbft carries for this - // mint's pool. - rmKey := utils.DeriveRewardManagerKeypair(app.config.LaunchpadDeterministicSecret, mintPubKey) - rewardsManagerPubkey := base58.Encode(rmKey.Public().(ed25519.PublicKey)) - oap := sdk.NewOpenAudioSDK(app.config.AudiusdURL) oap.SetPrivKey(envelopeKey) @@ -299,32 +290,22 @@ func (app *ApiServer) createRewardCode(ctx context.Context, code, mint string, a } deadline := statusResp.Msg.ChainInfo.CurrentHeight + rewardPoolDeadlineWindow - // First reward against this mint? Create the pool. Pre-existing pool - // is the common case (every subsequent reward for the same mint). - if _, err := oap.Rewards.GetRewardPool(ctx, rewardsManagerPubkey); err != nil { - if connect.CodeOf(err) != connect.CodeNotFound { - return "", fmt.Errorf("failed to look up reward pool for RM %s: %w", rewardsManagerPubkey, err) - } - app.logger.Info("createRewardCode: Creating reward pool", - zap.String("mint", mint), - zap.String("rewards_manager_pubkey", rewardsManagerPubkey), - zap.String("claim_authority", claimAuthority)) - if _, createErr := oap.Rewards.CreateRewardPool(ctx, &v1.CreateRewardPool{ - RewardsManagerPubkey: rewardsManagerPubkey, - Authorities: []string{claimAuthority}, - }, rmKey, deadline); createErr != nil { - // Race window: two concurrent first-reward requests for the - // same brand-new mint can both observe NotFound and both - // submit CreateRewardPool. The second one will fail because - // the pool now exists. Re-fetch and treat "pool exists" as - // success — equivalent to having lost the race cleanly. - // Anything else is a real error. - if _, getErr := oap.Rewards.GetRewardPool(ctx, rewardsManagerPubkey); getErr != nil { - return "", fmt.Errorf("failed to create reward pool: %w", createErr) - } - app.logger.Info("createRewardCode: Lost CreateRewardPool race; pool now exists", - zap.String("rewards_manager_pubkey", rewardsManagerPubkey)) - } + // Resolve the reward manager from indexed Solana state and ensure its + // pool exists. Deriving the reward manager from the launchpad secret + // instead would, after a secret rotation, point an already-launched + // mint at a reward manager that has no Solana account. + rewardsManagerPubkey, err := launchpad.PrepareRewardPool( + ctx, + app.logger, + app.pool, + oap.Rewards, + app.config.LaunchpadDeterministicSecret, + mintPubKey, + claimAuthority, + deadline, + ) + if err != nil { + return "", err } reward, err := oap.Rewards.CreateReward(ctx, &v1.CreateReward{ diff --git a/cmd/create_reward_codes/main.go b/cmd/create_reward_codes/main.go index f525bf81..8867cd02 100644 --- a/cmd/create_reward_codes/main.go +++ b/cmd/create_reward_codes/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "crypto/ed25519" "encoding/csv" "errors" "flag" @@ -13,6 +12,7 @@ import ( "time" "api.audius.co/config" + "api.audius.co/launchpad" "api.audius.co/utils" "connectrpc.com/connect" v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" @@ -20,7 +20,6 @@ import ( "github.com/OpenAudio/go-openaudio/pkg/sdk" "github.com/gagliardetto/solana-go" "github.com/jackc/pgx/v5/pgxpool" - "github.com/mr-tron/base58" "go.uber.org/zap" ) @@ -275,15 +274,10 @@ func processCode(ctx context.Context, logger *zap.Logger, pool *pgxpool.Pool, cf oap := sdk.NewOpenAudioSDK(cfg.AudiusdURL) oap.SetPrivKey(privateKey) - // Derive the RM ed25519 keypair matching what the solana-relay used to - // init the Solana reward manager state account. The base58-encoded - // public key IS the rewards_manager_pubkey cometbft carries for this - // mint's pool. - rmKey := utils.DeriveRewardManagerKeypair(cfg.LaunchpadDeterministicSecret, mintPubKey) - rewardsManagerPubkey := base58.Encode(rmKey.Public().(ed25519.PublicKey)) - - // Ensure pool exists for this mint, then create the reward. - rewardAddress, err := ensurePoolAndCreateReward(ctx, logger, pool, oap, code, amount, claimAuthority, rewardsManagerPubkey, rmKey) + // Ensure pool exists for this mint, then create the reward. The + // rewards_manager_pubkey comes from indexed Solana state, not from + // derivation — see launchpad.PrepareRewardPool. + rewardAddress, err := ensurePoolAndCreateReward(ctx, logger, pool, oap, cfg.LaunchpadDeterministicSecret, code, amount, claimAuthority, mintPubKey) if err != nil { return CodeResult{ Code: code, @@ -323,7 +317,7 @@ func checkCodeExists(ctx context.Context, pool *pgxpool.Pool, code string) (bool // via the cometbft error string and resolved by reading the previously // stored reward_address from the local DB — the idempotency guarantee // the prior implementation provided is preserved. -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) { +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) { var statusResp *connect.Response[v1.GetStatusResponse] if err := retryOperation(func() error { var err error @@ -334,37 +328,24 @@ func ensurePoolAndCreateReward(ctx context.Context, logger *zap.Logger, pool *pg } deadline := statusResp.Msg.ChainInfo.CurrentHeight + rewardPoolDeadlineWindow - // Pool existence check. The common case (any non-first reward for the - // mint) is "pool exists, skip the create." Brand-new mints fall into - // the create branch exactly once — except for the race where two - // concurrent first-reward requests for the same mint both observe - // NotFound and both submit CreateRewardPool; the second one's tx - // fails, but the post-failure GetRewardPool will now find the pool, - // which we treat as success. + // Resolve the mint's on-chain reward manager and make sure its pool + // exists. The common case (any non-first reward for the mint) is "pool + // exists, skip the create"; see launchpad.PrepareRewardPool for the + // creation path and the rotated-secret guard. + var rewardsManagerPubkey string if err := retryOperation(func() error { - _, err := oap.Rewards.GetRewardPool(ctx, rewardsManagerPubkey) - if err == nil { - return nil - } - if connect.CodeOf(err) != connect.CodeNotFound { - return err - } - logger.Info("Creating reward pool", zap.String("rewards_manager_pubkey", rewardsManagerPubkey), zap.String("claim_authority", claimAuthority)) - if _, createErr := oap.Rewards.CreateRewardPool(ctx, &v1.CreateRewardPool{ - RewardsManagerPubkey: rewardsManagerPubkey, - Authorities: []string{claimAuthority}, - }, rmKey, deadline); createErr != nil { - // Race: another caller created the pool between our - // GetRewardPool and CreateRewardPool. Verify by re-fetching - // the pool; if it now exists we lost the race cleanly. - // Anything else is a real error. - if _, verifyErr := oap.Rewards.GetRewardPool(ctx, rewardsManagerPubkey); verifyErr != nil { - return createErr - } - logger.Info("Lost CreateRewardPool race; pool now exists", - zap.String("rewards_manager_pubkey", rewardsManagerPubkey)) - } - return nil + var err error + rewardsManagerPubkey, err = launchpad.PrepareRewardPool( + ctx, + logger, + pool, + oap.Rewards, + launchpadDeterministicSecret, + mintPubKey, + claimAuthority, + deadline, + ) + return err }); err != nil { return "", fmt.Errorf("failed to ensure reward pool: %w", err) } @@ -413,6 +394,12 @@ func retryOperation(operation func() error) error { return nil } + // Deterministic failures: a rotated launchpad secret or a mint with + // no indexed reward manager will fail identically on every attempt. + if errors.Is(err, launchpad.ErrRewardManagerMismatch) || errors.Is(err, launchpad.ErrRewardManagerNotIndexed) { + return err + } + lastErr = err } diff --git a/launchpad/reward_pool.go b/launchpad/reward_pool.go new file mode 100644 index 00000000..2008f4e3 --- /dev/null +++ b/launchpad/reward_pool.go @@ -0,0 +1,155 @@ +// Package launchpad holds logic shared by the code paths that bind launchpad +// reward codes to a mint's Solana reward manager (the HTTP handler in +// api/v1_create_reward_code.go and the bulk CLI in cmd/create_reward_codes). +package launchpad + +import ( + "context" + "crypto/ed25519" + "errors" + "fmt" + + "api.audius.co/utils" + "connectrpc.com/connect" + v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/gagliardetto/solana-go" + "github.com/jackc/pgx/v5" + "github.com/mr-tron/base58" + "go.uber.org/zap" +) + +var ( + // ErrRewardManagerNotIndexed means no InitRewardManager instruction has + // been indexed for the mint: either the coin was never launched, or the + // Solana indexer hasn't caught up yet. Either way there is no reward + // manager to bind rewards to, and guessing one by derivation is exactly + // the mistake this package exists to prevent. + ErrRewardManagerNotIndexed = errors.New("no indexed reward manager for mint") + + // ErrRewardManagerMismatch means the reward manager derived from the + // currently configured launchpad secret is not the reward manager that + // exists on Solana for this mint. That happens when the launchpad + // deterministic secret is rotated after the coin was launched: the + // Solana reward manager account was created once, at launch, with the + // secret that was live then, and it can never move. Creating a pool for + // the newly derived key would produce a pool whose reward manager has no + // Solana account, so rewards written to it can never be redeemed. + ErrRewardManagerMismatch = errors.New("derived reward manager does not match the mint's on-chain reward manager") +) + +// Querier is the subset of pgx pool behavior needed to resolve a mint's +// reward manager. Both *pgxpool.Pool and *dbv1.DBPools satisfy it. +type Querier interface { + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +// RewardPoolClient is the subset of *sdk.Rewards used to look up and create +// reward pools. It is an interface so the pool-creation guard below can be +// exercised without a live validator. +type RewardPoolClient interface { + GetRewardPool(ctx context.Context, rewardsManagerPubkey string) (*v1.GetRewardPoolResponse, error) + CreateRewardPool(ctx context.Context, msg *v1.CreateRewardPool, rmKey ed25519.PrivateKey, deadlineBlockHeight int64) (string, error) +} + +// ResolveRewardManager returns the reward manager state pubkey that actually +// exists on Solana for mint, read from sol_reward_manager_inits — rows the +// Solana indexer writes from observed InitRewardManager instructions. This is +// ground truth and, unlike derivation from the launchpad deterministic +// secret, is immune to that secret being rotated. It is the same source +// redemption reads (see api/v1_coins_post_redeem.go), which is why redemption +// is unaffected by a rotation while creation is not. +// +// A mint has exactly one reward manager in practice; the ordering only makes +// the result deterministic if the indexer ever records more than one, in +// which case the earliest init is the one with the pool history. +func ResolveRewardManager(ctx context.Context, db Querier, mint string) (string, error) { + var rewardManagerState string + err := db.QueryRow(ctx, ` + SELECT reward_manager_state + FROM sol_reward_manager_inits + WHERE mint = @mint + ORDER BY slot, signature, instruction_index + LIMIT 1 + `, pgx.NamedArgs{"mint": mint}).Scan(&rewardManagerState) + if errors.Is(err, pgx.ErrNoRows) { + return "", fmt.Errorf("%w (mint %s): the coin was never launched, or the Solana indexer has not caught up", ErrRewardManagerNotIndexed, mint) + } + if err != nil { + return "", fmt.Errorf("failed to look up reward manager for mint %s: %w", mint, err) + } + return rewardManagerState, nil +} + +// PrepareRewardPool resolves the mint's real reward manager, ensures a +// cometbft reward pool exists for it, and returns the rewards_manager_pubkey +// the caller should use for CreateReward. +// +// Creating a pool requires an rm_owner_signature, which requires the reward +// manager PRIVATE key — and only derivation can produce that. So on the +// creation path the keypair is derived and its public half is checked against +// the reward manager that Solana actually has. A mismatch means the launchpad +// secret has been rotated since this mint launched, which is precisely the +// case where creating a pool is wrong, so it fails loudly rather than falling +// back to the derived key. +// +// For an already-launched mint the mismatch is moot: looking up the real +// reward manager means GetRewardPool finds the existing pool and the creation +// branch is never entered. For a mint launched under the current secret the +// derived key equals the real one and creation proceeds as before. +func PrepareRewardPool( + ctx context.Context, + logger *zap.Logger, + db Querier, + client RewardPoolClient, + launchpadDeterministicSecret string, + mint solana.PublicKey, + claimAuthority string, + deadlineBlockHeight int64, +) (string, error) { + rewardsManagerPubkey, err := ResolveRewardManager(ctx, db, mint.String()) + if err != nil { + return "", err + } + + if _, err := client.GetRewardPool(ctx, rewardsManagerPubkey); err == nil { + // Pool already exists — the common case for any mint that has ever + // had a reward. + return rewardsManagerPubkey, nil + } else if connect.CodeOf(err) != connect.CodeNotFound { + return "", fmt.Errorf("failed to look up reward pool for reward manager %s: %w", rewardsManagerPubkey, err) + } + + // Pool creation path: we need the reward manager private key, so derive + // the keypair and verify it corresponds to the on-chain reward manager. + rmKey := utils.DeriveRewardManagerKeypair(launchpadDeterministicSecret, mint) + derivedPubkey := base58.Encode(rmKey.Public().(ed25519.PublicKey)) + if derivedPubkey != rewardsManagerPubkey { + return "", fmt.Errorf( + "%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", + ErrRewardManagerMismatch, mint, rewardsManagerPubkey, derivedPubkey, + ) + } + + logger.Info("creating reward pool", + zap.String("mint", mint.String()), + zap.String("rewards_manager_pubkey", rewardsManagerPubkey), + zap.String("claim_authority", claimAuthority)) + + if _, createErr := client.CreateRewardPool(ctx, &v1.CreateRewardPool{ + RewardsManagerPubkey: rewardsManagerPubkey, + Authorities: []string{claimAuthority}, + }, rmKey, deadlineBlockHeight); createErr != nil { + // Race window: two concurrent first-reward requests for the same + // brand-new mint can both observe NotFound and both submit + // CreateRewardPool. The second one fails because the pool now + // exists. Re-fetch and treat "pool exists" as success — equivalent + // to having lost the race cleanly. Anything else is a real error. + if _, getErr := client.GetRewardPool(ctx, rewardsManagerPubkey); getErr != nil { + return "", fmt.Errorf("failed to create reward pool: %w", createErr) + } + logger.Info("lost CreateRewardPool race; pool now exists", + zap.String("rewards_manager_pubkey", rewardsManagerPubkey)) + } + + return rewardsManagerPubkey, nil +} diff --git a/launchpad/reward_pool_test.go b/launchpad/reward_pool_test.go new file mode 100644 index 00000000..50fb0158 --- /dev/null +++ b/launchpad/reward_pool_test.go @@ -0,0 +1,198 @@ +package launchpad + +import ( + "context" + "crypto/ed25519" + "errors" + "testing" + + "api.audius.co/database" + "api.audius.co/utils" + "connectrpc.com/connect" + v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/gagliardetto/solana-go" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/mr-tron/base58" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// A mint launched under some earlier launchpad secret. Its Solana reward +// manager account was created at launch and cannot move, so once the secret is +// rotated the configured secret derives a different reward manager — one with +// no Solana account. Reward-code creation used to bind rewards to that derived +// value and build a parallel pool against it, silently. +// +// Synthetic values; the behavior does not depend on which pubkeys these are. +const ( + launchedMint = "GkQ4dGqXk1sTVpsxWpsLmWEDRHzHHKtVdiHnRPMcbTBd" + // The reward manager that actually exists on Solana for launchedMint, + // as indexed from its InitRewardManager instruction. + onChainRewardManager = "HRRe6fbSDudpsBmkfBnLNHQnKkKgvhVc4pdBfR9U1YQz" +) + +// rotatedSecret stands in for a launchpad secret that is not the one the mint +// was launched under, so the reward manager it derives is not the mint's. +const rotatedSecret = "0011223344556677889900112233445566778899001122334455667788990011" + +// fakeRewardPoolClient records what the caller asked cometbft to do so a test +// can assert that no pool was created, which is the whole point: the +// production failure was silent precisely because a pool got created. +type fakeRewardPoolClient struct { + pools map[string]bool + getCalls []string + createCalls []*v1.CreateRewardPool + createdRmKey []ed25519.PrivateKey +} + +func newFakeRewardPoolClient(existingPools ...string) *fakeRewardPoolClient { + pools := map[string]bool{} + for _, p := range existingPools { + pools[p] = true + } + return &fakeRewardPoolClient{pools: pools} +} + +func (f *fakeRewardPoolClient) GetRewardPool(ctx context.Context, rewardsManagerPubkey string) (*v1.GetRewardPoolResponse, error) { + f.getCalls = append(f.getCalls, rewardsManagerPubkey) + if f.pools[rewardsManagerPubkey] { + return &v1.GetRewardPoolResponse{}, nil + } + return nil, connect.NewError(connect.CodeNotFound, errors.New("reward pool not found")) +} + +func (f *fakeRewardPoolClient) CreateRewardPool(ctx context.Context, msg *v1.CreateRewardPool, rmKey ed25519.PrivateKey, deadlineBlockHeight int64) (string, error) { + f.createCalls = append(f.createCalls, msg) + f.createdRmKey = append(f.createdRmKey, rmKey) + f.pools[msg.RewardsManagerPubkey] = true + return "txhash", nil +} + +func (f *fakeRewardPoolClient) createdPubkeys() []string { + out := []string{} + for _, c := range f.createCalls { + out = append(out, c.RewardsManagerPubkey) + } + return out +} + +func seedRewardManagerInit(t *testing.T, pool *pgxpool.Pool, mint, rewardManagerState string) { + t.Helper() + _, err := pool.Exec(context.Background(), ` + INSERT INTO sol_reward_manager_inits + (signature, instruction_index, slot, min_votes, reward_manager_state, token_source, mint, manager, authority) + VALUES ($1, 0, 100, 3, $2, 'tokenSource', $3, 'manager', 'authority') + `, "sig-"+mint, rewardManagerState, mint) + require.NoError(t, err) +} + +func derivedRewardManager(secret string, mint solana.PublicKey) string { + key := utils.DeriveRewardManagerKeypair(secret, mint) + return base58.Encode(key.Public().(ed25519.PublicKey)) +} + +func TestPrepareRewardPool(t *testing.T) { + pool := database.CreateTestDatabase(t, "test_api") + ctx := context.Background() + logger := zap.NewNop() + + mint := solana.MustPublicKeyFromBase58(launchedMint) + + // Sanity: the scenario under test is only meaningful if the configured + // secret derives something other than the mint's real reward manager. + require.NotEqual(t, onChainRewardManager, derivedRewardManager(rotatedSecret, mint)) + + t.Run("launched mint after a secret rotation reuses its real pool and creates nothing", func(t *testing.T) { + seedRewardManagerInit(t, pool, launchedMint, onChainRewardManager) + // cometbft already has the pool for the mint's real reward manager + // (441 rewards' worth of history, in production). + client := newFakeRewardPoolClient(onChainRewardManager) + + rm, err := PrepareRewardPool(ctx, logger, pool, client, rotatedSecret, mint, "0xclaimauthority", 1000) + require.NoError(t, err) + + assert.Equal(t, onChainRewardManager, rm, + "rewards must bind to the reward manager that exists on Solana, not one derived from the current secret") + assert.Empty(t, client.createdPubkeys(), + "an established mint must never look brand-new; creating a pool here is the phantom-pool bug") + assert.Equal(t, []string{onChainRewardManager}, client.getCalls) + }) + + t.Run("rotated secret with no existing pool fails loudly instead of creating a phantom pool", func(t *testing.T) { + rotatedMint := solana.MustPublicKeyFromBase58("So11111111111111111111111111111111111111112") + otherRewardManager := "HRRe6fbSDudpsBmkfBnLNHQnKkKgvhVc4pdBfR9U1YQy" + seedRewardManagerInit(t, pool, rotatedMint.String(), otherRewardManager) + client := newFakeRewardPoolClient() + + _, err := PrepareRewardPool(ctx, logger, pool, client, rotatedSecret, rotatedMint, "0xclaimauthority", 1000) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrRewardManagerMismatch) + assert.Contains(t, err.Error(), otherRewardManager) + assert.Contains(t, err.Error(), derivedRewardManager(rotatedSecret, rotatedMint)) + assert.Empty(t, client.createdPubkeys(), + "a pool signed by a key that has no Solana reward manager account must never be created") + }) + + t.Run("mint with no indexed reward manager fails without touching cometbft", func(t *testing.T) { + unlaunched := solana.MustPublicKeyFromBase58("11111111111111111111111111111111") + client := newFakeRewardPoolClient() + + _, err := PrepareRewardPool(ctx, logger, pool, client, rotatedSecret, unlaunched, "0xclaimauthority", 1000) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrRewardManagerNotIndexed) + assert.Empty(t, client.getCalls) + assert.Empty(t, client.createdPubkeys()) + }) + + t.Run("mint launched under the current secret still creates its pool", func(t *testing.T) { + newMint := solana.MustPublicKeyFromBase58("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + realRewardManager := derivedRewardManager(rotatedSecret, newMint) + seedRewardManagerInit(t, pool, newMint.String(), realRewardManager) + client := newFakeRewardPoolClient() + + rm, err := PrepareRewardPool(ctx, logger, pool, client, rotatedSecret, newMint, "0xclaimauthority", 1000) + require.NoError(t, err) + + assert.Equal(t, realRewardManager, rm) + assert.Equal(t, []string{realRewardManager}, client.createdPubkeys()) + assert.Equal(t, []string{"0xclaimauthority"}, client.createCalls[0].Authorities) + // The pool must be signed by the private half of the reward manager + // that Solana has, which is what makes rm_owner_signature verify. + require.Len(t, client.createdRmKey, 1) + assert.Equal(t, realRewardManager, base58.Encode(client.createdRmKey[0].Public().(ed25519.PublicKey))) + }) + + t.Run("existing pool for a matching derivation is reused", func(t *testing.T) { + newMint := solana.MustPublicKeyFromBase58("mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So") + realRewardManager := derivedRewardManager(rotatedSecret, newMint) + seedRewardManagerInit(t, pool, newMint.String(), realRewardManager) + client := newFakeRewardPoolClient(realRewardManager) + + rm, err := PrepareRewardPool(ctx, logger, pool, client, rotatedSecret, newMint, "0xclaimauthority", 1000) + require.NoError(t, err) + + assert.Equal(t, realRewardManager, rm) + assert.Empty(t, client.createdPubkeys()) + }) +} + +func TestResolveRewardManager(t *testing.T) { + pool := database.CreateTestDatabase(t, "test_api") + ctx := context.Background() + + t.Run("returns the indexed reward manager state", func(t *testing.T) { + seedRewardManagerInit(t, pool, launchedMint, onChainRewardManager) + rm, err := ResolveRewardManager(ctx, pool, launchedMint) + require.NoError(t, err) + assert.Equal(t, onChainRewardManager, rm) + }) + + t.Run("errors when the mint has never been indexed", func(t *testing.T) { + _, err := ResolveRewardManager(ctx, pool, "NotAnIndexedMint") + require.Error(t, err) + assert.ErrorIs(t, err, ErrRewardManagerNotIndexed) + }) +}