Skip to content

Master to stag - #2319

Merged
RamRamez merged 30 commits into
stagingfrom
master
Apr 28, 2026
Merged

Master to stag#2319
RamRamez merged 30 commits into
stagingfrom
master

Conversation

@RamRamez

@RamRamez RamRamez commented Apr 28, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

Release Notes

  • New Features

    • Added rate limiting for user account creation (20 per day for non-trusted requests)
    • Enhanced transaction data retrieval with RPC fallback when block explorer is unavailable
  • Improvements

    • Refined Givback eligibility calculations to use current month's operational window
    • Expanded CORS allowlist to include qf.giveth.io domain
    • Enhanced project address filtering for recipient validation

ae2079 and others added 30 commits February 5, 2026 01:30
Fix issue related to the AA wallet donations stuck in pending state
Add debug logs for the pending donations
deploy fix on the log decoding for AA wallets
Fix the failed donation issue
Hot fix for the boost data sync issue
hot fix for the boosting sync job
Hot fix for the sync boosting problem
Release hotfix: fix stale giveconomy sync event handling
Keep the public createUserByAddress IP cap in place while letting trusted internal requests authenticated with VERCEL_KEY skip the shared egress bucket. Add focused coverage for trusted bypass and public rate limiting.

Made-with: Cursor
Make the live Optimism transaction tests skip when the RPC cannot return a historical receipt instead of failing unrelated PRs. Keep the assertions active when the external provider responds normally.

Made-with: Cursor
Make the createUserByAddress limiter atomic so crashed requests cannot leave TTL-less counters behind, reuse the shared request trust/IP helpers across bootstrap and the resolver, and keep the rejection coverage aligned with the configured limit message.

Made-with: Cursor
…limit-master

hotfix trusted createUserByAddress rate limit
…s-duplicate-check

add isRecipient to wallet address duplicate check
@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This PR refactors donation and Givback calculations to use monthly operational UTC windows instead of power rounds, implements rate limiting with trusted Vercel bypass for user creation, adds RPC trace fallback for transaction value extraction, and updates related repositories, tests, and utilities to support these changes.

Changes

Cohort / File(s) Summary
Donation and Givback Calculation Logic
src/repositories/donationRepository.ts, src/repositories/donationRepository.test.ts
Repository function rewritten to calculate eligible donations via current-month UTC operational window (16:00 to 15:59:59) with recurring donation grouping and project-specific thresholds, removing prior power-round dependency. Tests updated to use deterministic timestamps within the operational window instead of powerRound values, with assertions relaxed to verify minimum sums and cleanup added.
Givback Factor Calculation
src/services/givbackService.ts, src/resolvers/donationResolver.test.ts
New exported calculateGivbackFactorByRank function extracted to compute givback factors with clamped min/max bounds. calculateGivbackFactor refactored to use the new helper and guarded env parsing. Test updated to validate actual rank-based computation instead of hardcoded assumptions, with deterministic timestamps via helper function.
Rate Limiting for User Creation
src/resolvers/userResolver.ts, src/resolvers/userResolver.rateLimit.test.ts, src/server/bootstrap.ts, src/utils/ipWhitelist.ts
Rate limiting logic added to user creation by address, bypassing checks for trusted Vercel requests via new isTrustedVercelRequest utility. Non-trusted requests checked via atomic Redis Lua script. Client IP extraction refactored and exported. Bootstrap rate-limit skip callback delegated to utility. New test file verifies both trusted and non-trusted rate-limiting paths.
Project Address Repository Updates
src/repositories/projectAddressRepository.ts, src/repositories/projectAddressRepository.test.ts, src/utils/validators/projectValidator.test.ts
findRelatedAddressByWalletAddress restricted to isRecipient = true records. Test suite switched from single-result to array-based assertions. Validator test added for non-recipient address scenario.
Transaction Service RPC Trace Fallback
src/services/chains/evm/transactionService.ts, src/services/chains/index.test.ts
Internal transaction extraction enhanced with RPC-based fallback chain: debug_traceTransaction (callTracer) followed by trace_transaction when block explorer fails. Value transfers recursively collected and converted to wei. Related tests refactored to handle TRANSACTION_NOT_FOUND errors gracefully.
CORS Configuration
src/server/cors.ts
Static CORS whitelist updated to permanently include qf.giveth.io and its subdomains.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant UserResolver
    participant VercelCheck
    participant Redis
    
    Client->>UserResolver: createUserByAddress (with/without vercel_key header)
    UserResolver->>VercelCheck: isTrustedVercelRequest(req)
    alt Trusted Vercel Request
        VercelCheck-->>UserResolver: true
        UserResolver->>UserResolver: Return existing user<br/>(bypass rate limit)
    else Non-Trusted Request
        VercelCheck-->>UserResolver: false
        UserResolver->>UserResolver: Extract client IP<br/>via getClientIP()
        UserResolver->>Redis: Execute Lua script<br/>(INCR + EXPIRE)
        alt Redis count <= limit (20)
            Redis-->>UserResolver: count
            UserResolver-->>Client: Create/return user
        else Redis count > limit
            Redis-->>UserResolver: count > 20
            UserResolver-->>Client: Error: Rate limit exceeded
        end
    end
