From 6fecb37b46a61ef0f5434b1e073a2d1a387912df Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:31:30 -0800 Subject: [PATCH 1/4] naively fix race condition --- api/v1_prizes_claim.go | 82 ++++++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/api/v1_prizes_claim.go b/api/v1_prizes_claim.go index 0a0e0ea5..c1b8ca83 100644 --- a/api/v1_prizes_claim.go +++ b/api/v1_prizes_claim.go @@ -104,20 +104,8 @@ func (app *ApiServer) v1PrizesClaim(c *fiber.Ctx) error { return err } - ctx := c.Context() - - // Check if this signature has already been used - var alreadyUsed bool - err := app.pool.QueryRow(ctx, ` - SELECT EXISTS(SELECT 1 FROM claimed_prizes WHERE signature = $1) - `, req.Signature).Scan(&alreadyUsed) - if err != nil { - app.logger.Error("Failed to check if signature already used", zap.Error(err)) - return fiber.NewError(fiber.StatusInternalServerError, "Database error") - } - if alreadyUsed { - return fiber.NewError(fiber.StatusBadRequest, "Transaction signature already used") - } + ctx, cancel := context.WithTimeout(c.Context(), 10*time.Second) + defer cancel() // Verify the transaction in sol_token_account_balance_changes // We need to find balance changes where: @@ -131,31 +119,55 @@ func (app *ApiServer) v1PrizesClaim(c *fiber.Ctx) error { Account string } - queryErr := app.pool.QueryRow(ctx, ` - SELECT owner, change, account - FROM sol_token_account_balance_changes - WHERE signature = $1 - AND mint = $2 - AND owner = $3 - AND change = $4 - LIMIT 1 - `, req.Signature, yakMintAddress, req.Wallet, -yakClaimAmount).Scan( - &userBalanceChange.Owner, - &userBalanceChange.Change, - &userBalanceChange.Account, - ) + // Poll every 200ms until we find the balance change (to compensate for indexing delay) + for { + select { + case <-ctx.Done(): + return fiber.NewError(fiber.StatusRequestTimeout, "Request timed out while verifying transaction") + default: + } + + queryErr := app.pool.QueryRow(ctx, ` + SELECT owner, change, account + FROM sol_token_account_balance_changes + WHERE signature = $1 + AND mint = $2 + AND owner = $3 + AND change = $4 + LIMIT 1 + `, req.Signature, yakMintAddress, req.Wallet, -yakClaimAmount).Scan( + &userBalanceChange.Owner, + &userBalanceChange.Change, + &userBalanceChange.Account, + ) + + if queryErr != nil { + if errors.Is(queryErr, pgx.ErrNoRows) { + time.Sleep(200 * time.Millisecond) + continue + } + app.logger.Error("Failed to query balance changes", zap.Error(queryErr)) + return fiber.NewError(fiber.StatusInternalServerError, "Database error") + } - if queryErr != nil { - if errors.Is(queryErr, pgx.ErrNoRows) { - return fiber.NewError(fiber.StatusBadRequest, "Transaction not found or invalid. Must be exactly 2 YAK sent to the prize address.") + // Verify the wallet matches the transaction owner + if userBalanceChange.Owner != req.Wallet { + return fiber.NewError(fiber.StatusBadRequest, "Wallet does not match transaction owner") } - app.logger.Error("Failed to query balance changes", zap.Error(queryErr)) - return fiber.NewError(fiber.StatusInternalServerError, "Database error") + break } - // Verify the wallet matches the transaction owner - if userBalanceChange.Owner != req.Wallet { - return fiber.NewError(fiber.StatusBadRequest, "Wallet does not match transaction owner") + // Check if this signature has already been used + var alreadyUsed bool + err := app.pool.QueryRow(ctx, ` + SELECT EXISTS(SELECT 1 FROM claimed_prizes WHERE signature = $1) + `, req.Signature).Scan(&alreadyUsed) + if err != nil { + app.logger.Error("Failed to check if signature already used", zap.Error(err)) + return fiber.NewError(fiber.StatusInternalServerError, "Database error") + } + if alreadyUsed { + return fiber.NewError(fiber.StatusBadRequest, "Transaction signature already used") } // Verify the transaction sent tokens to the prize receiver address From 11818c4883014d39af1817d486dac97a9491e4d8 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:06:37 -0800 Subject: [PATCH 2/4] merge conflicts --- api/v1_prizes_claim.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/v1_prizes_claim.go b/api/v1_prizes_claim.go index 0825f63b..aa8fd959 100644 --- a/api/v1_prizes_claim.go +++ b/api/v1_prizes_claim.go @@ -172,6 +172,7 @@ func (app *ApiServer) v1PrizesClaim(c *fiber.Ctx) error { break } + // Verify the transaction sent tokens to the prize receiver address var receiverBalanceChange struct { Owner string Change int64 @@ -183,9 +184,11 @@ func (app *ApiServer) v1PrizesClaim(c *fiber.Ctx) error { WHERE signature = $1 AND mint = $2 AND owner = $3 + AND change = $4 LIMIT 1 `, req.Signature, yakMintAddress, prizeReceiverAddress, yakClaimAmount).Scan( &receiverBalanceChange.Owner, + &receiverBalanceChange.Change, ) if receiverQueryErr != nil { From 0d3ef0b269b4c446d3037dd93e3ccc55b01f0652 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:20:32 -0800 Subject: [PATCH 3/4] redo polling outside of main check --- api/v1_prizes_claim.go | 65 +++++++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/api/v1_prizes_claim.go b/api/v1_prizes_claim.go index aa8fd959..790786c8 100644 --- a/api/v1_prizes_claim.go +++ b/api/v1_prizes_claim.go @@ -120,6 +120,31 @@ func (app *ApiServer) v1PrizesClaim(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Transaction signature already used") } + // Wait for the transaction to appear in sol_token_account_balance_changes to ensure no race conditions + for { + select { + case <-ctx.Done(): + app.logger.Error("Context deadline exceeded while processing prize claim", + zap.String("wallet", req.Wallet), + zap.String("signature", req.Signature)) + return fiber.NewError(fiber.StatusRequestTimeout, "Request timed out") + default: + } + + var exists bool + err := app.pool.QueryRow(ctx, ` + SELECT EXISTS(SELECT 1 FROM sol_token_account_balance_changes WHERE signature = $1) + `, req.Signature).Scan(&exists) + if err != nil { + app.logger.Error("Failed to query transaction existence", zap.Error(err)) + return fiber.NewError(fiber.StatusInternalServerError, "Database error") + } + if exists { + break + } + time.Sleep(200 * time.Millisecond) // Wait before retrying + } + // Verify the transaction in sol_token_account_balance_changes // We need to find balance changes where: // 1. The signature matches @@ -132,15 +157,7 @@ func (app *ApiServer) v1PrizesClaim(c *fiber.Ctx) error { Account string } - // Poll every 200ms until we find the balance change (to compensate for indexing delay) - for { - select { - case <-ctx.Done(): - return fiber.NewError(fiber.StatusRequestTimeout, "Request timed out while verifying transaction") - default: - } - - queryErr := app.pool.QueryRow(ctx, ` + queryErr := app.pool.QueryRow(ctx, ` SELECT owner, change, account FROM sol_token_account_balance_changes WHERE signature = $1 @@ -150,26 +167,22 @@ func (app *ApiServer) v1PrizesClaim(c *fiber.Ctx) error { AND change = $4 LIMIT 1 `, req.Signature, yakMintAddress, req.Wallet, -yakClaimAmount).Scan( - &userBalanceChange.Owner, - &userBalanceChange.Change, - &userBalanceChange.Account, - ) - - if queryErr != nil { - if errors.Is(queryErr, pgx.ErrNoRows) { - time.Sleep(200 * time.Millisecond) - continue - } - app.logger.Error("Failed to query balance changes", zap.Error(queryErr)) - return fiber.NewError(fiber.StatusInternalServerError, "Database error") - } + &userBalanceChange.Owner, + &userBalanceChange.Change, + &userBalanceChange.Account, + ) - // Verify the wallet matches the transaction owner - if userBalanceChange.Owner != req.Wallet && userBalanceChange.Account != req.Wallet { - return fiber.NewError(fiber.StatusBadRequest, "Wallet does not match transaction owner") + if queryErr != nil { + if errors.Is(queryErr, pgx.ErrNoRows) { + return fiber.NewError(fiber.StatusBadRequest, "Transaction not found or invalid. Must be exactly 2 YAK sent to the prize address.") } + app.logger.Error("Failed to query balance changes", zap.Error(queryErr)) + return fiber.NewError(fiber.StatusInternalServerError, "Database error") + } - break + // Verify the wallet matches the transaction owner + if userBalanceChange.Owner != req.Wallet && userBalanceChange.Account != req.Wallet { + return fiber.NewError(fiber.StatusBadRequest, "Wallet does not match transaction owner") } // Verify the transaction sent tokens to the prize receiver address From 9d6dfc48bdd326b7aabe49ac65fadc6282f68270 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:24:25 -0800 Subject: [PATCH 4/4] change to timeout --- api/v1_prizes_claim_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v1_prizes_claim_test.go b/api/v1_prizes_claim_test.go index e209cedb..d62b4a0f 100644 --- a/api/v1_prizes_claim_test.go +++ b/api/v1_prizes_claim_test.go @@ -266,7 +266,7 @@ func TestV1PrizesClaim(t *testing.T) { "Content-Type": "application/json", }) - assert.Equal(t, 400, status, "Should return 400 for non-existent transaction. Response: %s", string(respBody)) + assert.Equal(t, 408, status, "Should return 408 for non-existent transaction. Response: %s", string(respBody)) }) t.Run("Wrong mint - transaction uses different mint", func(t *testing.T) {