Skip to content

Adapt rewards to the new RewardPool primitive#802

Open
rickyrombo wants to merge 3 commits into
mainfrom
mjp-rewards-pool-primitive
Open

Adapt rewards to the new RewardPool primitive#802
rickyrombo wants to merge 3 commits into
mainfrom
mjp-rewards-pool-primitive

Conversation

@rickyrombo
Copy link
Copy Markdown
Contributor

@rickyrombo rickyrombo commented May 12, 2026

Summary

Adapts the two CreateReward call sites in this repo to the new cometbft RewardPool primitive shipping in go-openaudio v1.2.13 (OpenAudio/go-openaudio#254). Without this, the launchpad reward-code flow breaks at deploy: the new CreateReward proto reserves the old claim_authorities / deadline_block_height fields, and the validator now refuses any CreateReward whose RM has no pool.

Why

The pool primitive moves the eth addresses authorized to attest for programmatic rewards off each core_rewards row and onto a core_reward_pools row keyed by the Solana RM pubkey. Rewards now reference a pool by rewards_manager_pubkey; the pool's authority set is mutable via cometbft transactions; validators consult the pool's current authorities when serving claim attestations. Rotation becomes a one-row update instead of mass row reissuance.

For us to call CreateReward, a pool keyed by the mint's RM must already exist. Brand-new mints need a one-time CreateRewardPool call first.

What changed

utils/derive_reward_manager_keypair.go (new)

DeriveRewardManagerKeypair(secretHex, mint) ed25519.PrivateKey returns the deterministic ed25519 keypair the solana-relay used to init the Solana reward manager state account for a given mint. Seed material matches apps/.../solana-relay/.../launchpad/launch_coin.ts's deriveKeypair('reward-manager', mint):

seed = sha256(secret_utf8 || "audius-launchpad" || "reward-manager" || mint_bytes)

The returned private key's public key IS the rewards_manager_pubkey cometbft carries. Used to sign CreateRewardPool's envelope-level ed25519 rm_owner_signature, which is the frontrunning defense — only callers who hold the launchpad's deterministic secret can produce it.

api/v1_create_reward_code.go and cmd/create_reward_codes/main.go

Both rewards code paths reshaped identically:

  1. Derive the RM keypair for this mint (above).
  2. Ensure the pool exists: oap.Rewards.GetRewardPool(rewardsManagerPubkey). If CodeNotFound, call oap.Rewards.CreateRewardPool(...) with the RM private key as the new rmKey argument. Pool authorities = the per-mint claim authority eth address (the existing DeriveEthAddressForMint output).
  3. Submit CreateReward with RewardsManagerPubkey: rewardsManagerPubkey. The legacy ClaimAuthorities slice and inline DeadlineBlockHeight are gone (proto tags 4–6 reserved on CreateReward); deadline is now a separate SDK argument.

The CLI's "reward already exists, look up the stored address" idempotency path is preserved. The HTTP path's no-op behavior when LaunchpadDeterministicSecret is unset is preserved.

go.mod

github.com/OpenAudio/go-openaudio v1.2.11v1.2.13 (pseudo-version pointing at the merge commit).

Test plan

  • go build ./... and go vet ./... clean.
  • go test ./utils/... — new TestDeriveRewardManagerKeypair covers well-formedness, determinism, sign/verify round-trip, and that different mints / secrets produce different keypairs (5 subtests).
  • After merge: bump go.mod to the clean v1.2.13 tag once go-openaudio cuts it.
  • Devnet smoke: launch a new coin via the launchpad-relay, POST /v1/rewards/code, observe the first call creates both the pool and the reward, subsequent calls for the same mint create only the reward.
  • Staging: run the CLI against a fresh mint; verify the same two-call pattern then the idempotent reuse path on a second run.

Dependencies

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the launchpad reward-code creation flows to work with the new cometbft RewardPool primitive in go-openaudio, including deterministic derivation of the Solana rewards manager (RM) keypair so the API/CLI can create reward pools when needed.

Changes:

  • Add DeriveRewardManagerKeypair(secretHex, mint) utility (+ tests) to deterministically derive the RM ed25519 keypair used for CreateRewardPool signing.
  • Update both HTTP (/v1/rewards/code) and CLI reward-code creation to (a) derive RM pubkey, (b) ensure the pool exists, then (c) create the reward referencing RewardsManagerPubkey.
  • Bump github.com/OpenAudio/go-openaudio to the v1.2.13 pseudo-version that includes the RewardPool API.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
utils/derive_reward_manager_keypair.go Adds deterministic RM ed25519 key derivation used to sign pool creation.
utils/derive_reward_manager_keypair_test.go Tests derivation determinism and sign/verify behavior.
api/v1_create_reward_code.go Updates API reward-code flow to ensure reward pool exists and create rewards by RewardsManagerPubkey.
cmd/create_reward_codes/main.go Updates CLI flow to ensure reward pools exist and create rewards via RewardsManagerPubkey, with retries/idempotency.
go.mod Bumps go-openaudio dependency to include RewardPool changes.
go.sum Updates dependency checksums accordingly.
Comments suppressed due to low confidence (2)

cmd/create_reward_codes/main.go:292

  • This error message says "failed to create reward pool" but the call now both ensures the pool and creates the reward. Updating the message will make CLI failures easier to diagnose.
		return CodeResult{
			Code:    code,
			Success: false,
			Error:   fmt.Sprintf("failed to create reward pool: %v", err),
		}

cmd/create_reward_codes/main.go:369

  • Similarly, detecting an existing reward by strings.Contains(err.Error(), "already exists") is fragile. If CreateReward returns a connect error (or can be made to), prefer checking connect.CodeAlreadyExists (or another stable error type/code) rather than parsing the message.
		if err != nil && strings.Contains(err.Error(), "already exists") {
			logger.Info("Reward already exists", zap.String("code", code))
			return &RewardExistsError{Code: code}
		}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread api/v1_create_reward_code.go Outdated
Comment thread cmd/create_reward_codes/main.go Outdated
Comment thread api/v1_create_reward_code.go Outdated
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 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

cmd/create_reward_codes/main.go:326

  • The doc comment for ensurePoolAndCreateReward is out of date and no longer matches the implementation. It says 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." However, the substring-based detection, the RewardExistsError plumbing, and the DB-based reward_address lookup were all removed in this PR. The function now relies on the early checkCodeExists in processCode for idempotency and surfaces any CreateReward error directly. The comment should be updated to reflect the new flow.
// 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) {

cmd/create_reward_codes/main.go:326

  • The pool *pgxpool.Pool parameter passed to ensurePoolAndCreateReward is no longer used inside the function (the previous DB lookup for reward_address on the "already exists" path was removed). Consider dropping the parameter from the signature and the call site to avoid dead arguments.
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) {

cmd/create_reward_codes/main.go:293

  • The error string "failed to create reward pool" is now misleading: ensurePoolAndCreateReward performs two distinct submissions (the pool ensure step and the CreateReward call), and any error from the CreateReward path will be surfaced here labeled as a pool-creation failure. Consider using a more accurate wording (e.g. "failed to create reward" or "failed to ensure pool and create reward") to aid debugging.

This issue also appears in the following locations of the same file:

  • line 320
  • line 326
	rewardAddress, err := ensurePoolAndCreateReward(ctx, logger, pool, oap, code, amount, claimAuthority, rewardsManagerPubkey, rmKey)
	if err != nil {
		return CodeResult{
			Code:    code,
			Success: false,
			Error:   fmt.Sprintf("failed to create reward pool: %v", err),
		}
	}

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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants