diff --git a/api/v1_create_reward_code.go b/api/v1_create_reward_code.go index c2316f93..1c5b509c 100644 --- a/api/v1_create_reward_code.go +++ b/api/v1_create_reward_code.go @@ -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 { @@ -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), @@ -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 } diff --git a/cmd/create_reward_codes/main.go b/cmd/create_reward_codes/main.go index ac781891..f525bf81 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,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 diff --git a/go.mod b/go.mod index a0cc05c4..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.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 diff --git a/go.sum b/go.sum index 13b30527..af30df33 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 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= @@ -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") + } + }) +}