Loading
sequenceDiagram
    participant TransactionService
    participant BlockExplorer
    participant DebugRPC
    participant TraceRPC
    
    TransactionService->>BlockExplorer: Fetch internal transactions<br/>(txlistinternal)
    alt Explorer Success
        BlockExplorer-->>TransactionService: Internal transactions
        TransactionService-->>TransactionService: Return results
    else Explorer Fails
        BlockExplorer-->>TransactionService: Error
        TransactionService->>DebugRPC: debug_traceTransaction<br/>(callTracer)
        alt Debug Trace Success
            DebugRPC-->>TransactionService: Trace data
            TransactionService->>TransactionService: Extract value transfers<br/>recursively
            TransactionService-->>TransactionService: Return transfers
        else Debug Trace Fails
            DebugRPC-->>TransactionService: Error
            TransactionService->>TraceRPC: trace_transaction<br/>(fallback)
            alt Trace Success
                TraceRPC-->>TransactionService: Trace data
                TransactionService->>TransactionService: Extract value transfers
                TransactionService-->>TransactionService: Return transfers
            else Trace Fails
                TraceRPC-->>TransactionService: Error
                TransactionService-->>TransactionService: Return empty array
            end
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • kkatusic

Poem

🐰 Givbacks bloom in UTC windows,
Rate limits guard with Vercel's key,
Traces dance through RPC fallows,
Each donation grouped with glee,
Security and logic reign supreme! 🌿

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Master to stag' is vague and does not meaningfully describe the changeset; it appears to reference branch names rather than the actual code changes. Replace with a descriptive title summarizing the main changes, such as 'Refactor donation/givback calculations and rate limiting' or 'Update givback eligibility and add rate limiting to user creation'.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch master

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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 9

🧹 Nitpick comments (4)
src/repositories/projectAddressRepository.test.ts (1)

255-268: Add one focused test for findRelatedAddressByWalletAddress recipient-only filtering.

These updates validate the findAll... path well, but the behavior changed in production code is in findRelated.... Add a case asserting it returns null for non-recipient rows and returns a row when isRecipient = true.

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

In `@src/repositories/projectAddressRepository.test.ts` around lines 255 - 268,
Add a focused test that calls findRelatedAddressByWalletAddress to verify
recipient-only filtering: after creating the related address rows used in the
current test (the ones queried by findAllRelatedAddressByWalletAddress), call
findRelatedAddressByWalletAddress(newAddress1) and assert it returns null when
isRecipient is false, then flip or create a row with isRecipient = true and call
findRelatedAddressByWalletAddress(newAddress2) (or the updated address) and
assert it returns the related address with project.id matching project.id; use
the same identifiers (findRelatedAddressByWalletAddress,
findAllRelatedAddressByWalletAddress, newAddress1, newAddress2, project) so the
new assertions integrate alongside the existing assertions.
src/utils/ipWhitelist.ts (1)

51-59: Consider using constant-time comparison for the secret key.

The current string comparison headerValue === vercelKey is vulnerable to timing attacks, where an attacker could potentially infer the secret by measuring response times. While the practical risk is low in this context (rate limiting bypass), using a constant-time comparison is a security best practice for secret comparisons.

🔒 Proposed fix using crypto.timingSafeEqual
+import { timingSafeEqual } from 'crypto';
+
+const safeCompare = (a: string, b: string): boolean => {
+  const bufA = Buffer.from(a);
+  const bufB = Buffer.from(b);
+  if (bufA.length !== bufB.length) {
+    return false;
+  }
+  return timingSafeEqual(bufA, bufB);
+};
+
 export const isTrustedVercelRequest = (req: any): boolean => {
   const vercelKey = process.env.VERCEL_KEY;
   const headerValue = getHeaderValue(req?.headers?.vercel_key);
   if (!vercelKey || !headerValue) {
     return false;
   }

-  return headerValue === vercelKey;
+  return safeCompare(headerValue, vercelKey);
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/ipWhitelist.ts` around lines 51 - 59, The equality check in
isTrustedVercelRequest uses a normal string comparison which can leak timing
information; update isTrustedVercelRequest to perform a constant-time comparison
by importing Node's crypto and using crypto.timingSafeEqual: convert headerValue
and process.env.VERCEL_KEY to Buffer (e.g., Buffer.from(..., 'utf8')), ensure
they are the same length (return false if lengths differ) and then call
timingSafeEqual inside a try/catch, returning the boolean result and false on
any error; reference the isTrustedVercelRequest function and the
vercelKey/headerValue values when making the change.
src/resolvers/userResolver.rateLimit.test.ts (1)

47-68: Consider adding a test for the boundary condition.

The current tests cover trusted bypass and over-limit rejection. Consider adding a test that verifies the 20th request succeeds while the 21st fails, to ensure the boundary condition (current > CREATE_USER_BY_ADDRESS_RATE_LIMIT) works correctly.

🧪 Example boundary test
it('allows the 20th request but rejects the 21st', async () => {
  process.env.VERCEL_KEY = 'trusted-vercel-key';
  const evalStub = sinon.stub(redis, 'eval');
  const existingUser = { id: 1, walletAddress: '0x...' } as User;
  const getOne = sinon.stub().resolves(existingUser);
  const where = sinon.stub().returns({ getOne });
  sinon.stub(User, 'createQueryBuilder').returns({ where } as never);
  sinon.stub(AppDataSource, 'getDataSource').returns({
    getRepository: () => ({}),
  } as never);
  const resolver = new UserResolver({} as never);
  const ctx = { expressReq: { headers: {}, ip: '1.2.3.4' } } as never;

  // 20th request should succeed
  evalStub.resolves(20);
  const result = await resolver.createUserByAddress('0x...', ctx);
  assert.isTrue(result.existing);

  // 21st request should fail
  evalStub.resolves(21);
  await assertRejects(
    () => resolver.createUserByAddress('0x...', ctx),
    /Rate limit exceeded/,
  );
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/resolvers/userResolver.rateLimit.test.ts` around lines 47 - 68, Add a
boundary test for UserResolver.createUserByAddress that stubs redis.eval to
return 20 then 21 and asserts the 20th request succeeds while the 21st is
rejected; specifically, set up stubs for redis.eval, User.createQueryBuilder (or
getRepository/getOne) to simulate an existing user, stub
AppDataSource.getDataSource to return a repository, instantiate new
UserResolver({} as never) and call createUserByAddress with a context object
(expressReq.headers/ip), first resolving eval to 20 and asserting a
successful/existing result, then resolving eval to 21 and asserting
assertRejects with /Rate limit exceeded/.
src/repositories/donationRepository.ts (1)

665-665: Explicitly convert result to number for type safety.

PostgreSQL may return numeric aggregates as strings through certain drivers to preserve decimal precision. The function signature promises Promise<number>, so explicit conversion prevents potential type mismatches.

Suggested fix
-      return result?.[0]?.totalUsdWithGivbackFactor ?? 0;
+      return parseFloat(result?.[0]?.totalUsdWithGivbackFactor) || 0;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/repositories/donationRepository.ts` at line 665, The query result may
return totalUsdWithGivbackFactor as a string; update the return in the function
that currently returns result?.[0]?.totalUsdWithGivbackFactor ?? 0 to explicitly
coerce the value to a number (e.g., using Number(...) or parseFloat(...)) before
returning so the function's Promise<number> contract is satisfied; ensure you
handle null/undefined by falling back to 0 if conversion yields NaN.
🤖 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/repositories/donationRepository.test.ts`:
- Around line 2014-2026: The test currently asserts only that the aggregated sum
is at least the expected amount; capture a baseline by calling
getSumOfGivbackEligibleDonationsForSpecificRound() before inserting test
donations, then insert donation1/2/3, call the same function again and assert
that the exact delta equals valueUsd1*givbackFactor1 + valueUsd2*givbackFactor2
+ valueUsd3*givbackFactor3; for the exclusion cases follow the same
baseline/delta pattern: insert the excluded donation, call the sum function and
assert the delta is 0 for that excluded donation; keep using
Donation.delete(...) for cleanup and apply this baseline/delta fix to the other
affected tests that use getSumOfGivbackEligibleDonationsForSpecificRound and
Donation.delete.

In `@src/repositories/donationRepository.ts`:
- Around line 634-635: The SQL date filter in donationRepository.ts currently
uses strict inequalities (">" and "<") which excludes donations that fall
exactly on the window edges; update the WHERE clause to use inclusive
comparisons (">=" for the start boundary and "<" or preferably "<=" for the end
boundary depending on desired inclusivity) and, if following the suggested
approach, compute endDate as 16:00:00 on the first day of the next month so the
range is [startDate >= 16:00:00 on 1st, endDate < next month 16:00:00) or use
startDate >= and endDate <= if you want both endpoints included; modify the SQL
fragment and the code that builds startDate/endDate accordingly to ensure
donations exactly at 16:00:00 are captured.

In `@src/resolvers/donationResolver.test.ts`:
- Around line 2251-2259: Current test wrongly derives expectedGivbackFactor from
the saved donation itself (using donation.projectRank and
donation.bottomRankInRound), making the assertion self-fulfilling; instead, use
the known fixture values (the 80/20 snapshot: projectRank = 1 and
bottomRankInRound = 2) to compute the expected factor and assert against that.
Update the test to assert donation.projectRank === 1 and
donation.bottomRankInRound === 2 (or use the fixture variable names if present),
then call calculateGivbackFactorByRank with those literal/fixture values and
process.env.GIVBACK_MIN_FACTOR/MAX to compute expectedGivbackFactor and compare
it to donation.givbackFactor; keep the existing assertion for powerRound
(roundNumber) unchanged.
- Around line 6571-6586: The test currently uses assert.isAtLeast on
allocatedGivbacks.usdValueSentAmountInPowerRound which allows over-counting to
pass; change it to assert the exact delta introduced by donation1/2/3 by (1)
fetching the current allocatedGivbacks aggregate before inserting the three test
donations (store e.g. priorAllocated =
allocatedGivbacks.usdValueSentAmountInPowerRound), (2) insert donation1,
donation2, donation3 as done, (3) fetch allocatedGivbacks again into
result.data.data?.allocatedGivbacks and compute expectedSum as already defined,
then assert that newAllocated === priorAllocated + expectedSum (use strict
equality, not isAtLeast), and finally run the existing cleanup
Donation.delete([donation1.id, donation2.id, donation3.id]).

In `@src/services/chains/evm/transactionService.ts`:
- Around line 540-552: The empty-result branch in
getInternalTransactionsByTxHash currently returns [] when the explorer responds
with status: '0' or "no transactions found" without attempting the new RPC trace
fallback; update getInternalTransactionsByTxHash so that in the empty-result /
status:'0' path it calls getInternalTransactionsByTxHashFromRpcTrace (same as
the explorer-exception path), logs via txTrace (e.g.,
'getInternalTransactionsByTxHash:explorer_empty_fallback' and
'...:fallback_done' with internalTxsCount), and only return [] after that
fallback also yields no results; this ensures callers (and
getInternalTransfersFromTrace consumers) benefit from trace_transaction
retry-capable RPCs.
- Around line 367-389: collectValueTransfersFromCallTrace currently treats any
frame with a positive value as a transfer and can include reverted/errored
subcalls; update the function to first detect and skip errored/reverted frames
(e.g., check node.error, node.reverted or node.revertReason, node.failed, or
node.status === 0) with an early return before parsing value/adding a transfer,
and apply the identical guard to the other similar routine handling call traces
(the similar block around lines 437-453) so reverted internal calls are never
counted as value transfers.

In `@src/services/chains/index.test.ts`:
- Around line 13-27: The test helper
getTransactionInfoOrSkipWhenRpcIsUnavailable should not skip on
application-level TRANSACTION_NOT_FOUND from getTransactionInfoFromNetwork;
update the catch in getTransactionInfoOrSkipWhenRpcIsUnavailable to only call
context.skip() for transport-level RPC failures (e.g., network/connection
errors, request timeouts, or HTTP 5xx responses) by checking the thrown error's
class/properties (for example network error types, status/code fields or
messages like "ECONN", "ETIMEDOUT", or HTTP status >=500) and rethrow otherwise;
alternatively replace live chain calls with mocks for
getTransactionInfoFromNetwork to avoid flaky RPCs.

In `@src/services/givbackService.ts`:
- Around line 15-17: The current minFactor/maxFactor computation can produce NaN
if either minGivFactor or maxGivFactor is non-finite; before calling
Math.min/Math.max, sanitize inputs (Number.isFinite) and coerce non-finite
values to safe fallbacks (e.g., if one is finite use that value for both, or use
explicit defaults), then compute minFactor = Math.min(sanitizedMin,
sanitizedMax) and maxFactor = Math.max(sanitizedMin, sanitizedMax). Update the
helper that computes these bounds (the code that sets minFactor/maxFactor) so it
always returns finite numbers, and ensure calculateGivbackFactorByRank() uses
this sanitized result (and that calculateGivbackFactor() behavior remains
unchanged).
- Around line 73-74: Normalize the parsed rank coming from
getBottomGivbackRank() so both the factor and the returned value use the same
numeric value: parse the result once (e.g., const bottomRankNum =
Number(bottomRank)), validate Number.isFinite(bottomRankNum) && bottomRankNum >
0, use bottomRankNum when computing the factor and set bottomRankInRound to
bottomRankNum (falling back to 1 only if invalid). Update references in the
function that currently use Number(bottomRank) and the bottomRankInRound ternary
to use this normalized bottomRankNum.

---

Nitpick comments:
In `@src/repositories/donationRepository.ts`:
- Line 665: The query result may return totalUsdWithGivbackFactor as a string;
update the return in the function that currently returns
result?.[0]?.totalUsdWithGivbackFactor ?? 0 to explicitly coerce the value to a
number (e.g., using Number(...) or parseFloat(...)) before returning so the
function's Promise<number> contract is satisfied; ensure you handle
null/undefined by falling back to 0 if conversion yields NaN.

In `@src/repositories/projectAddressRepository.test.ts`:
- Around line 255-268: Add a focused test that calls
findRelatedAddressByWalletAddress to verify recipient-only filtering: after
creating the related address rows used in the current test (the ones queried by
findAllRelatedAddressByWalletAddress), call
findRelatedAddressByWalletAddress(newAddress1) and assert it returns null when
isRecipient is false, then flip or create a row with isRecipient = true and call
findRelatedAddressByWalletAddress(newAddress2) (or the updated address) and
assert it returns the related address with project.id matching project.id; use
the same identifiers (findRelatedAddressByWalletAddress,
findAllRelatedAddressByWalletAddress, newAddress1, newAddress2, project) so the
new assertions integrate alongside the existing assertions.

In `@src/resolvers/userResolver.rateLimit.test.ts`:
- Around line 47-68: Add a boundary test for UserResolver.createUserByAddress
that stubs redis.eval to return 20 then 21 and asserts the 20th request succeeds
while the 21st is rejected; specifically, set up stubs for redis.eval,
User.createQueryBuilder (or getRepository/getOne) to simulate an existing user,
stub AppDataSource.getDataSource to return a repository, instantiate new
UserResolver({} as never) and call createUserByAddress with a context object
(expressReq.headers/ip), first resolving eval to 20 and asserting a
successful/existing result, then resolving eval to 21 and asserting
assertRejects with /Rate limit exceeded/.

In `@src/utils/ipWhitelist.ts`:
- Around line 51-59: The equality check in isTrustedVercelRequest uses a normal
string comparison which can leak timing information; update
isTrustedVercelRequest to perform a constant-time comparison by importing Node's
crypto and using crypto.timingSafeEqual: convert headerValue and
process.env.VERCEL_KEY to Buffer (e.g., Buffer.from(..., 'utf8')), ensure they
are the same length (return false if lengths differ) and then call
timingSafeEqual inside a try/catch, returning the boolean result and false on
any error; reference the isTrustedVercelRequest function and the
vercelKey/headerValue values when making the change.
🪄 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: 34d9dfaf-f17a-42c3-ac1e-b1222543f13b

📥 Commits

Reviewing files that changed from the base of the PR and between 1346cc0 and 33e1406.

📒 Files selected for processing (14)
  • src/repositories/donationRepository.test.ts
  • src/repositories/donationRepository.ts
  • src/repositories/projectAddressRepository.test.ts
  • src/repositories/projectAddressRepository.ts
  • src/resolvers/donationResolver.test.ts
  • src/resolvers/userResolver.rateLimit.test.ts
  • src/resolvers/userResolver.ts
  • src/server/bootstrap.ts
  • src/server/cors.ts
  • src/services/chains/evm/transactionService.ts
  • src/services/chains/index.test.ts
  • src/services/givbackService.ts
  • src/utils/ipWhitelist.ts
  • src/utils/validators/projectValidator.test.ts

Comment on lines 2014 to +2026
const sumOfGivbackEligibleDonations =
await getSumOfGivbackEligibleDonationsForSpecificRound({
powerRound,
});
await getSumOfGivbackEligibleDonationsForSpecificRound({});

assert.equal(
sumOfGivbackEligibleDonations,
const expectedSum =
valueUsd1 * givbackFactor1 +
valueUsd2 * givbackFactor2 +
valueUsd3 * givbackFactor3,
);
valueUsd2 * givbackFactor2 +
valueUsd3 * givbackFactor3;

// Result should include at least these donations (may include others from DB)
assert.isAtLeast(sumOfGivbackEligibleDonations, expectedSum);

// Cleanup
await Donation.delete([donation1.id, donation2.id, donation3.id]);

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

Strengthen these aggregation assertions.

These checks can still pass when the query is wrong: the happy-path test only proves the total is at least the expected value, and the exclusion tests only prove the total does not change after deleting the excluded row. If eligible donations are dropped, or unrelated rows are double-counted, this suite still goes green. Capture a baseline before inserting test data and assert the exact delta contributed by the eligible donations.

Suggested pattern
+ const baseline =
+   await getSumOfGivbackEligibleDonationsForSpecificRound({});
+
  const sumOfGivbackEligibleDonations =
    await getSumOfGivbackEligibleDonationsForSpecificRound({});

- assert.isAtLeast(sumOfGivbackEligibleDonations, expectedSum);
+ assert.equal(sumOfGivbackEligibleDonations - baseline, expectedSum);

Apply the same baseline/delta pattern to the exclusion cases so they verify both:

  1. eligible donations are included, and
  2. the excluded donation contributes 0.

Also applies to: 2085-2097, 2158-2170, 2241-2253, 2292-2304

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

In `@src/repositories/donationRepository.test.ts` around lines 2014 - 2026, The
test currently asserts only that the aggregated sum is at least the expected
amount; capture a baseline by calling
getSumOfGivbackEligibleDonationsForSpecificRound() before inserting test
donations, then insert donation1/2/3, call the same function again and assert
that the exact delta equals valueUsd1*givbackFactor1 + valueUsd2*givbackFactor2
+ valueUsd3*givbackFactor3; for the exclusion cases follow the same
baseline/delta pattern: insert the excluded donation, call the sum function and
assert the delta is 0 for that excluded donation; keep using
Donation.delete(...) for cleanup and apply this baseline/delta fix to the other
affected tests that use getSumOfGivbackEligibleDonationsForSpecificRound and
Donation.delete.

Comment on lines +634 to +635
AND "createdAt" > $1
AND "createdAt" < $2

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

Boundary conditions may exclude donations at exact window edges.

The SQL uses strict inequality (> and <) which excludes donations at exactly 16:00:00.000 on the 1st and at 15:59:59.xxx on the last day. If the operational window should be inclusive of these boundaries (as the comments suggest "at 16:00 UTC"), consider using >= for the start boundary.

Suggested fix for inclusive boundaries
-          AND "createdAt" > $1
-          AND "createdAt" < $2
+          AND "createdAt" >= $1
+          AND "createdAt" < $2

And adjust endDate to be 16:00:00 on the 1st of the next month for cleaner boundary:

-      // End: Last day of current month at 15:59:59 UTC (or 1st of next month at 15:59:59)
-      const endDate = new Date(Date.UTC(year, month + 1, 0, 15, 59, 59));
+      // End: 1st of next month at 16:00 UTC (exclusive)
+      const endDate = new Date(Date.UTC(year, month + 1, 1, 16, 0, 0));
📝 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
AND "createdAt" > $1
AND "createdAt" < $2
AND "createdAt" >= $1
AND "createdAt" < $2
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/repositories/donationRepository.ts` around lines 634 - 635, The SQL date
filter in donationRepository.ts currently uses strict inequalities (">" and "<")
which excludes donations that fall exactly on the window edges; update the WHERE
clause to use inclusive comparisons (">=" for the start boundary and "<" or
preferably "<=" for the end boundary depending on desired inclusivity) and, if
following the suggested approach, compute endDate as 16:00:00 on the first day
of the next month so the range is [startDate >= 16:00:00 on 1st, endDate < next
month 16:00:00) or use startDate >= and endDate <= if you want both endpoints
included; modify the SQL fragment and the code that builds startDate/endDate
accordingly to ensure donations exactly at 16:00:00 are captured.

Comment on lines +2251 to +2259
const expectedGivbackFactor = calculateGivbackFactorByRank({
projectRank: donation?.projectRank,
bottomRank: donation?.bottomRankInRound || 1,
minGivFactor: Number(process.env.GIVBACK_MIN_FACTOR),
maxGivFactor: Number(process.env.GIVBACK_MAX_FACTOR),
});
assert.equal(donation?.givbackFactor, expectedGivbackFactor);
assert.equal(donation?.powerRound, roundNumber);
assert.equal(donation?.projectRank, 1);
assert.isAtLeast(donation?.projectRank || 0, 1);

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

Don't derive the expected rank/factor from the saved donation itself.

This assertion is now self-fulfilling: expectedGivbackFactor is computed from donation.projectRank and donation.bottomRankInRound, which were produced by the same calculateGivbackFactor() call you're trying to validate. If the service stores the wrong rank pair, this still passes. Use the known fixture instead — with the 80/20 snapshot setup, this project should be rank 1 among 2 ranked projects.

Suggested fix
-    const expectedGivbackFactor = calculateGivbackFactorByRank({
-      projectRank: donation?.projectRank,
-      bottomRank: donation?.bottomRankInRound || 1,
+    assert.equal(donation?.projectRank, 1);
+    assert.equal(donation?.bottomRankInRound, 2);
+    const expectedGivbackFactor = calculateGivbackFactorByRank({
+      projectRank: 1,
+      bottomRank: 2,
       minGivFactor: Number(process.env.GIVBACK_MIN_FACTOR),
       maxGivFactor: Number(process.env.GIVBACK_MAX_FACTOR),
     });
     assert.equal(donation?.givbackFactor, expectedGivbackFactor);
     assert.equal(donation?.powerRound, roundNumber);
-    assert.isAtLeast(donation?.projectRank || 0, 1);
📝 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 expectedGivbackFactor = calculateGivbackFactorByRank({
projectRank: donation?.projectRank,
bottomRank: donation?.bottomRankInRound || 1,
minGivFactor: Number(process.env.GIVBACK_MIN_FACTOR),
maxGivFactor: Number(process.env.GIVBACK_MAX_FACTOR),
});
assert.equal(donation?.givbackFactor, expectedGivbackFactor);
assert.equal(donation?.powerRound, roundNumber);
assert.equal(donation?.projectRank, 1);
assert.isAtLeast(donation?.projectRank || 0, 1);
assert.equal(donation?.projectRank, 1);
assert.equal(donation?.bottomRankInRound, 2);
const expectedGivbackFactor = calculateGivbackFactorByRank({
projectRank: 1,
bottomRank: 2,
minGivFactor: Number(process.env.GIVBACK_MIN_FACTOR),
maxGivFactor: Number(process.env.GIVBACK_MAX_FACTOR),
});
assert.equal(donation?.givbackFactor, expectedGivbackFactor);
assert.equal(donation?.powerRound, roundNumber);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/resolvers/donationResolver.test.ts` around lines 2251 - 2259, Current
test wrongly derives expectedGivbackFactor from the saved donation itself (using
donation.projectRank and donation.bottomRankInRound), making the assertion
self-fulfilling; instead, use the known fixture values (the 80/20 snapshot:
projectRank = 1 and bottomRankInRound = 2) to compute the expected factor and
assert against that. Update the test to assert donation.projectRank === 1 and
donation.bottomRankInRound === 2 (or use the fixture variable names if present),
then call calculateGivbackFactorByRank with those literal/fixture values and
process.env.GIVBACK_MIN_FACTOR/MAX to compute expectedGivbackFactor and compare
it to donation.givbackFactor; keep the existing assertion for powerRound
(roundNumber) unchanged.

Comment on lines 6571 to +6586
const allocatedGivbacks = result.data.data?.allocatedGivbacks;
assert.isNotNull(allocatedGivbacks);
assert.equal(

const expectedSum =
valueUsd1 * givbackFactor1 +
valueUsd2 * givbackFactor2 +
valueUsd3 * givbackFactor3;

// Result should include at least these donations (may include others from DB)
assert.isAtLeast(
allocatedGivbacks.usdValueSentAmountInPowerRound,
valueUsd1 * givbackFactor1 + valueUsd2 * givbackFactor2,
expectedSum,
);

// Cleanup
await Donation.delete([donation1.id, donation2.id, donation3.id]);

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

Make this integration assertion exact, not lower-bounded.

assert.isAtLeast(...) lets resolver over-counting slip through whenever the current window already contains other qualifying donations. This test should verify the exact delta introduced by the three rows it creates, otherwise a broken aggregation can still pass.

Suggested pattern
+    const baselineResult = await axios.post(
+      graphqlUrl,
+      {
+        query: allocatedGivbacksQuery,
+        variables: { refreshCache: true },
+      },
+      {},
+    );
+    const baseline =
+      baselineResult.data.data?.allocatedGivbacks.usdValueSentAmountInPowerRound;

     const result = await axios.post(
       graphqlUrl,
       {
         query: allocatedGivbacksQuery,
@@
-    assert.isAtLeast(
-      allocatedGivbacks.usdValueSentAmountInPowerRound,
-      expectedSum,
-    );
+    assert.equal(
+      allocatedGivbacks.usdValueSentAmountInPowerRound - baseline,
+      expectedSum,
+    );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/resolvers/donationResolver.test.ts` around lines 6571 - 6586, The test
currently uses assert.isAtLeast on
allocatedGivbacks.usdValueSentAmountInPowerRound which allows over-counting to
pass; change it to assert the exact delta introduced by donation1/2/3 by (1)
fetching the current allocatedGivbacks aggregate before inserting the three test
donations (store e.g. priorAllocated =
allocatedGivbacks.usdValueSentAmountInPowerRound), (2) insert donation1,
donation2, donation3 as done, (3) fetch allocatedGivbacks again into
result.data.data?.allocatedGivbacks and compute expectedSum as already defined,
then assert that newAllocated === priorAllocated + expectedSum (use strict
equality, not isAtLeast), and finally run the existing cleanup
Donation.delete([donation1.id, donation2.id, donation3.id]).

Comment on lines +367 to +389
function collectValueTransfersFromCallTrace(
node: any,
transfers: Array<{ from: string; to: string; value: string }>,
) {
if (!node || typeof node !== 'object') return;

const from = typeof node.from === 'string' ? node.from.toLowerCase() : '';
const to = typeof node.to === 'string' ? node.to.toLowerCase() : '';
const value = parseTraceValueToWei(node.value);

if (from && to && value.gt(0)) {
transfers.push({
from,
to,
value: value.toString(),
});
}

if (Array.isArray(node.calls)) {
for (const call of node.calls) {
collectValueTransfersFromCallTrace(call, transfers);
}
}

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

Skip reverted trace frames when deriving internal transfers.

Both trace formats can include errored subcalls. Right now any frame with a positive value is treated as a real transfer, so a reverted internal call can still be picked as the AA donation transfer and return the wrong from/to/amount.

💡 Minimal hardening
 function collectValueTransfersFromCallTrace(
   node: any,
   transfers: Array<{ from: string; to: string; value: string }>,
 ) {
   if (!node || typeof node !== 'object') return;
+  if (node.error) return;

   const from = typeof node.from === 'string' ? node.from.toLowerCase() : '';
   const to = typeof node.to === 'string' ? node.to.toLowerCase() : '';
   const value = parseTraceValueToWei(node.value);
@@
     for (const call of calls) {
+      if (call?.error) continue;
       const from =
         typeof call?.action?.from === 'string'
           ? call.action.from.toLowerCase()
           : '';

Also applies to: 437-453

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

In `@src/services/chains/evm/transactionService.ts` around lines 367 - 389,
collectValueTransfersFromCallTrace currently treats any frame with a positive
value as a transfer and can include reverted/errored subcalls; update the
function to first detect and skip errored/reverted frames (e.g., check
node.error, node.reverted or node.revertReason, node.failed, or node.status ===
0) with an early return before parsing value/adding a transfer, and apply the
identical guard to the other similar routine handling call traces (the similar
block around lines 437-453) so reverted internal calls are never counted as
value transfers.

Comment on lines +540 to +552
txTrace(
params,
'getInternalTransactionsByTxHash:explorer_failed_fallback',
{
error: e?.message,
},
);
const internalTxsFromTrace =
await getInternalTransactionsByTxHashFromRpcTrace(params);
txTrace(params, 'getInternalTransactionsByTxHash:fallback_done', {
internalTxsCount: internalTxsFromTrace.length,
});
return [];
return internalTxsFromTrace;

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

Wire the new RPC trace fallback into the empty-result path too.

This only runs when the explorer request throws. When the explorer returns status: '0' with an empty/"no transactions found" payload, getInternalTransactionsByTxHash() returns [] earlier and the AA callers fall back to the older getInternalTransfersFromTrace() helper, which still lacks the trace_transaction retry. Providers that expose trace_transaction but not debug_traceTransaction will still produce false negatives.

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

In `@src/services/chains/evm/transactionService.ts` around lines 540 - 552, The
empty-result branch in getInternalTransactionsByTxHash currently returns [] when
the explorer responds with status: '0' or "no transactions found" without
attempting the new RPC trace fallback; update getInternalTransactionsByTxHash so
that in the empty-result / status:'0' path it calls
getInternalTransactionsByTxHashFromRpcTrace (same as the explorer-exception
path), logs via txTrace (e.g.,
'getInternalTransactionsByTxHash:explorer_empty_fallback' and
'...:fallback_done' with internalTxsCount), and only return [] after that
fallback also yields no results; this ensures callers (and
getInternalTransfersFromTrace consumers) benefit from trace_transaction
retry-capable RPCs.

Comment on lines +13 to +27
async function getTransactionInfoOrSkipWhenRpcIsUnavailable(
context: Mocha.Context,
input: Parameters<typeof getTransactionInfoFromNetwork>[0],
) {
try {
return await getTransactionInfoFromNetwork(input);
} catch (error) {
if ((error as Error).message === errorMessages.TRANSACTION_NOT_FOUND) {
// These assertions hit live Optimism RPCs and can intermittently lose
// historical receipts even though the transactions are valid.
context.skip();
}
throw error;
}
}

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

Don't skip on TRANSACTION_NOT_FOUND from the code under test.

TRANSACTION_NOT_FOUND is also the normal product error when lookup/resolution breaks, so these tests now go green for real regressions as well as flaky RPCs. Please only skip on transport-level RPC failures (timeout/5xx/connection errors), or mock the chain calls instead.

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

In `@src/services/chains/index.test.ts` around lines 13 - 27, The test helper
getTransactionInfoOrSkipWhenRpcIsUnavailable should not skip on
application-level TRANSACTION_NOT_FOUND from getTransactionInfoFromNetwork;
update the catch in getTransactionInfoOrSkipWhenRpcIsUnavailable to only call
context.skip() for transport-level RPC failures (e.g., network/connection
errors, request timeouts, or HTTP 5xx responses) by checking the thrown error's
class/properties (for example network error types, status/code fields or
messages like "ECONN", "ETIMEDOUT", or HTTP status >=500) and rethrow otherwise;
alternatively replace live chain calls with mocks for
getTransactionInfoFromNetwork to avoid flaky RPCs.

Comment on lines +15 to +17
// Keep configured bounds stable even if env values are swapped.
const minFactor = Math.min(minGivFactor, maxGivFactor);
const maxFactor = Math.max(minGivFactor, maxGivFactor);

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

Sanitize non-finite factor bounds before clamping.

Math.min()/Math.max() return NaN when either input is NaN, so this exported helper can still return NaN if a caller passes env-derived values directly. calculateGivbackFactor() protects its own call site, but calculateGivbackFactorByRank() is now public and should be safe on its own.

Suggested fix
-  const minFactor = Math.min(minGivFactor, maxGivFactor);
-  const maxFactor = Math.max(minGivFactor, maxGivFactor);
+  const safeMinGivFactor = Number.isFinite(minGivFactor) ? minGivFactor : 0;
+  const safeMaxGivFactor = Number.isFinite(maxGivFactor)
+    ? maxGivFactor
+    : safeMinGivFactor;
+
+  const minFactor = Math.min(safeMinGivFactor, safeMaxGivFactor);
+  const maxFactor = Math.max(safeMinGivFactor, safeMaxGivFactor);
📝 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
// Keep configured bounds stable even if env values are swapped.
const minFactor = Math.min(minGivFactor, maxGivFactor);
const maxFactor = Math.max(minGivFactor, maxGivFactor);
// Keep configured bounds stable even if env values are swapped.
const safeMinGivFactor = Number.isFinite(minGivFactor) ? minGivFactor : 0;
const safeMaxGivFactor = Number.isFinite(maxGivFactor)
? maxGivFactor
: safeMinGivFactor;
const minFactor = Math.min(safeMinGivFactor, safeMaxGivFactor);
const maxFactor = Math.max(safeMinGivFactor, safeMaxGivFactor);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/givbackService.ts` around lines 15 - 17, The current
minFactor/maxFactor computation can produce NaN if either minGivFactor or
maxGivFactor is non-finite; before calling Math.min/Math.max, sanitize inputs
(Number.isFinite) and coerce non-finite values to safe fallbacks (e.g., if one
is finite use that value for both, or use explicit defaults), then compute
minFactor = Math.min(sanitizedMin, sanitizedMax) and maxFactor =
Math.max(sanitizedMin, sanitizedMax). Update the helper that computes these
bounds (the code that sets minFactor/maxFactor) so it always returns finite
numbers, and ensure calculateGivbackFactorByRank() uses this sanitized result
(and that calculateGivbackFactor() behavior remains unchanged).

Comment on lines +73 to +74
bottomRankInRound:
Number.isFinite(bottomRank) && bottomRank > 0 ? bottomRank : 1,

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

Normalize bottomRankInRound from the parsed rank.

The factor calculation uses Number(bottomRank), but the returned bottomRankInRound still checks the raw value. If getBottomGivbackRank() ever comes back as a numeric string, the factor is computed with that parsed rank while the stored bottomRankInRound collapses to 1. That leaves donation metadata inconsistent with the applied factor.

Suggested fix
+  const normalizedBottomRank = Number(bottomRank);
+
   return {
     givbackFactor,
     projectRank: projectGivbackRankView?.powerRank,
     bottomRankInRound:
-      Number.isFinite(bottomRank) && bottomRank > 0 ? bottomRank : 1,
+      Number.isFinite(normalizedBottomRank) && normalizedBottomRank > 0
+        ? normalizedBottomRank
+        : 1,
     powerRound: powerRound?.round as number,
   };
📝 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
bottomRankInRound:
Number.isFinite(bottomRank) && bottomRank > 0 ? bottomRank : 1,
const normalizedBottomRank = Number(bottomRank);
return {
givbackFactor,
projectRank: projectGivbackRankView?.powerRank,
bottomRankInRound:
Number.isFinite(normalizedBottomRank) && normalizedBottomRank > 0
? normalizedBottomRank
: 1,
powerRound: powerRound?.round as number,
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/givbackService.ts` around lines 73 - 74, Normalize the parsed
rank coming from getBottomGivbackRank() so both the factor and the returned
value use the same numeric value: parse the result once (e.g., const
bottomRankNum = Number(bottomRank)), validate Number.isFinite(bottomRankNum) &&
bottomRankNum > 0, use bottomRankNum when computing the factor and set
bottomRankInRound to bottomRankNum (falling back to 1 only if invalid). Update
references in the function that currently use Number(bottomRank) and the
bottomRankInRound ternary to use this normalized bottomRankNum.

@ae2079 ae2079 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

thanks @RamRamez

@RamRamez
RamRamez merged commit 424ffe7 into staging Apr 28, 2026
38 of 39 checks passed
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.

4 participants