Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion api/dbv1/get_undisbursed_challenges.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion api/dbv1/queries/get_undisbursed_challenges.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions api/v1_challenges_disbursements.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := ""
Expand All @@ -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;
`
Expand Down
6 changes: 4 additions & 2 deletions api/v1_challenges_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion api/v1_challenges_undisbursed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion api/v1_coins_post_redeem.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 7 additions & 10 deletions api/v1_users_challenges.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions api/v1_users_challenges_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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),
})
}
Loading