Skip to content

fix(payments): prevent on-chain txId replay across winner submissions#1357

Open
mitgajera wants to merge 1 commit into
SuperteamDAO:stagingfrom
mitgajera:fix/payment-txid-replay
Open

fix(payments): prevent on-chain txId replay across winner submissions#1357
mitgajera wants to merge 1 commit into
SuperteamDAO:stagingfrom
mitgajera:fix/payment-txid-replay

Conversation

@mitgajera
Copy link
Copy Markdown
Contributor

@mitgajera mitgajera commented Apr 15, 2026

What does this PR do?

Fixes a vulnerability in the verify-external-payment endpoint where the same on-chain transaction ID could be used to mark multiple winners as paid.

The duplicate txId check was only scanning submissions fetched with isPaid: false in the current request. A txId from an already-paid submission was
invisible to this check, so a sponsor could reuse the same real on-chain transaction to "verify" payment for additional winners.

Fix: replaced the local check with a global prisma.submission.findMany query that scans all submissions (paid and unpaid) for any matching txId
before processing the payment loop.

Where should the reviewer start?

src/pages/api/sponsor-dashboard/listings/verify-external-payment.ts — lines 102–131.

The old code (removed):

const allExistingTxIds = submissions   // only unpaid, only in this request
  .flatMap(sub => sub.paymentDetails.map(p => p.txId))
  .filter(Boolean);

The new code (added):
const submissionsUsingTxIds = await prisma.submission.findMany({
  where: { OR: txIds.map(txId => ({ paymentDetails: { string_contains: txId } })) },
  select: { id: true, paymentDetails: true },
});

How should this be manually tested?

  1. Create a bounty with 2 winners (position 1 and position 2).
  2. Pay winner 1 - note the txId used.
  3. In the verify-external-payment flow for winner 2, submit the same txId.
  4. Before fix: the payment would go through.
  5. After fix: you get 400 Transaction IDs already used: .

Any background context you want to provide?

The root cause is that the submissions variable used for the duplicate check is pre-filtered to isPaid: false, which excludes paid submissions from the scope
of the check. The fix queries globally with no payment-status filter.

What are the relevant issues?

N/A (security find, no prior issue)

Summary by CodeRabbit

  • Improvements
    • Enhanced sponsor metrics display on the home banner with improved data accuracy
    • Strengthened payment verification mechanisms to prevent transaction replay attacks
    • Updated SEO structured data schema URLs for regional content pages

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 15, 2026

@mitgajera is attempting to deploy a commit to the Superteam Team on Vercel.

A member of the Team first needs to authorize it.

@mitgajera mitgajera changed the title Fix/payment txid replay fix(payments): prevent on-chain txId replay across winner submissions Apr 15, 2026
@mitgajera mitgajera marked this pull request as ready for review April 15, 2026 14:11
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 15, 2026

Warning

Rate limit exceeded

@mitgajera has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 13 minutes and 9 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 13 minutes and 9 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 13612874-bd51-490f-b2fa-10981114f2b8

📥 Commits

Reviewing files that changed from the base of the PR and between e173f33 and de20fac.

📒 Files selected for processing (1)
  • src/pages/api/sponsor-dashboard/listings/verify-external-payment.ts

Walkthrough

The PR extends sponsor and user tracking across the homepage banner component by adding a totalSponsors prop throughout the component hierarchy, from the page level down to the banner component. Additionally, it updates sponsor payment verification to query all submissions globally for txId reuse detection rather than just unpaid submissions, and adjusts JSON-LD schema URLs to include an earn/ path segment.

Changes

Cohort / File(s) Summary
Banner Component Props
src/features/home/components/Banner/SponsorBanner.tsx, src/features/home/components/Banner/index.tsx
Added optional totalSponsors prop to the banner component hierarchy; updated sponsorship count rendering logic to prefer the totalSponsors prop over data source values.
Homepage Integration
src/pages/earn/index.tsx
Extended HomePageProps with totalUsers and totalSponsors; updated getServerSideProps to fetch sponsor counts via prisma.sponsors.count(), compute rounded totals, and pass both metrics to BannerCarousel.
Payment Verification
src/pages/api/sponsor-dashboard/listings/verify-external-payment.ts
Changed txId reuse detection to query the database for all submissions (paid and unpaid) with matching transaction IDs, then aggregate and deduplicate results before comparison.
Schema URL Updates
src/utils/json-ld.ts
Updated Organization schema URLs for regions and Superteam chapters to prepend earn/ path segment (e.g., /regions/{slug}//earn/regions/{slug}/).

Sequence Diagram

sequenceDiagram
    participant Client as Client/API Request
    participant API as verify-external-payment API
    participant DB as Database
    
    Client->>API: POST with txIds to verify
    activate API
    Note over API: Extract submitted txIds
    API->>DB: Query submissions with paymentDetails<br/>containing any submitted txIds
    activate DB
    DB-->>API: Return matching submissions<br/>(paid + unpaid)
    deactivate DB
    
    Note over API: Aggregate txIds from results<br/>Deduplicate
    alt txIds already used
        API-->>Client: 400 Bad Request
    else txIds are new
        API->>DB: Process payment
        activate DB
        DB-->>API: Success
        deactivate DB
        API-->>Client: 200 OK
    end
    deactivate API
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • scutuatua-crypto
  • a20hek

🐰 A rabbit hops through sponsor counts,
Props now flow where payments mount,
Database queries cast wider nets,
URLs gain paths, new routes are set! 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title directly addresses the main security fix: preventing on-chain transaction ID replay across winner submissions, which is the primary change in verify-external-payment.ts.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/pages/api/sponsor-dashboard/listings/verify-external-payment.ts (2)

108-115: Query approach is correct; consider indexing for scale.

The paymentDetails field is a JSON type storing an array of payment objects. The string_contains filter correctly searches across all submissions but cannot use database indexes efficiently.

For production scale, if this endpoint becomes slow, consider storing txId values in a dedicated indexed column or a separate payment_transactions table. This is not blocking for the current fix.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/api/sponsor-dashboard/listings/verify-external-payment.ts` around
lines 108 - 115, The current prisma.submission.findMany query scans the JSON
paymentDetails using string_contains (paymentDetails, txIds) which won’t scale;
add a dedicated indexed field or relation to store transaction IDs (e.g., a
txIds string[] column on Submission or a payment_transactions table with
submissionId and txId) and migrate data, then update the lookup in the
verify-external-payment handler to query that indexed column/relation (e.g.,
filter by txIds has/hasSome or join on payment_transactions) instead of using
paymentDetails string_contains; keep prisma model changes consistent with
existing functions that read/write payments.

102-127: Solid fix for the txId replay vulnerability.

The global query approach correctly addresses the security issue by checking all submissions rather than just request-scoped unpaid ones. The filtering on line 125 properly handles potential string_contains substring false positives by intersecting with the input txIds.

One minor edge case: if txIds is empty (all payment links lack a txId), the query runs with OR: [] which is wasteful. Consider adding an early guard:

🛡️ Suggested guard for empty txIds
     const txIds = paymentLinks.map((link) => link.txId).filter(Boolean);
+    
+    // Skip global txId check if no txIds to verify
+    if (txIds.length === 0) {
+      // Proceed directly to validation loop - individual links will fail with "Invalid URL"
+    }
+
     const duplicateTxIds = txIds.filter(
       (txId, index) => txIds.indexOf(txId) !== index,
     );

Or wrap lines 102-133 in an if (txIds.length > 0) block.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/api/sponsor-dashboard/listings/verify-external-payment.ts` around
lines 102 - 127, Add an early guard to skip the global DB query when there are
no txIds: if txIds.length === 0 return/skip the block that runs
prisma.submission.findMany and the subsequent computation of
submissionsUsingTxIds and alreadyUsedTxIds; this avoids executing
prisma.submission.findMany({ where: { OR: txIds.map(...) } }) with an empty OR
and prevents wasted work when all payment links lack a txId.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/pages/earn/index.tsx`:
- Line 168: The calculation for totalUsers can go negative when userCount < 289;
clamp the adjusted count before rounding by replacing the expression that
computes totalUsers (the Math.ceil((userCount - 289) / 10) * 10 line) with a
version that first computes adjusted = Math.max(userCount - 289, 0) (or
equivalent), then rounds up in tens: Math.ceil(adjusted / 10) * 10, ensuring
totalUsers never becomes negative; update references to totalUsers if needed.
- Around line 163-173: The server-side code is blocking SSR by calling
prisma.user.count() and prisma.sponsors.count() inside getServerSideProps;
remove those calls and stop returning totalUsers/totalSponsors from
getServerSideProps (keep returning potentialSession/cookieExists), and instead
fetch the stats client-side from the cached endpoints /api/homepage/user-count
and /api/homepage/sponsor-count (e.g., in the page component using useEffect or
SWR) and populate UI state with their JSON responses and graceful fallbacks;
make sure to reference and replace the existing
prisma.user.count()/prisma.sponsors.count() usage and the returned props object
so the page no longer waits for DB counts on the server.

---

Nitpick comments:
In `@src/pages/api/sponsor-dashboard/listings/verify-external-payment.ts`:
- Around line 108-115: The current prisma.submission.findMany query scans the
JSON paymentDetails using string_contains (paymentDetails, txIds) which won’t
scale; add a dedicated indexed field or relation to store transaction IDs (e.g.,
a txIds string[] column on Submission or a payment_transactions table with
submissionId and txId) and migrate data, then update the lookup in the
verify-external-payment handler to query that indexed column/relation (e.g.,
filter by txIds has/hasSome or join on payment_transactions) instead of using
paymentDetails string_contains; keep prisma model changes consistent with
existing functions that read/write payments.
- Around line 102-127: Add an early guard to skip the global DB query when there
are no txIds: if txIds.length === 0 return/skip the block that runs
prisma.submission.findMany and the subsequent computation of
submissionsUsingTxIds and alreadyUsedTxIds; this avoids executing
prisma.submission.findMany({ where: { OR: txIds.map(...) } }) with an empty OR
and prevents wasted work when all payment links lack a txId.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a1ad9a75-faca-4967-86d3-abb49157e01c

📥 Commits

Reviewing files that changed from the base of the PR and between 65e9fca and e173f33.

📒 Files selected for processing (5)
  • src/features/home/components/Banner/SponsorBanner.tsx
  • src/features/home/components/Banner/index.tsx
  • src/pages/api/sponsor-dashboard/listings/verify-external-payment.ts
  • src/pages/earn/index.tsx
  • src/utils/json-ld.ts

Comment thread src/pages/earn/index.tsx
Comment on lines +163 to +173
const [userCount, sponsorCount] = await Promise.all([
prisma.user.count(),
prisma.sponsors.count(),
]);

const totalUsers = Math.ceil((userCount - 289) / 10) * 10;
const totalSponsors = Math.ceil(sponsorCount / 10) * 10;

return {
props: { potentialSession: cookieExists, totalUsers, totalSponsors },
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, let's check the file structure and read the relevant section
wc -l src/pages/earn/index.tsx

Repository: SuperteamDAO/earn

Length of output: 89


🏁 Script executed:

#!/bin/bash
# Read the getServerSideProps function to see full context
sed -n '140,180p' src/pages/earn/index.tsx

Repository: SuperteamDAO/earn

Length of output: 936


🏁 Script executed:

#!/bin/bash
# Check for any caching mechanisms in the codebase
rg -i "cache|redis|memoize" src/pages/earn/ -A 2 -B 2

Repository: SuperteamDAO/earn

Length of output: 3276


🏁 Script executed:

#!/bin/bash
# Check if there's error handling around the count queries
rg "prisma.user.count|prisma.sponsors.count" -B 5 -A 5

Repository: SuperteamDAO/earn

Length of output: 5872


🏁 Script executed:

#!/bin/bash
# Check the import statements to understand the structure
head -20 src/pages/earn/index.tsx

Repository: SuperteamDAO/earn

Length of output: 904


🏁 Script executed:

#!/bin/bash
# Check the full implementation of the API routes
cat src/app/api/homepage/user-count/route.ts

Repository: SuperteamDAO/earn

Length of output: 1067


🏁 Script executed:

#!/bin/bash
# Check the sponsor count route
cat src/app/api/homepage/sponsor-count/route.ts

Repository: SuperteamDAO/earn

Length of output: 1057


Use the existing cached API routes instead of blocking on database counts in getServerSideProps.

Lines 163-166 make every page request wait on synchronous prisma.user.count() and prisma.sponsors.count() calls with no error handling. The identical logic already exists in dedicated API routes (/api/homepage/user-count and /api/homepage/sponsor-count) with proper caching (1 day max-age), error handling, and graceful fallbacks. Fetch these stats client-side from those endpoints instead of blocking the SSR path on live database queries.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/earn/index.tsx` around lines 163 - 173, The server-side code is
blocking SSR by calling prisma.user.count() and prisma.sponsors.count() inside
getServerSideProps; remove those calls and stop returning
totalUsers/totalSponsors from getServerSideProps (keep returning
potentialSession/cookieExists), and instead fetch the stats client-side from the
cached endpoints /api/homepage/user-count and /api/homepage/sponsor-count (e.g.,
in the page component using useEffect or SWR) and populate UI state with their
JSON responses and graceful fallbacks; make sure to reference and replace the
existing prisma.user.count()/prisma.sponsors.count() usage and the returned
props object so the page no longer waits for DB counts on the server.

Comment thread src/pages/earn/index.tsx
prisma.sponsors.count(),
]);

const totalUsers = Math.ceil((userCount - 289) / 10) * 10;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clamp the adjusted user count before rounding.

Line 168 goes negative whenever userCount < 289, which can leak a negative reach number into the banner on fresh or staging databases.

Suggested fix
-  const totalUsers = Math.ceil((userCount - 289) / 10) * 10;
+  const totalUsers = Math.ceil(Math.max(0, userCount - 289) / 10) * 10;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const totalUsers = Math.ceil((userCount - 289) / 10) * 10;
const totalUsers = Math.ceil(Math.max(0, userCount - 289) / 10) * 10;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/earn/index.tsx` at line 168, The calculation for totalUsers can go
negative when userCount < 289; clamp the adjusted count before rounding by
replacing the expression that computes totalUsers (the Math.ceil((userCount -
289) / 10) * 10 line) with a version that first computes adjusted =
Math.max(userCount - 289, 0) (or equivalent), then rounds up in tens:
Math.ceil(adjusted / 10) * 10, ensuring totalUsers never becomes negative;
update references to totalUsers if needed.

The verify-external-payment endpoint checked txIds only against
submissions fetched with `isPaid: false` in the current request.
A txId from an already-paid submission was invisible to the guard,
allowing the same on-chain transaction to be reused to mark a
second winner as paid.

Fix: replace the local check with a global prisma.submission.findMany
query that scans ALL submissions (paid and unpaid) for any matching
txId before entering the payment verification loop. Also adds an
early return when no valid txIds are present to avoid a wasted query.
@mitgajera mitgajera force-pushed the fix/payment-txid-replay branch from e173f33 to de20fac Compare April 15, 2026 15:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant