From 04008a41e85c2dd0f1a321f238c72ab506fcfdb3 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Thu, 28 May 2026 22:25:08 -0700 Subject: [PATCH 1/3] fix(api): key challenge is_disbursed by specifier, not user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user-facing /users/{id}/challenges endpoint computed is_disbursed by joining disbursements filtered to the querying user's own user_id. But a reward is disbursed exactly once per (challenge_id, specifier) on-chain, independent of which wallet/user received it. When a disbursement's recipient resolves to a different user than user_challenges records (e.g. trending ranks recomputed after payout, or a wallet that no longer maps to that user), the completing user saw is_complete=true / is_disbursed=false and the client offered the reward as claimable. The claim/undisbursed path keys on (challenge_id, specifier) only, so it correctly refused — leaving a stuck reward that errors on claim. Match disbursements by (challenge_id, specifier) so an already-paid specifier is never surfaced as claimable, aligning this endpoint with the claim path. Read sol_reward_disbursements directly instead of v_challenge_disbursements so disbursements whose recipient wallet does not resolve to a current user are still counted. Co-Authored-By: Claude Opus 4.8 --- api/v1_users_challenges.go | 22 ++++++++++++---------- api/v1_users_challenges_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/api/v1_users_challenges.go b/api/v1_users_challenges.go index 78e21bd1..2a122b70 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,28 @@ 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 + -- A reward is disbursed exactly once per (challenge_id, specifier) on-chain, + -- regardless of which wallet/user received it. Match disbursements by + -- specifier (not by user_id) so a reward that has already been paid out is + -- never surfaced as still-claimable, even when it was attributed to a + -- different user than user_challenges records. Read sol_reward_disbursements + -- directly rather than v_challenge_disbursements so disbursements whose + -- recipient wallet does not resolve to a current user are still counted. + 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), + }) +} From ff97aca2aa8696061d5879453168c09db509ba01 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Thu, 28 May 2026 23:07:03 -0700 Subject: [PATCH 2/3] refactor(api): migrate challenge disbursement reads off v_challenge_disbursements Apply the specifier-keyed reasoning from the is_disbursed fix to every other consumer of the v_challenge_disbursements compatibility view. The view INNER JOINs sol_reward_disbursements to users on the recipient wallet, so any disbursement whose recipient does not resolve to a current user is silently dropped. Callers asking the identity-independent question "has this (challenge_id, specifier) been disbursed?" then wrongly treat a paid reward as unpaid: - challenges/undisbursed (HTTP + the sqlc query used by the claim path): a dropped disbursement makes a paid specifier look claimable; the claim then fails on-chain as "specifier already used" (the failure mode migration 0204 documents). - challenges/info: weekly_pool_remaining is overstated by any dropped disbursement. - coins redeem: the double-redeem guard counts 0 and allows a second redemption. These now read sol_reward_disbursements directly by (challenge_id, specifier). The one endpoint that genuinely needs the recipient user_id, challenges/disbursements, inlines the users join (using LOWER(users.wallet) per 0204, which the stale ddl/views file was silently reverting). The view itself is now unused but retained here so this binary can deploy before it is dropped. A follow-up PR drops it. Co-Authored-By: Claude Opus 4.8 --- api/dbv1/get_undisbursed_challenges.sql.go | 6 +++++- .../queries/get_undisbursed_challenges.sql | 6 +++++- api/v1_challenges_disbursements.go | 20 +++++++++++++------ api/v1_challenges_info.go | 8 ++++++-- api/v1_challenges_undisbursed.go | 7 ++++++- api/v1_coins_post_redeem.go | 6 +++++- 6 files changed, 41 insertions(+), 12 deletions(-) diff --git a/api/dbv1/get_undisbursed_challenges.sql.go b/api/dbv1/get_undisbursed_challenges.sql.go index ae9e1653..f746d30b 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,10 @@ type GetUndisbursedChallengesRow struct { Amount int32 `json:"amount"` } +// Match raw disbursement rows by (challenge_id, specifier): a reward is disbursed +// once per specifier on-chain regardless of recipient, and reading +// sol_reward_disbursements directly avoids v_challenge_disbursements dropping +// disbursements whose recipient wallet does not resolve to a current 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..68b264c2 100644 --- a/api/dbv1/queries/get_undisbursed_challenges.sql +++ b/api/dbv1/queries/get_undisbursed_challenges.sql @@ -7,7 +7,11 @@ 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 +-- Match raw disbursement rows by (challenge_id, specifier): a reward is disbursed +-- once per specifier on-chain regardless of recipient, and reading +-- sol_reward_disbursements directly avoids v_challenge_disbursements dropping +-- disbursements whose recipient wallet does not resolve to a current 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..fc041756 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,33 @@ 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 ") } + // Resolve the recipient user_id from the on-chain recipient_eth_address. + // The Go indexer stores recipient_eth_address lowercased, while users.wallet + // is an EIP-55 checksummed (mixed-case) address, so normalise before joining + // (see migration 0204). This inlines the former v_challenge_disbursements view, + // now that this is its only consumer. 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..f028db07 100644 --- a/api/v1_challenges_info.go +++ b/api/v1_challenges_info.go @@ -59,8 +59,12 @@ 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 + -- Read sol_reward_disbursements directly: the weekly pool spent is the + -- sum of all disbursements for the challenge, independent of recipient. + -- v_challenge_disbursements would drop any whose recipient wallet does + -- not resolve to a current user, overstating 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..173f6b23 100644 --- a/api/v1_challenges_undisbursed.go +++ b/api/v1_challenges_undisbursed.go @@ -62,7 +62,12 @@ 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 + -- A reward is disbursed once per (challenge_id, specifier) on-chain, regardless + -- of recipient. Match on the raw sol_reward_disbursements rows by specifier so a + -- reward that was already paid is never offered for claim, even if its recipient + -- wallet does not resolve to a current user (which v_challenge_disbursements would + -- drop, wrongly surfacing it as claimable). + 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..c86822d0 100644 --- a/api/v1_coins_post_redeem.go +++ b/api/v1_coins_post_redeem.go @@ -164,7 +164,11 @@ 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{ + // Read sol_reward_disbursements directly: this is an existence check by + // (challenge_id, specifier) and must count a prior redemption even if its + // recipient wallet does not resolve to a current user (which + // v_challenge_disbursements would drop, allowing a double redemption). + 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) From 282ec67529991b848a7f4430588e3680770a9c37 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Fri, 29 May 2026 09:51:07 -0700 Subject: [PATCH 3/3] chore(api): trim disbursement-read comments Condense the repeated "read the raw table, not v_challenge_disbursements" rationale: keep the load-bearing notes (the view-drops-unresolved-recipients reasoning on the claim path, and the LOWER()/0204 join) and reduce the rest to a single line each. Co-Authored-By: Claude Opus 4.8 --- api/dbv1/get_undisbursed_challenges.sql.go | 6 ++---- api/dbv1/queries/get_undisbursed_challenges.sql | 6 ++---- api/v1_challenges_disbursements.go | 8 +++----- api/v1_challenges_info.go | 6 ++---- api/v1_challenges_undisbursed.go | 8 +++----- api/v1_coins_post_redeem.go | 6 ++---- api/v1_users_challenges.go | 9 ++------- 7 files changed, 16 insertions(+), 33 deletions(-) diff --git a/api/dbv1/get_undisbursed_challenges.sql.go b/api/dbv1/get_undisbursed_challenges.sql.go index f746d30b..931d4475 100644 --- a/api/dbv1/get_undisbursed_challenges.sql.go +++ b/api/dbv1/get_undisbursed_challenges.sql.go @@ -45,10 +45,8 @@ type GetUndisbursedChallengesRow struct { Amount int32 `json:"amount"` } -// Match raw disbursement rows by (challenge_id, specifier): a reward is disbursed -// once per specifier on-chain regardless of recipient, and reading -// sol_reward_disbursements directly avoids v_challenge_disbursements dropping -// disbursements whose recipient wallet does not resolve to a current user. +// 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 68b264c2..5256b699 100644 --- a/api/dbv1/queries/get_undisbursed_challenges.sql +++ b/api/dbv1/queries/get_undisbursed_challenges.sql @@ -7,10 +7,8 @@ SELECT user_challenges.amount FROM user_challenges JOIN users ON users.user_id = user_challenges.user_id --- Match raw disbursement rows by (challenge_id, specifier): a reward is disbursed --- once per specifier on-chain regardless of recipient, and reading --- sol_reward_disbursements directly avoids v_challenge_disbursements dropping --- disbursements whose recipient wallet does not resolve to a current user. +-- 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 diff --git a/api/v1_challenges_disbursements.go b/api/v1_challenges_disbursements.go index fc041756..244a47f2 100644 --- a/api/v1_challenges_disbursements.go +++ b/api/v1_challenges_disbursements.go @@ -70,11 +70,9 @@ func (app *ApiServer) v1ChallengesDisbursements(c *fiber.Ctx) error { whereClause = "WHERE " + strings.Join(filters, " AND ") } - // Resolve the recipient user_id from the on-chain recipient_eth_address. - // The Go indexer stores recipient_eth_address lowercased, while users.wallet - // is an EIP-55 checksummed (mixed-case) address, so normalise before joining - // (see migration 0204). This inlines the former v_challenge_disbursements view, - // now that this is its only consumer. + // 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, diff --git a/api/v1_challenges_info.go b/api/v1_challenges_info.go index f028db07..4b78a194 100644 --- a/api/v1_challenges_info.go +++ b/api/v1_challenges_info.go @@ -59,10 +59,8 @@ func (app *ApiServer) v1ChallengesInfo(c *fiber.Ctx) error { CASE WHEN c.weekly_pool IS NULL THEN NULL ELSE c.weekly_pool - COALESCE( - -- Read sol_reward_disbursements directly: the weekly pool spent is the - -- sum of all disbursements for the challenge, independent of recipient. - -- v_challenge_disbursements would drop any whose recipient wallet does - -- not resolve to a current user, overstating the remaining pool. + -- 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 diff --git a/api/v1_challenges_undisbursed.go b/api/v1_challenges_undisbursed.go index 173f6b23..325f47f8 100644 --- a/api/v1_challenges_undisbursed.go +++ b/api/v1_challenges_undisbursed.go @@ -62,11 +62,9 @@ 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 - -- A reward is disbursed once per (challenge_id, specifier) on-chain, regardless - -- of recipient. Match on the raw sol_reward_disbursements rows by specifier so a - -- reward that was already paid is never offered for claim, even if its recipient - -- wallet does not resolve to a current user (which v_challenge_disbursements would - -- drop, wrongly surfacing it as claimable). + -- 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 diff --git a/api/v1_coins_post_redeem.go b/api/v1_coins_post_redeem.go index c86822d0..516a9f7b 100644 --- a/api/v1_coins_post_redeem.go +++ b/api/v1_coins_post_redeem.go @@ -164,10 +164,8 @@ func (app *ApiServer) v1CoinsPostRedeem(c *fiber.Ctx) error { redeemCode = coinTicker // Check for challenge disbursement for the given code/userId var count int - // Read sol_reward_disbursements directly: this is an existence check by - // (challenge_id, specifier) and must count a prior redemption even if its - // recipient wallet does not resolve to a current user (which - // v_challenge_disbursements would drop, allowing a double redemption). + // 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, diff --git a/api/v1_users_challenges.go b/api/v1_users_challenges.go index 2a122b70..4b4e7a55 100644 --- a/api/v1_users_challenges.go +++ b/api/v1_users_challenges.go @@ -46,13 +46,8 @@ func (app *ApiServer) v1UsersChallenges(c *fiber.Ctx) error { FROM challenges LEFT JOIN user_challenges_filtered ON challenges.id = user_challenges_filtered.challenge_id CROSS JOIN user_row - -- A reward is disbursed exactly once per (challenge_id, specifier) on-chain, - -- regardless of which wallet/user received it. Match disbursements by - -- specifier (not by user_id) so a reward that has already been paid out is - -- never surfaced as still-claimable, even when it was attributed to a - -- different user than user_challenges records. Read sol_reward_disbursements - -- directly rather than v_challenge_disbursements so disbursements whose - -- recipient wallet does not resolve to a current user are still counted. + -- 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