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) + }) +}