diff --git a/api/dbv1/get_undisbursed_challenges.sql.go b/api/dbv1/get_undisbursed_challenges.sql.go index ae9e1653..931d4475 100644 --- a/api/dbv1/get_undisbursed_challenges.sql.go +++ b/api/dbv1/get_undisbursed_challenges.sql.go @@ -20,7 +20,7 @@ SELECT user_challenges.amount FROM user_challenges JOIN users ON users.user_id = user_challenges.user_id -LEFT JOIN v_challenge_disbursements AS challenge_disbursements +LEFT JOIN sol_reward_disbursements AS challenge_disbursements ON challenge_disbursements.challenge_id = user_challenges.challenge_id AND challenge_disbursements.specifier = user_challenges.specifier WHERE @@ -45,6 +45,8 @@ type GetUndisbursedChallengesRow struct { Amount int32 `json:"amount"` } +// Anti-join the raw table by (challenge_id, specifier); the v_challenge_disbursements +// view drops disbursements whose recipient wallet doesn't resolve to a user. func (q *Queries) GetUndisbursedChallenges(ctx context.Context, arg GetUndisbursedChallengesParams) ([]GetUndisbursedChallengesRow, error) { rows, err := q.db.Query(ctx, getUndisbursedChallenges, arg.UserID, arg.ChallengeID, arg.Specifier) if err != nil { diff --git a/api/dbv1/queries/get_undisbursed_challenges.sql b/api/dbv1/queries/get_undisbursed_challenges.sql index d638c0ac..5256b699 100644 --- a/api/dbv1/queries/get_undisbursed_challenges.sql +++ b/api/dbv1/queries/get_undisbursed_challenges.sql @@ -7,7 +7,9 @@ SELECT user_challenges.amount FROM user_challenges JOIN users ON users.user_id = user_challenges.user_id -LEFT JOIN v_challenge_disbursements AS challenge_disbursements +-- Anti-join the raw table by (challenge_id, specifier); the v_challenge_disbursements +-- view drops disbursements whose recipient wallet doesn't resolve to a user. +LEFT JOIN sol_reward_disbursements AS challenge_disbursements ON challenge_disbursements.challenge_id = user_challenges.challenge_id AND challenge_disbursements.specifier = user_challenges.specifier WHERE diff --git a/api/v1_challenges_disbursements.go b/api/v1_challenges_disbursements.go index 8214bfbd..244a47f2 100644 --- a/api/v1_challenges_disbursements.go +++ b/api/v1_challenges_disbursements.go @@ -51,7 +51,7 @@ func (app *ApiServer) v1ChallengesDisbursements(c *fiber.Ctx) error { case "specifier": sortMethod = "cd.specifier" case "user_id": - sortMethod = "cd.user_id" + sortMethod = "u.user_id" } whereClause := "" @@ -63,25 +63,31 @@ func (app *ApiServer) v1ChallengesDisbursements(c *fiber.Ctx) error { filters = append(filters, "cd.specifier ILIKE '%' || @specifierQuery || '%'") } if params.ChallengeUserID != 0 { - filters = append(filters, "cd.user_id = @challengeUserId") + filters = append(filters, "u.user_id = @challengeUserId") } if len(filters) > 0 { whereClause = "WHERE " + strings.Join(filters, " AND ") } + // Inlines the former v_challenge_disbursements view to resolve recipient user_id. + // recipient_eth_address is stored lowercase but users.wallet is checksummed, so + // join on LOWER(users.wallet) (see migration 0204). sql := ` SELECT cd.challenge_id, - cd.user_id, + u.user_id, cd.specifier, - cd.amount, + cd.amount::text AS amount, cd.created_at, cd.signature, cd.slot - FROM v_challenge_disbursements cd + FROM sol_reward_disbursements cd + JOIN users u + ON LOWER(u.wallet) = cd.recipient_eth_address + AND u.is_current = true ` + whereClause + ` - ORDER BY ` + sortMethod + ` ` + sortDir + `, cd.user_id ASC + ORDER BY ` + sortMethod + ` ` + sortDir + `, u.user_id ASC LIMIT @limit OFFSET @offset; ` diff --git a/api/v1_challenges_info.go b/api/v1_challenges_info.go index ecd52e46..4b78a194 100644 --- a/api/v1_challenges_info.go +++ b/api/v1_challenges_info.go @@ -59,8 +59,10 @@ func (app *ApiServer) v1ChallengesInfo(c *fiber.Ctx) error { CASE WHEN c.weekly_pool IS NULL THEN NULL ELSE c.weekly_pool - COALESCE( - (SELECT SUM(cd.amount::bigint) / 100000000 - FROM v_challenge_disbursements cd + -- Sum from the raw table, not the view, which would drop + -- unresolved-recipient disbursements and overstate the remaining pool. + (SELECT SUM(cd.amount) / 100000000 + FROM sol_reward_disbursements cd WHERE cd.challenge_id = c.id AND cd.created_at > @weeklyPoolWindowStart), 0 diff --git a/api/v1_challenges_undisbursed.go b/api/v1_challenges_undisbursed.go index 731d1da4..325f47f8 100644 --- a/api/v1_challenges_undisbursed.go +++ b/api/v1_challenges_undisbursed.go @@ -62,7 +62,10 @@ func (app *ApiServer) v1ChallengesUndisbursed(c *fiber.Ctx) error { FROM user_challenges JOIN challenges ON challenges.id = user_challenges.challenge_id JOIN users ON users.user_id = user_challenges.user_id - LEFT JOIN v_challenge_disbursements AS challenge_disbursements ON + -- Anti-join raw sol_reward_disbursements by (challenge_id, specifier), not the + -- v_challenge_disbursements view: a paid specifier must never be offered for claim, + -- and the view drops disbursements whose recipient wallet doesn't resolve to a user. + LEFT JOIN sol_reward_disbursements AS challenge_disbursements ON challenge_disbursements.challenge_id = user_challenges.challenge_id AND challenge_disbursements.specifier = user_challenges.specifier WHERE challenge_disbursements.challenge_id IS NULL diff --git a/api/v1_coins_post_redeem.go b/api/v1_coins_post_redeem.go index 7553b6bd..516a9f7b 100644 --- a/api/v1_coins_post_redeem.go +++ b/api/v1_coins_post_redeem.go @@ -164,7 +164,9 @@ func (app *ApiServer) v1CoinsPostRedeem(c *fiber.Ctx) error { redeemCode = coinTicker // Check for challenge disbursement for the given code/userId var count int - err := app.writePool.QueryRow(c.Context(), `SELECT count(*) FROM v_challenge_disbursements WHERE challenge_id = @code AND specifier = @specifier LIMIT 1;`, pgx.NamedArgs{ + // Existence check against the raw table, not the view: a prior redemption must + // block a second one even if its recipient wallet doesn't resolve to a user. + err := app.writePool.QueryRow(c.Context(), `SELECT count(*) FROM sol_reward_disbursements WHERE challenge_id = @code AND specifier = @specifier LIMIT 1;`, pgx.NamedArgs{ "code": redeemCode, "specifier": specifier, }).Scan(&count) diff --git a/api/v1_users_challenges.go b/api/v1_users_challenges.go index 78e21bd1..4b4e7a55 100644 --- a/api/v1_users_challenges.go +++ b/api/v1_users_challenges.go @@ -24,11 +24,6 @@ func (app *ApiServer) v1UsersChallenges(c *fiber.Ctx) error { SELECT * FROM user_challenges JOIN user_row USING (user_id) ), - -- Pre-filter to their disbursements - challenge_disbursements_filtered AS ( - SELECT * FROM v_challenge_disbursements JOIN user_row USING (user_id) - ), - -- Start with the list of all active challenges, and then -- apply the user's user challenges and disbursements. -- verified-only challenges if not verified @@ -39,21 +34,23 @@ func (app *ApiServer) v1UsersChallenges(c *fiber.Ctx) error { COALESCE(user_challenges_filtered.specifier, '') AS specifier, COALESCE(user_challenges_filtered.is_complete, false) AS is_complete, challenges.active AS is_active, - (challenge_disbursements_filtered.slot IS NOT NULL) AS is_disbursed, + (reward_disbursements.slot IS NOT NULL) AS is_disbursed, COALESCE(user_challenges_filtered.current_step_count, 0) AS current_step_count, challenges.step_count AS max_steps, challenges.type AS challenge_type, COALESCE(challenges.amount::BIGINT, 0) AS amount, COALESCE(user_challenges_filtered.amount, 0) AS user_amount, - COALESCE(challenge_disbursements_filtered.amount, '0')::BIGINT / 100000000 AS disbursed_amount, + COALESCE(reward_disbursements.amount, 0) / 100000000 AS disbursed_amount, COALESCE(challenges.cooldown_days, 0) AS cooldown_days, user_challenges_filtered.created_at FROM challenges LEFT JOIN user_challenges_filtered ON challenges.id = user_challenges_filtered.challenge_id CROSS JOIN user_row - LEFT JOIN challenge_disbursements_filtered - ON user_challenges_filtered.challenge_id = challenge_disbursements_filtered.challenge_id - AND user_challenges_filtered.specifier = challenge_disbursements_filtered.specifier + -- Match by (challenge_id, specifier), not user: a specifier is disbursed once + -- on-chain, so a paid reward is never shown claimable even if paid to another user. + LEFT JOIN sol_reward_disbursements AS reward_disbursements + ON user_challenges_filtered.challenge_id = reward_disbursements.challenge_id + AND user_challenges_filtered.specifier = reward_disbursements.specifier WHERE challenges.active AND NOT (challenges.id IN ('rv', 's') AND NOT user_row.is_verified) AND NOT (challenges.id IN ('r') AND user_row.is_verified) diff --git a/api/v1_users_challenges_test.go b/api/v1_users_challenges_test.go index c263e234..e0355966 100644 --- a/api/v1_users_challenges_test.go +++ b/api/v1_users_challenges_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "testing" "github.com/stretchr/testify/assert" @@ -51,3 +52,35 @@ func TestUserChallenges(t *testing.T) { "data.0.is_complete": false, }) } + +// A reward that has been disbursed for a (challenge_id, specifier) must show as +// disbursed to the user who completed that specifier, even when the on-chain +// recipient wallet resolves to a different user. Disbursements are deduped by +// (challenge_id, specifier) on-chain, so a paid specifier can never be claimed +// again — surfacing it as still-claimable produces a stuck reward that errors +// on claim. +func TestUserChallengesDisbursedToDifferentUser(t *testing.T) { + app := testAppWithFixtures(t) + ctx := context.Background() + + // User 402 (L50xn) completed boolean challenge "f" with specifier "fff". + // Record an on-chain disbursement for that specifier whose recipient wallet + // belongs to a different user (user 1, rayjacobson). + _, err := app.pool.Exec(ctx, ` + INSERT INTO sol_reward_disbursements + (signature, instruction_index, amount, slot, user_bank, challenge_id, specifier, recipient_eth_address) + VALUES + ('sig-fff', 0, 100000000, 1, 'user-bank-x', 'f', 'fff', '0x7d273271690538cf855e5b3002a0dd8c154bb060') + `) + assert.NoError(t, err) + + status, body := testGet(t, app, "/v1/users/L50xn/challenges") + assert.Equal(t, 200, status) + // Challenges are ordered by challenge_id, so "f" follows the rolled-up "e". + jsonAssert(t, body, map[string]any{ + "data.1.challenge_id": "f", + "data.1.is_complete": true, + "data.1.is_disbursed": true, + "data.1.disbursed_amount": float64(1), + }) +}