Adapt rewards to the new RewardPool primitive#802
Conversation
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>
There was a problem hiding this comment.
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 forCreateRewardPoolsigning. - 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 referencingRewardsManagerPubkey. - Bump
github.com/OpenAudio/go-openaudioto the v1.2.13 pseudo-version that includes theRewardPoolAPI.
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.
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>
There was a problem hiding this comment.
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
ensurePoolAndCreateRewardis 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, theRewardExistsErrorplumbing, and the DB-based reward_address lookup were all removed in this PR. The function now relies on the earlycheckCodeExistsinprocessCodefor 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.Poolparameter passed toensurePoolAndCreateRewardis no longer used inside the function (the previous DB lookup forreward_addresson 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:ensurePoolAndCreateRewardperforms two distinct submissions (the pool ensure step and theCreateRewardcall), and any error from theCreateRewardpath 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>
Summary
Adapts the two CreateReward call sites in this repo to the new cometbft
RewardPoolprimitive shipping in go-openaudio v1.2.13 (OpenAudio/go-openaudio#254). Without this, the launchpad reward-code flow breaks at deploy: the newCreateRewardproto reserves the oldclaim_authorities/deadline_block_heightfields, and the validator now refuses anyCreateRewardwhose RM has no pool.Why
The pool primitive moves the eth addresses authorized to attest for programmatic rewards off each
core_rewardsrow and onto acore_reward_poolsrow keyed by the Solana RM pubkey. Rewards now reference a pool byrewards_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-timeCreateRewardPoolcall first.What changed
utils/derive_reward_manager_keypair.go(new)DeriveRewardManagerKeypair(secretHex, mint) ed25519.PrivateKeyreturns the deterministic ed25519 keypair the solana-relay used to init the Solana reward manager state account for a given mint. Seed material matchesapps/.../solana-relay/.../launchpad/launch_coin.ts'sderiveKeypair('reward-manager', mint):The returned private key's public key IS the
rewards_manager_pubkeycometbft carries. Used to signCreateRewardPool's envelope-level ed25519rm_owner_signature, which is the frontrunning defense — only callers who hold the launchpad's deterministic secret can produce it.api/v1_create_reward_code.goandcmd/create_reward_codes/main.goBoth rewards code paths reshaped identically:
oap.Rewards.GetRewardPool(rewardsManagerPubkey). IfCodeNotFound, calloap.Rewards.CreateRewardPool(...)with the RM private key as the newrmKeyargument. Pool authorities = the per-mint claim authority eth address (the existingDeriveEthAddressForMintoutput).CreateRewardwithRewardsManagerPubkey: rewardsManagerPubkey. The legacyClaimAuthoritiesslice and inlineDeadlineBlockHeightare gone (proto tags 4–6 reserved onCreateReward); 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
LaunchpadDeterministicSecretis unset is preserved.go.modgithub.com/OpenAudio/go-openaudio v1.2.11→v1.2.13(pseudo-version pointing at the merge commit).Test plan
go build ./...andgo vet ./...clean.go test ./utils/...— newTestDeriveRewardManagerKeypaircovers well-formedness, determinism, sign/verify round-trip, and that different mints / secrets produce different keypairs (5 subtests).go.modto the cleanv1.2.13tag once go-openaudio cuts it./v1/rewards/code, observe the first call creates both the pool and the reward, subsequent calls for the same mint create only the reward.Dependencies
🤖 Generated with Claude Code