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
151 changes: 100 additions & 51 deletions api/v1_create_reward_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ const (
signedAuthMessage = "code"
codeLength = 10
codeChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"

// rewardPoolDeadlineWindow is the number of blocks ahead of the
// current height at which we set the deadline_block_height on
// cometbft tx envelopes that this server originates
// (CreateRewardPool, CreateReward). Cheap to keep generous: the
// deadline only bounds how stale a single signed envelope can sit
// before the validator rejects it.
rewardPoolDeadlineWindow = 100
)

type CreateRewardCodeRequest struct {
Expand Down Expand Up @@ -212,8 +220,27 @@ func (app *ApiServer) createAndInsertRewardCode(ctx context.Context, code, mint
return rewardAddress, nil
}

// createRewardCode creates or reuses a reward pool and returns the reward address.
// This is shared business logic used by both v1CreateRewardCode and prize claim flow.
// createRewardCode creates a cometbft reward bound to the launchpad mint's
// pool and returns the reward address. Idempotent on the pool (a pool that
// already exists is reused; only the very first reward for a brand-new
// mint triggers CreateRewardPool).
//
// Three 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.
//
// 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).
func (app *ApiServer) createRewardCode(ctx context.Context, code, mint string, amount int64, rewardName string) (string, error) {
app.logger.Info("createRewardCode: Starting",
zap.String("code", code),
Expand All @@ -223,65 +250,87 @@ func (app *ApiServer) createRewardCode(ctx context.Context, code, mint string, a
zap.Bool("has_deterministic_secret", app.config.LaunchpadDeterministicSecret != ""),
zap.String("audiusd_url", app.config.AudiusdURL))

var rewardAddress string
if app.config.LaunchpadDeterministicSecret == "" {
app.logger.Info("createRewardCode: Completed (no launchpad secret configured; reward pool skipped)",
zap.String("code", code))
return "", nil
}

// Only create reward pool if deterministic secret is configured
if app.config.LaunchpadDeterministicSecret != "" {
mintPubKey, err := solana.PublicKeyFromBase58(mint)
if err != nil {
return "", fmt.Errorf("invalid mint address: %w", err)
}
mintPubKey, err := solana.PublicKeyFromBase58(mint)
if err != nil {
return "", fmt.Errorf("invalid mint address: %w", err)
}

claimAuthority, claimAuthorityPrivateKey, err := utils.DeriveEthAddressForMint(
[]byte("claimAuthority"),
app.config.LaunchpadDeterministicSecret,
mintPubKey,
)
if err != nil {
return "", fmt.Errorf("failed to derive Ethereum key: %w", err)
}
claimAuthority, claimAuthorityPrivateKey, err := utils.DeriveEthAddressForMint(
[]byte("claimAuthority"),
app.config.LaunchpadDeterministicSecret,
mintPubKey,
)
if err != nil {
return "", fmt.Errorf("failed to derive eth claim-authority key: %w", err)
}
envelopeKey, err := common.EthToEthKey(claimAuthorityPrivateKey)
if err != nil {
return "", fmt.Errorf("failed to convert eth claim-authority key: %w", err)
}

// Convert the private key to the format expected by the SDK
privateKey, err := common.EthToEthKey(claimAuthorityPrivateKey)
if err != nil {
return "", fmt.Errorf("failed to convert private 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))

// Create OpenAudio SDK instance and set the private key
oap := sdk.NewOpenAudioSDK(app.config.AudiusdURL)
oap.SetPrivKey(privateKey)
oap := sdk.NewOpenAudioSDK(app.config.AudiusdURL)
oap.SetPrivKey(envelopeKey)

// Get current chain status to calculate deadline
statusResp, err := oap.Core.GetStatus(context.Background(), connect.NewRequest(&v1.GetStatusRequest{}))
if err != nil {
return "", fmt.Errorf("failed to get chain status: %w", err)
}
statusResp, err := oap.Core.GetStatus(ctx, connect.NewRequest(&v1.GetStatusRequest{}))
if err != nil {
return "", fmt.Errorf("failed to get chain status: %w", err)
}
deadline := statusResp.Msg.ChainInfo.CurrentHeight + rewardPoolDeadlineWindow

currentHeight := statusResp.Msg.ChainInfo.CurrentHeight
deadline := currentHeight + 100
rewardID := fmt.Sprintf("%s", code)

reward, err := oap.Rewards.CreateReward(context.Background(), &v1.CreateReward{
RewardId: rewardID,
Name: fmt.Sprintf("Launchpad Reward %s", code),
Amount: uint64(amount),
ClaimAuthorities: []*v1.ClaimAuthority{
{Address: claimAuthority, Name: "Launchpad"},
},
DeadlineBlockHeight: deadline,
})
if err != nil {
return "", fmt.Errorf("failed to create reward pool: %w", err)
// 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))
}
}

rewardAddress = reward.Address
} else {
rewardAddress = ""
reward, err := oap.Rewards.CreateReward(ctx, &v1.CreateReward{
RewardId: code,
Name: fmt.Sprintf("Launchpad Reward %s", code),
Amount: uint64(amount),
RewardsManagerPubkey: rewardsManagerPubkey,
}, deadline)
if err != nil {
return "", fmt.Errorf("failed to create reward: %w", err)
}

app.logger.Info("createRewardCode: Completed",
zap.String("code", code),
zap.String("reward_address", rewardAddress),
zap.Bool("has_reward_address", rewardAddress != ""))
return rewardAddress, nil
zap.String("reward_address", reward.Address),
zap.String("rewards_manager_pubkey", rewardsManagerPubkey))
return reward.Address, nil
}
125 changes: 69 additions & 56 deletions cmd/create_reward_codes/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"crypto/ed25519"
"encoding/csv"
"errors"
"flag"
Expand All @@ -19,9 +20,15 @@ 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"
)

// rewardPoolDeadlineWindow is the number of blocks ahead of the current
// height at which the CLI sets the deadline_block_height on cometbft tx
// envelopes (CreateRewardPool, CreateReward).
const rewardPoolDeadlineWindow = 100

const (
maxRetries = 3
initialRetryDelay = 100 * time.Millisecond
Expand Down Expand Up @@ -268,8 +275,15 @@ func processCode(ctx context.Context, logger *zap.Logger, pool *pgxpool.Pool, cf
oap := sdk.NewOpenAudioSDK(cfg.AudiusdURL)
oap.SetPrivKey(privateKey)

// Create reward pool (with retry and idempotency check)
rewardAddress, err := createRewardPool(ctx, logger, pool, oap, code, amount, claimAuthority)
// 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)
if err != nil {
return CodeResult{
Code: code,
Expand Down Expand Up @@ -303,76 +317,75 @@ func checkCodeExists(ctx context.Context, pool *pgxpool.Pool, code string) (bool
return exists, err
}

func createRewardPool(ctx context.Context, logger *zap.Logger, pool *pgxpool.Pool, oap *sdk.OpenAudioSDK, code string, amount int64, claimAuthority string) (string, error) {
// Get current chain status to calculate deadline
// ensurePoolAndCreateReward looks up (and if missing, creates) the reward
// pool for the mint, then submits the CreateReward tx and returns the
// reward address. The "reward already exists in pool" case is detected
// 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) {
var statusResp *connect.Response[v1.GetStatusResponse]
err := retryOperation(func() error {
if err := retryOperation(func() error {
var err error
statusResp, err = oap.Core.GetStatus(ctx, connect.NewRequest(&v1.GetStatusRequest{}))
return err
})
if err != nil {
}); err != nil {
return "", fmt.Errorf("failed to get chain status: %w", err)
}
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.
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
}); err != nil {
return "", fmt.Errorf("failed to ensure reward pool: %w", err)
}

currentHeight := statusResp.Msg.ChainInfo.CurrentHeight
deadline := currentHeight + 100
rewardID := code

// Try to create reward pool
var reward *v1.GetRewardResponse
err = retryOperation(func() error {
if err := retryOperation(func() error {
var err error
reward, err = oap.Rewards.CreateReward(ctx, &v1.CreateReward{
RewardId: rewardID,
Name: fmt.Sprintf("Launchpad Reward %s", code),
Amount: uint64(amount),
ClaimAuthorities: []*v1.ClaimAuthority{
{Address: claimAuthority, Name: "Launchpad"},
},
DeadlineBlockHeight: deadline,
})

// If error indicates reward already exists, return special error
if err != nil && strings.Contains(err.Error(), "already exists") {
logger.Info("Reward pool already exists", zap.String("code", code))
return &RewardExistsError{Code: code}
}

RewardId: code,
Name: fmt.Sprintf("Launchpad Reward %s", code),
Amount: uint64(amount),
RewardsManagerPubkey: rewardsManagerPubkey,
}, deadline)
return err
})

// Handle reward already exists case
if err != nil {
if existsErr, ok := err.(*RewardExistsError); ok {
// Reward pool already exists - check if we have it in the DB
// We need to pass pool to the closure, so we'll query it here
var rewardAddress string
dbErr := retryOperation(func() error {
return pool.QueryRow(ctx, "SELECT reward_address FROM reward_codes WHERE code = $1", existsErr.Code).Scan(&rewardAddress)
})
if dbErr == nil && rewardAddress != "" {
// We have it in DB, use that
return rewardAddress, nil
}
// If not in DB, we can't proceed - this shouldn't happen in normal flow
// but if it does, we'll return an error
return "", fmt.Errorf("reward pool exists but address not found in database")
}
return "", err
}); err != nil {
return "", fmt.Errorf("failed to create reward: %w", err)
}

return reward.Address, nil
}

type RewardExistsError struct {
Code string
}

func (e *RewardExistsError) Error() string {
return fmt.Sprintf("reward already exists: %s", e.Code)
}

func insertCodeIntoDB(ctx context.Context, pool *pgxpool.Pool, code, mint, rewardAddress string, amount int64, uses int) error {
return retryOperation(func() error {
// Use ON CONFLICT DO NOTHING for idempotency
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
connectrpc.com/connect v1.18.1
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/Doist/unfurlist v0.0.0-20250409100812-515f2735f8e5
github.com/OpenAudio/go-openaudio v1.2.11
github.com/OpenAudio/go-openaudio v1.2.13
github.com/aquasecurity/esquery v0.2.0
github.com/axiomhq/axiom-go v0.23.0
github.com/axiomhq/hyperloglog v0.2.5
Expand Down
Loading
Loading