diff --git a/api/server.go b/api/server.go index b7bf354d..f9d32db8 100644 --- a/api/server.go +++ b/api/server.go @@ -512,6 +512,8 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/coins/:mint/members/count", app.v1CoinMembersCount) g.Get("/coins/:mint/redeem", app.v1CoinsRedeem) g.Get("/coins/:mint/redeem/:code", app.v1CoinsRedeemCode) + g.Post("/coins/:mint/redeem", app.requireAuthMiddleware, app.v1CoinsPostRedeem) + g.Post("/coins/:mint/redeem/:code", app.requireAuthMiddleware, app.v1CoinsPostRedeem) g.Post("/coins", app.v1CreateCoin) g.Post("/coins/:mint", app.v1UpdateCoin) diff --git a/api/swagger/swagger-v1-full.yaml b/api/swagger/swagger-v1-full.yaml index 3719d345..0aae3bd3 100644 --- a/api/swagger/swagger-v1-full.yaml +++ b/api/swagger/swagger-v1-full.yaml @@ -361,6 +361,41 @@ paths: application/json: schema: $ref: '#/components/schemas/redeem_amount_response' + post: + tags: + - coins + operationId: Claim Coin Reward + description: 'Claims a coin reward for a given mint' + parameters: + - name: mint + in: path + description: The mint address of the coin + required: true + schema: + type: string + example: 9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM + - name: user_id + in: query + description: The user ID of the user making the request + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/claim_rewards_response' + '400': + description: Bad request - No coin reward available or already claimed + content: + application/json: + schema: + $ref: '#/components/schemas/reward_code_error_response' + '500': + description: Server error + content: {} /coins/{mint}/redeem/{code}: get: tags: @@ -395,6 +430,48 @@ paths: application/json: schema: $ref: '#/components/schemas/reward_code_error_response' + post: + tags: + - coins + operationId: Claim Coin Reward Code + description: 'Claims a coin reward using a given code' + parameters: + - name: mint + in: path + description: The mint address of the coin + required: true + schema: + type: string + example: 9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM + - name: code + in: path + description: The reward code to claim + required: true + schema: + type: string + example: XYZ123 + - name: user_id + in: query + description: The user ID of the user making the request + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/claim_rewards_response' + '400': + description: Bad request - Code is invalid or already used + content: + application/json: + schema: + $ref: '#/components/schemas/reward_code_error_response' + '500': + description: Server error + content: {} /rewards/code: post: tags: @@ -11216,6 +11293,41 @@ components: type: array items: $ref: '#/components/schemas/comment' + claim_rewards_response: + type: object + required: + - data + properties: + data: + type: array + items: + type: object + required: + - challengeId + - specifier + properties: + challengeId: + type: string + description: The challenge ID + example: "u" + specifier: + type: string + description: The challenge specifier + example: "7eP5n" + amount: + type: string + description: The reward amount + example: "1000000000" + signatures: + type: array + items: + type: string + description: Transaction signatures + example: ["5j7s1QjmRKFuDbCWMRVRNibSV2VAAEcNKP6HWU7GwPdXkBZvhz8n4vQl7bBq8tN4Rz9x1Kj3mP5wQ8rT2Y6zA"] + error: + type: string + description: Error message if claim failed + example: "Insufficient balance" comment_replies_response: required: - latest_chain_block diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 2f3bf912..21684795 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -3942,6 +3942,41 @@ paths: application/json: schema: $ref: '#/components/schemas/redeem_amount_response' + post: + tags: + - coins + operationId: Claim Coin Reward + description: 'Claims a coin reward for a given mint' + parameters: + - name: mint + in: path + description: The mint address of the coin + required: true + schema: + type: string + example: 9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM + - name: user_id + in: query + description: The user ID of the user making the request + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/claim_rewards_response' + '400': + description: Bad request - No coin reward available or already claimed + content: + application/json: + schema: + $ref: '#/components/schemas/reward_code_error_response' + '500': + description: Server error + content: {} /coins/{mint}/redeem/{code}: get: tags: @@ -3976,6 +4011,48 @@ paths: application/json: schema: $ref: '#/components/schemas/reward_code_error_response' + post: + tags: + - coins + operationId: Claim Coin Reward Code + description: 'Claims a coin reward using a given code' + parameters: + - name: mint + in: path + description: The mint address of the coin + required: true + schema: + type: string + example: 9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM + - name: code + in: path + description: The reward code to claim + required: true + schema: + type: string + example: XYZ123 + - name: user_id + in: query + description: The user ID of the user making the request + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/claim_rewards_response' + '400': + description: Bad request - Code is invalid or already used + content: + application/json: + schema: + $ref: '#/components/schemas/reward_code_error_response' + '500': + description: Server error + content: {} /rewards/claim: post: tags: diff --git a/api/v1_claim_rewards.go b/api/v1_claim_rewards.go index 60907bf5..f9fbf66b 100644 --- a/api/v1_claim_rewards.go +++ b/api/v1_claim_rewards.go @@ -217,7 +217,6 @@ func fetchAttestations( hasAntiAbuseOracleAttestation bool, minVotes int, ) ([]SenderAttestation, error) { - // Shuffle the validators shuffled := slices.Clone(allValidators) rand.Shuffle(len(shuffled), func(i, j int) { @@ -355,6 +354,7 @@ func sendRewardClaimTransactions( transactionSender *spl.TransactionSender, rewardClaim RewardClaim, attestations []SenderAttestation, + sourceTokenAccount *solana.PublicKey, ) ([]solana.Signature, error) { // Transaction to send attestations in a separate transaction partialTx := solana.NewTransactionBuilder() @@ -445,14 +445,17 @@ func sendRewardClaimTransactions( if err != nil { return nil, err } + if sourceTokenAccount == nil { + sourceTokenAccount = &state.TokenAccount + } evaluateAttestationInstruction, err := reward_manager.NewEvaluateAttestationInstruction( rewardClaim.RewardID, rewardClaim.Specifier, common.HexToAddress(rewardClaim.RecipientEthAddress), - rewardClaim.Amount*1e8, // Convert to wAUDIO wei + rewardClaim.Amount*rewardClaim.TokenDecimals, // Convert to token wei common.HexToAddress(rewardClaim.ClaimAuthority), rewardManagerClient.GetProgramStateAccount(), - state.TokenAccount, + *sourceTokenAccount, rewardClaim.UserBank, feePayer.PublicKey(), ) @@ -552,6 +555,7 @@ func claimReward( transactionSender, rewardClaim, attestations, + nil, // No ATA needed for AUDIO rewards ) if err != nil { return nil, err @@ -613,8 +617,9 @@ func getReward(rewardId string, rewardsList []rewards.Reward) (rewards.Reward, e type RewardClaim struct { rewards.RewardClaim - Handle string - UserBank solana.PublicKey + Handle string + UserBank solana.PublicKey + TokenDecimals uint64 } type ClaimResult struct { @@ -711,8 +716,9 @@ func (app *ApiServer) v1ClaimRewards(c *fiber.Ctx) error { RecipientEthAddress: row.Wallet.String, ClaimAuthority: antiAbuseOracle.DelegateOwnerWallet, }, - Handle: row.Handle.String, - UserBank: *bankAccount, + Handle: row.Handle.String, + UserBank: *bankAccount, + TokenDecimals: 8, // wAUDIO wei } validators := app.validators.GetNodes() diff --git a/api/v1_coins_post_redeem.go b/api/v1_coins_post_redeem.go new file mode 100644 index 00000000..edb56621 --- /dev/null +++ b/api/v1_coins_post_redeem.go @@ -0,0 +1,338 @@ +package api + +import ( + "encoding/hex" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "api.audius.co/config" + "api.audius.co/solana/spl" + "api.audius.co/solana/spl/programs/reward_manager" + "api.audius.co/utils" + v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + oap_common "github.com/OpenAudio/go-openaudio/pkg/common" + oap_rewards "github.com/OpenAudio/go-openaudio/pkg/rewards" + "github.com/OpenAudio/go-openaudio/pkg/sdk" + "github.com/ethereum/go-ethereum/common" + "github.com/gagliardetto/solana-go" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" + "go.uber.org/zap" +) + +type CoinRewardParams struct { + Mint string + Amount uint64 +} + +func (app *ApiServer) v1CoinsPostRedeem(c *fiber.Ctx) error { + // #region Validate Params + if config.Cfg.LaunchpadDeterministicSecret == "" { + return fiber.NewError(fiber.StatusInternalServerError, "Claim authority base is not configured") + } + + mintString := c.Params("mint") + if mintString == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "mint is required", + }) + } + + // Read optional code from route params + redeemCode := c.Params("code") + + myId := app.getMyId(c) + if myId == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "user_id is required", + }) + } + // #endregion Validate Params + // #region Init State + + mint, err := solana.PublicKeyFromBase58(mintString) + if err != nil { + return fmt.Errorf("Mint is invalid: %s", mintString) + } + + // Derive claim authority key for mint + claimAuthorityPublicKey, claimAuthorityPrivKeyString, err := utils.DeriveEthAddressForMint( + []byte("claimAuthority"), + config.Cfg.LaunchpadDeterministicSecret, + mint, + ) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to derive Ethereum key: "+err.Error()) + } + + // Convert the private key to the format expected by the SDK + claimAuthorityKey, err := oap_common.EthToEthKey(claimAuthorityPrivKeyString) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert private key: "+err.Error()) + } + + if err != nil { + return fmt.Errorf("failed to get claim authority: %w", err) + } + + // Lookup user + var userWalletAddress string + var userHandle string + err = app.pool.QueryRow(c.Context(), `SELECT wallet, handle FROM users WHERE user_id = @user_id`, pgx.NamedArgs{ + "user_id": myId, + }).Scan(&userWalletAddress, &userHandle) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get user wallet") + } + return err + } + var coinTicker string + var coinDecimals uint32 + // Lookup coin info for given mint + err = app.pool.QueryRow(c.Context(), `SELECT ticker, decimals FROM artist_coins WHERE mint = @mint`, pgx.NamedArgs{ + "mint": mintString, + }).Scan(&coinTicker, &coinDecimals) + if err != nil { + return fmt.Errorf("failed to get coin info: %w", err) + } + + // Get reward manager state for the given mint + // and init a new client for it + var rewardStateAddress string + var minVotes int + var tokenSourceAddress string + err = app.pool.QueryRow(c.Context(), `SELECT reward_manager_state, token_source, min_votes FROM sol_reward_manager_inits WHERE mint = @mint`, pgx.NamedArgs{ + "mint": mintString, + }).Scan(&rewardStateAddress, &tokenSourceAddress, &minVotes) + if err != nil { + return fmt.Errorf("failed to get reward manager state: %w", err) + } + rewardManagerPubkey, err := solana.PublicKeyFromBase58(rewardStateAddress) + if err != nil { + return fmt.Errorf("failed to get init pubkey for reward manager state address: %w", err) + } + tokenSourcePubkey, err := solana.PublicKeyFromBase58(tokenSourceAddress) + if err != nil { + return fmt.Errorf("failed to init pubkey for token source address: %w", err) + } + + // Ensure ProgramID is set correctly for PDA derivation + reward_manager.SetProgramID(app.solanaConfig.RewardManagerProgramID) + + rewardManagerClient, err := reward_manager.NewRewardManagerClient( + app.solanaRpcClient, + app.solanaConfig.RewardManagerProgramID, + rewardManagerPubkey, + app.solanaConfig.RewardManagerLookupTable, + app.logger, + ) + + if err != nil { + return fmt.Errorf("failed to create reward manager client: %w", err) + } + + // Ensure user bank exists + bankAccount, err := app.claimableTokensClient.GetOrCreateUserBank( + c.Context(), + common.HexToAddress(userWalletAddress), + mint, + ) + if err != nil { + return fmt.Errorf("failed to get or create user bank: %w", err) + } + + // #endregion Init State/Userbank + // #region Burn Code + amount := uint64(0) + rewardAddress := "" + // Use can claim code once + specifier := strconv.Itoa(int(myId)) + + // If no code, default to coin ticker for generic redemption + if redeemCode == "" { + redeemCode = coinTicker + // Check for challenge disbursement for the given code/userId + var count int + err := app.writePool.QueryRow(c.Context(), `SELECT count(*) FROM challenge_disbursements WHERE challenge_id = @code AND specifier = @specifier LIMIT 1;`, pgx.NamedArgs{ + "code": redeemCode, + "specifier": specifier, + }).Scan(&count) + if err != nil { + return err + } + if count > 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "used", + }) + } + } + + // Read and burn code in one go + // Use CTE to capture old remaining_uses value before updating + sql := `WITH old_row AS ( + SELECT remaining_uses, reward_address, amount + FROM reward_codes + WHERE code = @code + AND mint = @mint + ) + UPDATE reward_codes + SET remaining_uses = GREATEST(reward_codes.remaining_uses - 1, 0) + FROM old_row + WHERE reward_codes.code = @code + AND reward_codes.mint = @mint + RETURNING + reward_codes.reward_address, + reward_codes.amount, + (old_row.remaining_uses <= 0) AS was_already_used` + + rows, err := app.writePool.Query(c.Context(), sql, pgx.NamedArgs{ + "code": redeemCode, + "mint": mintString, + }) + if err != nil { + return err + } + + type RewardCodeBurnResultRow struct { + RewardAddress string `db:"reward_address"` + Amount uint64 `db:"amount"` + WasAlreadyUsed bool `db:"was_already_used"` + } + rewardCode, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[RewardCodeBurnResultRow]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "invalid", + }) + } + return err + } + + if rewardCode.WasAlreadyUsed { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "used", + }) + } + + amount = rewardCode.Amount + rewardAddress = rewardCode.RewardAddress + + // #endregion Burn Code + // #region Build Claim + rewardClaim := oap_rewards.RewardClaim{ + RecipientEthAddress: userWalletAddress, + Amount: amount, + RewardID: redeemCode, + RewardAddress: rewardAddress, + Specifier: specifier, + ClaimAuthority: claimAuthorityPublicKey, + } + + result := ClaimResult{ + ChallengeID: "code", + Specifier: specifier, + Amount: amount, + Signatures: []solana.Signature{}, + Error: "", + } + + decoratedRewardClaim := RewardClaim{ + RewardClaim: rewardClaim, + Handle: userHandle, + UserBank: *bankAccount, + TokenDecimals: uint64(coinDecimals), + } + + claimMessage, err := rewardClaim.Compile() + + attestations := make([]SenderAttestation, 0) + // TODO: Add retries to this loop, maybe the whole thing, but def each attestation attempt + for _, validator := range app.validators.GetNodes() { + if len(attestations) >= minVotes { + break + } + oap := sdk.NewOpenAudioSDK(validator.Endpoint) + oap.SetPrivKey(claimAuthorityKey) + + response, err := oap.Rewards.GetRewardAttestation(c.Context(), &v1.GetRewardAttestationRequest{ + EthRecipientAddress: userWalletAddress, + Amount: amount, + RewardAddress: rewardAddress, + RewardId: redeemCode, + Specifier: specifier, + AmountDecimals: coinDecimals, + ClaimAuthority: oap.Address(), + }) + + if err != nil { + continue + } + + // Pad the start if there's a missing leading zero + signature := response.Attestation + if len(signature)%2 == 1 { + signature = "0" + signature + } + signatureBytes, err := hex.DecodeString(strings.TrimPrefix(signature, "0x")) + if err != nil { + continue + } + + attestation := SenderAttestation{ + EthAddress: common.HexToAddress(response.Owner), + Message: claimMessage, + Signature: signatureBytes, + } + attestations = append(attestations, attestation) + } + + if len(attestations) < minVotes { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "failed to get enough attestations", + }) + } + + // #endregion Build Claim + // #region Send Tx + // Build and send solana transactions + signatures, err := sendRewardClaimTransactions( + c.Context(), + rewardManagerClient, + app.transactionSender, + decoratedRewardClaim, + attestations, + &tokenSourcePubkey, + ) + + if err != nil { + var instrErr *spl.InstructionError + if errors.As(err, &instrErr) { + app.logger.Error("failed to claim challenge reward. transaction failed to send.", + zap.String("handle", userHandle), + zap.String("rewardId", "code"), + zap.String("specifier", specifier), + zap.String("transaction", instrErr.EncodedTransaction), + zap.String("customError", reward_manager.RewardManagerError(instrErr.Code).String()), + zap.Error(err), + ) + } else { + app.logger.Error("failed to claim challenge reward.", + zap.String("handle", userHandle), + zap.String("rewardId", "code"), + zap.String("specifier", specifier), + zap.Error(err), + ) + } + result.Error = err.Error() + } + + result.Signatures = signatures + + return c.Status(http.StatusOK).JSON(fiber.Map{ + "data": []ClaimResult{result}, + }) +} diff --git a/go.mod b/go.mod index 013835ba..a91ef396 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.3 require ( connectrpc.com/connect v1.18.1 github.com/Doist/unfurlist v0.0.0-20250409100812-515f2735f8e5 - github.com/OpenAudio/go-openaudio v1.0.3 + github.com/OpenAudio/go-openaudio v1.0.4 github.com/aquasecurity/esquery v0.2.0 github.com/axiomhq/axiom-go v0.23.0 github.com/axiomhq/hyperloglog v0.2.5 @@ -41,6 +41,7 @@ require ( github.com/tidwall/sjson v1.2.5 github.com/urfave/cli/v3 v3.5.0 go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.41.0 golang.org/x/sync v0.16.0 google.golang.org/grpc v1.71.1 google.golang.org/protobuf v1.36.6 @@ -220,7 +221,6 @@ require ( go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/ratelimit v0.2.0 // indirect - golang.org/x/crypto v0.41.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/sys v0.35.0 // indirect diff --git a/go.sum b/go.sum index b92e2b74..4d84c2a1 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 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.0.3 h1:Jb9fd3PaulDUnQLRJftPFsFscoEfnerRb4ZGIKEOi9k= -github.com/OpenAudio/go-openaudio v1.0.3/go.mod h1:xmx0/cpToJenH1N+CrFgF3aApI1dJBiwoShvlwSbpHE= +github.com/OpenAudio/go-openaudio v1.0.4 h1:A8ZnVVmyDrCZU7FL87+ROqnuhZwpz4exxAoEsIzOIOk= +github.com/OpenAudio/go-openaudio v1.0.4/go.mod h1:xmx0/cpToJenH1N+CrFgF3aApI1dJBiwoShvlwSbpHE= 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=