From a16b83e02afb76f7d7e79003e2c7604618df8d5a Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Tue, 12 May 2026 14:15:01 -0500 Subject: [PATCH 1/3] Adapt rewards to the new RewardPool primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrades go-openaudio to the v1.2.13 line (the bundle merged in OpenAudio/go-openaudio#254 — pseudo-versioned here until the v1.2.13 tag is cut). Two call sites needed reshaping for the new CreateReward proto and the new pool primitive: - api/v1_create_reward_code.go (HTTP path: POST /v1/rewards/code) - cmd/create_reward_codes/main.go (the batch CLI) Both now: 1. Derive the launchpad mint's ed25519 RM keypair via the new utils.DeriveRewardManagerKeypair helper. The seed material matches the solana-relay's deriveKeypair('reward-manager', mint) exactly (see launch_coin.ts), so the public key equals the Solana reward manager state account this mint was inited under. The base58-encoded pubkey IS the rewards_manager_pubkey cometbft carries for the pool. 2. Check for the pool via oap.Rewards.GetRewardPool. If NotFound (only the first reward for a brand-new mint), call CreateRewardPool with the RM private key passed as the new rmKey arg. The cometbft validator verifies the envelope's ed25519 rm_owner_signature against the pool's pubkey, which proves possession of the RM keypair and prevents an observer of Solana RM init events from frontrunning pool creation with attacker-chosen authorities. Pool authorities = the per-mint claim authority eth address (the existing DeriveEthAddressForMint output), keeping rotation surface identical to today. 3. CreateReward now drops the inline ClaimAuthorities / DeadlineBlockHeight fields (proto tags 4–6 are reserved on CreateReward) and passes rewards_manager_pubkey + the deadline as separate args. The SDK envelope signer (secp256k1) stays the per-mint claim authority key — same eth identity as before. The CLI's existing "reward already exists in pool" idempotency path (check the local DB for a stored reward_address and return it) is preserved. When LaunchpadDeterministicSecret is unset (dev environments), the HTTP path is a no-op and returns "" — same behavior as before. Co-Authored-By: Claude Opus 4.7 --- api/v1_create_reward_code.go | 138 +++++++++++++------- cmd/create_reward_codes/main.go | 90 ++++++++----- go.mod | 2 +- go.sum | 20 +-- utils/derive_reward_manager_keypair.go | 40 ++++++ utils/derive_reward_manager_keypair_test.go | 59 +++++++++ 6 files changed, 245 insertions(+), 104 deletions(-) create mode 100644 utils/derive_reward_manager_keypair.go create mode 100644 utils/derive_reward_manager_keypair_test.go diff --git a/api/v1_create_reward_code.go b/api/v1_create_reward_code.go index c2316f93..c3d2b6e3 100644 --- a/api/v1_create_reward_code.go +++ b/api/v1_create_reward_code.go @@ -21,6 +21,13 @@ import ( "go.uber.org/zap" ) +// 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. +const rewardPoolDeadlineWindow = 100 + const ( signedAuthMessage = "code" codeLength = 10 @@ -212,8 +219,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), @@ -223,65 +249,77 @@ 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 { + // 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 _, err := oap.Rewards.CreateRewardPool(ctx, &v1.CreateRewardPool{ + RewardsManagerPubkey: rewardsManagerPubkey, + Authorities: []string{claimAuthority}, + }, rmKey, deadline); err != nil { return "", fmt.Errorf("failed to create reward pool: %w", err) } + } - 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 } diff --git a/cmd/create_reward_codes/main.go b/cmd/create_reward_codes/main.go index ac781891..5bf9cbe6 100644 --- a/cmd/create_reward_codes/main.go +++ b/cmd/create_reward_codes/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/ed25519" "encoding/csv" "errors" "flag" @@ -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 @@ -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, @@ -303,61 +317,67 @@ 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. + if err := retryOperation(func() error { + _, err := oap.Rewards.GetRewardPool(ctx, rewardsManagerPubkey) + if err != nil && connect.CodeOf(err) == connect.CodeNotFound { + 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 && !strings.Contains(createErr.Error(), "already exists") { + return createErr + } + return nil + } + return err + }); 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 + RewardId: code, + Name: fmt.Sprintf("Launchpad Reward %s", code), + Amount: uint64(amount), + RewardsManagerPubkey: rewardsManagerPubkey, + }, deadline) if err != nil && strings.Contains(err.Error(), "already exists") { - logger.Info("Reward pool already exists", zap.String("code", code)) + logger.Info("Reward already exists", zap.String("code", code)) return &RewardExistsError{Code: code} } - return err - }) - - // Handle reward already exists case - if err != nil { + }); 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 "", fmt.Errorf("reward already exists on chain but address not found in local DB") } return "", err } diff --git a/go.mod b/go.mod index a0cc05c4..cd29761c 100644 --- a/go.mod +++ b/go.mod @@ -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-0.20260512190210-e499df4ad4d8 github.com/aquasecurity/esquery v0.2.0 github.com/axiomhq/axiom-go v0.23.0 github.com/axiomhq/hyperloglog v0.2.5 diff --git a/go.sum b/go.sum index 13b30527..117529da 100644 --- a/go.sum +++ b/go.sum @@ -20,12 +20,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= -github.com/OpenAudio/go-openaudio v1.2.0 h1:jnvc7nWpPEZlDTABVrp9uLuLh0BcchwpHkg8nTQ/Zqs= -github.com/OpenAudio/go-openaudio v1.2.0/go.mod h1:tI/qfjYymj8TmVWEMl4WnciLxB//I2eZZyIkuUUCSnM= -github.com/OpenAudio/go-openaudio v1.2.9 h1:dhUTfzNAq4jVhSRmu1XDpVs8wMen3rPlSuVW1qqwZdo= -github.com/OpenAudio/go-openaudio v1.2.9/go.mod h1:+xl3SeIY7pc6CfwO1qmYjWELLSseQaulqSQefE6i2FA= -github.com/OpenAudio/go-openaudio v1.2.11 h1:s30csv/5g2bi8j4RcI6op04AmazX3qFuOw1o+FAq7UE= -github.com/OpenAudio/go-openaudio v1.2.11/go.mod h1:+xl3SeIY7pc6CfwO1qmYjWELLSseQaulqSQefE6i2FA= +github.com/OpenAudio/go-openaudio v1.2.13-0.20260512190210-e499df4ad4d8 h1:3CSruoJu4vumWtBn8jSiEabMt7E74zdPm+DtKSrXAk0= +github.com/OpenAudio/go-openaudio v1.2.13-0.20260512190210-e499df4ad4d8/go.mod h1:+xl3SeIY7pc6CfwO1qmYjWELLSseQaulqSQefE6i2FA= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI= @@ -206,8 +202,6 @@ github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBj github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -636,30 +630,22 @@ go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= @@ -812,8 +798,6 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/utils/derive_reward_manager_keypair.go b/utils/derive_reward_manager_keypair.go new file mode 100644 index 00000000..ff1b3629 --- /dev/null +++ b/utils/derive_reward_manager_keypair.go @@ -0,0 +1,40 @@ +package utils + +import ( + "crypto/ed25519" + "crypto/sha256" + + "github.com/gagliardetto/solana-go" +) + +// DeriveRewardManagerKeypair deterministically derives the ed25519 keypair +// for the Solana reward manager state account associated with a launchpad +// mint. The result matches the keypair produced by the solana-relay's +// `deriveKeypair('reward-manager', mint)` helper (see +// apps/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/ +// src/routes/launchpad/launch_coin.ts), so the public key equals the +// rewards_manager_pubkey that cometbft carries for that mint's pool. +// +// Seed material: +// +// sha256(secret_utf8 || "audius-launchpad" || "reward-manager" || mint_bytes) +// +// where secret_utf8 is the UTF-8 bytes of the launchpad's hex-encoded +// secret STRING (NOT the decoded hex bytes — matches the TS +// `Buffer.from(secret, 'utf8')`). +// +// The returned private key is what callers feed to +// `oap.Rewards.CreateRewardPool` as the `rmKey` argument: the cometbft +// validator verifies the envelope's rm_owner_signature against the +// matching public key, which prevents an observer of Solana RM init +// events from frontrunning pool creation with attacker-chosen +// authorities. +func DeriveRewardManagerKeypair(secretHex string, mint solana.PublicKey) ed25519.PrivateKey { + var buf []byte + buf = append(buf, []byte(secretHex)...) + buf = append(buf, []byte("audius-launchpad")...) + buf = append(buf, []byte("reward-manager")...) + buf = append(buf, mint.Bytes()...) + seed := sha256.Sum256(buf) + return ed25519.NewKeyFromSeed(seed[:]) +} diff --git a/utils/derive_reward_manager_keypair_test.go b/utils/derive_reward_manager_keypair_test.go new file mode 100644 index 00000000..ddea98bb --- /dev/null +++ b/utils/derive_reward_manager_keypair_test.go @@ -0,0 +1,59 @@ +package utils + +import ( + "crypto/ed25519" + "testing" + + "github.com/gagliardetto/solana-go" +) + +func TestDeriveRewardManagerKeypair(t *testing.T) { + mint := solana.MustPublicKeyFromBase58("4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R") + secret := "0000000000000000000000000000000000000000000000000000000000000001" + + priv := DeriveRewardManagerKeypair(secret, mint) + + t.Run("returns a well-formed ed25519 private key", func(t *testing.T) { + if len(priv) != ed25519.PrivateKeySize { + t.Fatalf("private key length = %d, want %d", len(priv), ed25519.PrivateKeySize) + } + pub, ok := priv.Public().(ed25519.PublicKey) + if !ok { + t.Fatalf("priv.Public() did not return ed25519.PublicKey") + } + if len(pub) != ed25519.PublicKeySize { + t.Fatalf("public key length = %d, want %d", len(pub), ed25519.PublicKeySize) + } + }) + + t.Run("derivation is deterministic", func(t *testing.T) { + again := DeriveRewardManagerKeypair(secret, mint) + if string(again) != string(priv) { + t.Fatalf("re-derivation produced a different private key") + } + }) + + t.Run("signs and verifies", func(t *testing.T) { + msg := []byte("smoke test") + sig := ed25519.Sign(priv, msg) + if !ed25519.Verify(priv.Public().(ed25519.PublicKey), msg, sig) { + t.Fatalf("signature did not verify against the derived public key") + } + }) + + t.Run("different mints produce different keypairs", func(t *testing.T) { + otherMint := solana.MustPublicKeyFromBase58("So11111111111111111111111111111111111111112") + other := DeriveRewardManagerKeypair(secret, otherMint) + if string(other) == string(priv) { + t.Fatalf("different mints should yield different RM keypairs") + } + }) + + t.Run("different secrets produce different keypairs", func(t *testing.T) { + otherSecret := "0000000000000000000000000000000000000000000000000000000000000002" + other := DeriveRewardManagerKeypair(otherSecret, mint) + if string(other) == string(priv) { + t.Fatalf("different secrets should yield different RM keypairs") + } + }) +} From 89b129a3cefd64845df1c8f9ecaf05903c80c6be Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Tue, 12 May 2026 15:05:01 -0500 Subject: [PATCH 2/3] Address PR #802 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three items: 1. Pool-creation race (Copilot, api/v1_create_reward_code.go:306). GetRewardPool + CreateRewardPool is not concurrency-safe under the previous "CreateRewardPool error == fatal" handling: two first-reward requests for the same brand-new mint can both observe NotFound and both submit CreateRewardPool; the second's tx fails. Now on CreateRewardPool failure we re-fetch the pool — if it now exists we lost the race cleanly and continue. Any other error stays fatal. 2. Brittle "already exists" substring match in the CLI (Copilot, cmd/create_reward_codes/main.go:348 + :366). For CreateRewardPool: same race-resolution as above — verify the pool via GetRewardPool after a failed CreateRewardPool, treat "now exists" as success, anything else as the original error. For CreateReward: dropped the substring match and the RewardExistsError plumbing entirely. After the move to first- class pools, CreateReward never returns an "already exists" error from cometbft (each tx's reward.address is derived from txhash + messageIndex, always unique). The check was dead code. Local-DB checkCodeExists at the top of processCode remains the primary idempotency guarantee, which is what we already depend on for skipping reruns. 3. Const grouping (raymondjacobson). Moved rewardPoolDeadlineWindow into the existing const block in v1_create_reward_code.go. Co-Authored-By: Claude Opus 4.7 --- api/v1_create_reward_code.go | 31 ++++++++++++------ cmd/create_reward_codes/main.go | 57 +++++++++++++++------------------ 2 files changed, 46 insertions(+), 42 deletions(-) diff --git a/api/v1_create_reward_code.go b/api/v1_create_reward_code.go index c3d2b6e3..1c5b509c 100644 --- a/api/v1_create_reward_code.go +++ b/api/v1_create_reward_code.go @@ -21,17 +21,18 @@ import ( "go.uber.org/zap" ) -// 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. -const rewardPoolDeadlineWindow = 100 - 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 { @@ -299,11 +300,21 @@ func (app *ApiServer) createRewardCode(ctx context.Context, code, mint string, a zap.String("mint", mint), zap.String("rewards_manager_pubkey", rewardsManagerPubkey), zap.String("claim_authority", claimAuthority)) - if _, err := oap.Rewards.CreateRewardPool(ctx, &v1.CreateRewardPool{ + if _, createErr := oap.Rewards.CreateRewardPool(ctx, &v1.CreateRewardPool{ RewardsManagerPubkey: rewardsManagerPubkey, Authorities: []string{claimAuthority}, - }, rmKey, deadline); err != nil { - return "", fmt.Errorf("failed to create reward pool: %w", err) + }, 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)) } } diff --git a/cmd/create_reward_codes/main.go b/cmd/create_reward_codes/main.go index 5bf9cbe6..f525bf81 100644 --- a/cmd/create_reward_codes/main.go +++ b/cmd/create_reward_codes/main.go @@ -336,20 +336,35 @@ func ensurePoolAndCreateReward(ctx context.Context, logger *zap.Logger, pool *pg // 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. + // 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 && connect.CodeOf(err) == connect.CodeNotFound { - 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 && !strings.Contains(createErr.Error(), "already exists") { + 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 } - return nil + logger.Info("Lost CreateRewardPool race; pool now exists", + zap.String("rewards_manager_pubkey", rewardsManagerPubkey)) } - return err + return nil }); err != nil { return "", fmt.Errorf("failed to ensure reward pool: %w", err) } @@ -363,36 +378,14 @@ func ensurePoolAndCreateReward(ctx context.Context, logger *zap.Logger, pool *pg Amount: uint64(amount), RewardsManagerPubkey: rewardsManagerPubkey, }, deadline) - if err != nil && strings.Contains(err.Error(), "already exists") { - logger.Info("Reward already exists", zap.String("code", code)) - return &RewardExistsError{Code: code} - } return err }); err != nil { - if existsErr, ok := err.(*RewardExistsError); ok { - 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 != "" { - return rewardAddress, nil - } - return "", fmt.Errorf("reward already exists on chain but address not found in local DB") - } - return "", err + 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 From 0b5e934b38edadb7f86b4a2bdcd719d2b2acb6f4 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Thu, 14 May 2026 11:24:06 -0500 Subject: [PATCH 3/3] Bump go-openaudio pseudo-version to the v1.2.13 tag OpenAudio/go-openaudio cut v1.2.13 (shipping the RewardPool primitive merged in OpenAudio/go-openaudio#254). Replace the pseudo-version we'd pinned to the merge commit with the clean tag. Co-Authored-By: Claude Opus 4.7 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index cd29761c..dc22765a 100644 --- a/go.mod +++ b/go.mod @@ -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.13-0.20260512190210-e499df4ad4d8 + 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 diff --git a/go.sum b/go.sum index 117529da..af30df33 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= -github.com/OpenAudio/go-openaudio v1.2.13-0.20260512190210-e499df4ad4d8 h1:3CSruoJu4vumWtBn8jSiEabMt7E74zdPm+DtKSrXAk0= -github.com/OpenAudio/go-openaudio v1.2.13-0.20260512190210-e499df4ad4d8/go.mod h1:+xl3SeIY7pc6CfwO1qmYjWELLSseQaulqSQefE6i2FA= +github.com/OpenAudio/go-openaudio v1.2.13 h1:ILPaM6EneDQMoKXSyjb///758I7Ou52e76NvNmCkcdY= +github.com/OpenAudio/go-openaudio v1.2.13/go.mod h1:+xl3SeIY7pc6CfwO1qmYjWELLSseQaulqSQefE6i2FA= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI=