Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 26 additions & 45 deletions api/v1_create_reward_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)

Expand All @@ -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{
Expand Down
71 changes: 29 additions & 42 deletions cmd/create_reward_codes/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package main

import (
"context"
"crypto/ed25519"
"encoding/csv"
"errors"
"flag"
Expand All @@ -13,14 +12,14 @@ 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"
"github.com/OpenAudio/go-openaudio/pkg/common"
"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"
)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}

Expand Down
155 changes: 155 additions & 0 deletions launchpad/reward_pool.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading