diff --git a/src/repositories/donationRepository.ts b/src/repositories/donationRepository.ts index 8daf0ba71..c920f5673 100644 --- a/src/repositories/donationRepository.ts +++ b/src/repositories/donationRepository.ts @@ -8,7 +8,6 @@ import { QfRound } from '../entities/qfRound'; import { ChainType } from '../types/network'; import { ORGANIZATION_LABELS } from '../entities/organization'; import { AppDataSource } from '../orm'; -import { getPowerRound } from './powerRoundRepository'; export const exportClusterMatchingDonationsFormat = async ( qfRoundId: number, @@ -584,45 +583,94 @@ export const getRecentDonations = async (take: number): Promise => { .getMany(); }; -export const getSumOfGivbackEligibleDonationsForSpecificRound = async (params: { - powerRound?: number; -}): Promise => { - // This function calculates the total USD value of all donations that are eligible for Givback in a specific PowerRound - - try { - // If no powerRound is passed, get the latest one from the PowerRound table - const powerRound = params.powerRound || (await getPowerRound())?.round; - if (!powerRound) { - throw new Error('No powerRound found in the database.'); +export const getSumOfGivbackEligibleDonationsForSpecificRound = + async (_params: { powerRound?: number }): Promise => { + // This function calculates the total USD value of all donations eligible for GIVback + // Uses the operational window (16:00 UTC offset) to match givbacks service + // Groups recurring donations and applies minimum thresholds + + try { + // Calculate date range for current month with 16:00 UTC offset (matches givbacks service) + const now = new Date(); + const year = now.getUTCFullYear(); + const month = now.getUTCMonth(); + + // Start: 1st of current month at 16:00 UTC + const startDate = new Date(Date.UTC(year, month, 1, 16, 0, 0)); + // 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)); + + const startDateStr = startDate + .toISOString() + .replace('T', ' ') + .replace('Z', ''); + const endDateStr = endDate + .toISOString() + .replace('T', ' ') + .replace('Z', ''); + + // Minimum threshold: $4 for most projects, $0.05 for community project + const minEligibleValueUsd = 4; + const communityMinValueUsd = 0.05; + const communityProjectSlug = 'the-giveth-community-of-makers'; + + // Execute query with: + // 1. Date range (operational window) + // 2. Group recurring donations by parent ID + // 3. Apply minimum threshold on grouped totals + // 4. Purple list exclusion + const result = await AppDataSource.getDataSource().query( + ` + WITH grouped_donations AS ( + SELECT + COALESCE("recurringDonationId"::text, "id"::text) AS group_id, + SUM("valueUsd") AS total_usd, + SUM("valueUsd" * "givbackFactor") AS total_usd_with_factor, + MAX("fromWalletAddress") AS from_address, + MAX("projectId") AS project_id + FROM "donation" + WHERE "status" = 'verified' + AND "isProjectGivbackEligible" = true + AND "createdAt" > $1 + AND "createdAt" < $2 + GROUP BY COALESCE("recurringDonationId"::text, "id"::text) + ) + SELECT COALESCE(SUM(gd.total_usd_with_factor), 0) AS "totalUsdWithGivbackFactor" + FROM grouped_donations gd + JOIN "project" p ON p.id = gd.project_id + WHERE + ( + (p.slug = $3 AND gd.total_usd > $4) + OR (p.slug != $3 AND gd.total_usd >= $5) + ) + AND NOT EXISTS ( + SELECT 1 + FROM "project_address" pa + INNER JOIN "project" proj ON proj.id = pa."projectId" + WHERE LOWER(pa.address) = LOWER(gd.from_address) + AND pa."isRecipient" = true + AND proj.verified = true + AND proj."statusId" = 5 + ); + `, + [ + startDateStr, + endDateStr, + communityProjectSlug, + communityMinValueUsd, + minEligibleValueUsd, + ], + ); + + return result?.[0]?.totalUsdWithGivbackFactor ?? 0; + } catch (e) { + logger.error( + 'getSumOfGivbackEligibleDonationsForSpecificRound() error', + e, + ); + return 0; } - - // Execute the main raw SQL query with the powerRound - const result = await AppDataSource.getDataSource().query( - ` - SELECT - SUM("donation"."valueUsd" * "donation"."givbackFactor") AS "totalUsdWithGivbackFactor" - FROM "donation" - WHERE "donation"."status" = 'verified' - AND "donation"."isProjectGivbackEligible" = true - AND "donation"."powerRound" = $1 - AND NOT EXISTS ( - SELECT 1 - FROM "project_address" "pa" - INNER JOIN "project" "p" ON "p"."id" = "pa"."projectId" - WHERE "pa"."address" = "donation"."fromWalletAddress" - AND "pa"."isRecipient" = true - AND "p"."verified" = true - ); - `, - [powerRound], - ); - - return result?.[0]?.totalUsdWithGivbackFactor ?? 0; - } catch (e) { - logger.error('getSumOfGivbackEligibleDonationsForSpecificRound() error', e); - return 0; - } -}; + }; export const getPendingDonationsIds = (): Promise<{ id: number }[]> => { const date = moment() diff --git a/src/server/cors.ts b/src/server/cors.ts index 4d8465802..dca1f8932 100644 --- a/src/server/cors.ts +++ b/src/server/cors.ts @@ -3,7 +3,7 @@ import { logger } from '../utils/logger'; // Hostnames that are always allowed, regardless of env. // NOTE: CORS check below also allows subdomains of any entry here. -const staticWhitelistHostnames: string[] = ['base.giveth.io']; +const staticWhitelistHostnames: string[] = ['base.giveth.io', 'qf.giveth.io']; export const whitelistHostnames: string[] = Array.from( new Set([ diff --git a/src/services/chains/evm/transactionService.ts b/src/services/chains/evm/transactionService.ts index 67f6f8b9d..ecb7f0d30 100644 --- a/src/services/chains/evm/transactionService.ts +++ b/src/services/chains/evm/transactionService.ts @@ -349,6 +349,130 @@ const closeTo = (a: number, b: number, delta = 0.001) => { return Math.abs(1 - a / b) < delta; }; +function parseTraceValueToWei(value: unknown) { + try { + if ( + value === undefined || + value === null || + (typeof value === 'string' && value.trim() === '') + ) { + return ethers.BigNumber.from(0); + } + return ethers.BigNumber.from(value); + } catch { + return ethers.BigNumber.from(0); + } +} + +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); + } + } +} + +async function getInternalTransactionsByTxHashFromRpcTrace(params: { + networkId: number; + txHash: string; +}) { + const { networkId, txHash } = params; + const provider = getProvider(networkId); + + try { + logger.debug( + 'NODE RPC request count - getInternalTransactionsByTxHashFromRpcTrace provider.send debug_traceTransaction txHash:', + txHash, + ); + const debugTraceResult = await provider.send('debug_traceTransaction', [ + txHash, + { tracer: 'callTracer' }, + ]); + const transfers: Array<{ from: string; to: string; value: string }> = []; + collectValueTransfersFromCallTrace(debugTraceResult, transfers); + txTrace( + params, + 'getInternalTransactionsByTxHashFromRpcTrace:debug_success', + { + transfersCount: transfers.length, + }, + ); + return transfers; + } catch (debugError) { + txTrace( + params, + 'getInternalTransactionsByTxHashFromRpcTrace:debug_failed', + { + error: debugError?.message, + }, + ); + } + + try { + logger.debug( + 'NODE RPC request count - getInternalTransactionsByTxHashFromRpcTrace provider.send trace_transaction txHash:', + txHash, + ); + const traceResult = await provider.send('trace_transaction', [txHash]); + const transfers: Array<{ from: string; to: string; value: string }> = []; + const calls = Array.isArray(traceResult) ? traceResult : []; + + for (const call of calls) { + const from = + typeof call?.action?.from === 'string' + ? call.action.from.toLowerCase() + : ''; + const to = + typeof call?.action?.to === 'string' + ? call.action.to.toLowerCase() + : ''; + const value = parseTraceValueToWei(call?.action?.value); + if (from && to && value.gt(0)) { + transfers.push({ + from, + to, + value: value.toString(), + }); + } + } + + txTrace( + params, + 'getInternalTransactionsByTxHashFromRpcTrace:trace_success', + { + transfersCount: transfers.length, + }, + ); + return transfers; + } catch (traceError) { + txTrace( + params, + 'getInternalTransactionsByTxHashFromRpcTrace:trace_failed', + { + error: traceError?.message, + }, + ); + return []; + } +} + async function getInternalTransactionsByTxHash(params: { networkId: number; txHash: string; @@ -413,10 +537,19 @@ async function getInternalTransactionsByTxHash(params: { networkId: params.networkId, txHash: params.txHash, }); - txTrace(params, 'getInternalTransactionsByTxHash:catch_return_empty', { - error: e?.message, + 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; } }