diff --git a/packages/processors/src/allo/handlers/poolCreated.handler.ts b/packages/processors/src/allo/handlers/poolCreated.handler.ts index e46f915..2661237 100644 --- a/packages/processors/src/allo/handlers/poolCreated.handler.ts +++ b/packages/processors/src/allo/handlers/poolCreated.handler.ts @@ -1,4 +1,4 @@ -import { getAddress, parseUnits, zeroAddress } from "viem"; +import { getAddress, zeroAddress } from "viem"; import type { Changeset, NewRound, PendingRoundRole } from "@grants-stack-indexer/repository"; import type { ChainId, ProtocolEvent, Token } from "@grants-stack-indexer/shared"; @@ -7,10 +7,10 @@ import { getToken } from "@grants-stack-indexer/shared/dist/src/internal.js"; import type { IEventHandler, ProcessorDependencies, StrategyTimings } from "../../internal.js"; import { getRoundRoles } from "../../helpers/roles.js"; -import { extractStrategyFromId, getStrategyTimings } from "../../helpers/strategy.js"; import { calculateAmountInUsd } from "../../helpers/tokenMath.js"; import { TokenPriceNotFoundError } from "../../internal.js"; import { RoundMetadataSchema } from "../../schemas/index.js"; +import { StrategyHandlerFactory } from "../../strategy/strategyHandler.factory.js"; type Dependencies = Pick< ProcessorDependencies, @@ -61,7 +61,13 @@ export class PoolCreatedHandler implements IEventHandler<"Allo", "PoolCreated"> ? zeroAddress : checksummedTokenAddress; - const strategy = extractStrategyFromId(strategyId); + const strategyHandler = StrategyHandlerFactory.createHandler( + this.chainId, + this.dependencies as ProcessorDependencies, + strategyId, + ); + + // const strategy = extractStrategyFromId(strategyId); const token = getToken(this.chainId, matchTokenAddress); @@ -72,26 +78,17 @@ export class PoolCreatedHandler implements IEventHandler<"Allo", "PoolCreated"> donationsEndTime: null, }; - let matchAmount = 0n; - let matchAmountInUsd = "0"; - - if (strategy) { - strategyTimings = await getStrategyTimings(evmProvider, strategy, strategyAddress); - - //TODO: when creating strategy handlers, should this be moved there? - if ( - strategy.name === "allov2.DonationVotingMerkleDistributionDirectTransferStrategy" && - parsedRoundMetadata.success && - token - ) { - matchAmount = parseUnits( - parsedRoundMetadata.data.quadraticFundingConfig.matchingFundsAvailable.toString(), - token.decimals, - ); + let matchAmount = { + matchAmount: 0n, + matchAmountInUsd: "0", + }; - matchAmountInUsd = await this.getTokenAmountInUsd( + if (strategyHandler) { + strategyTimings = await strategyHandler.fetchStrategyTimings(strategyAddress); + if (parsedRoundMetadata.success && token) { + matchAmount = await strategyHandler.fetchMatchAmount( + Number(parsedRoundMetadata.data.quadraticFundingConfig.matchingFundsAvailable), token, - matchAmount, this.event.blockTimestamp, ); } @@ -120,8 +117,8 @@ export class PoolCreatedHandler implements IEventHandler<"Allo", "PoolCreated"> totalAmountDonatedInUsd: "0", uniqueDonorsCount: 0, matchTokenAddress, - matchAmount, - matchAmountInUsd, + matchAmount: matchAmount.matchAmount, + matchAmountInUsd: matchAmount.matchAmountInUsd, fundedAmount, fundedAmountInUsd, applicationMetadataCid: metadataPointer, @@ -132,7 +129,7 @@ export class PoolCreatedHandler implements IEventHandler<"Allo", "PoolCreated"> ...roundRoles, strategyAddress, strategyId, - strategyName: strategy?.name ?? "", + strategyName: strategyHandler?.name ?? "", createdByAddress: getAddress(createdBy), createdAtBlock: BigInt(this.event.blockNumber), updatedAtBlock: BigInt(this.event.blockNumber), diff --git a/packages/processors/src/external.ts b/packages/processors/src/external.ts index a898320..02e1abc 100644 --- a/packages/processors/src/external.ts +++ b/packages/processors/src/external.ts @@ -1,3 +1,5 @@ // Add your external exports here export { StrategyProcessor, AlloProcessor } from "./internal.js"; export type { IProcessor } from "./internal.js"; + +export { existsHandler } from "./internal.js"; diff --git a/packages/processors/src/helpers/strategy.ts b/packages/processors/src/helpers/strategy.ts index a71937c..2acd549 100644 --- a/packages/processors/src/helpers/strategy.ts +++ b/packages/processors/src/helpers/strategy.ts @@ -1,231 +1,11 @@ import type { EvmProvider } from "@grants-stack-indexer/chain-providers"; -import type { Address, Branded } from "@grants-stack-indexer/shared"; +import type { Address } from "@grants-stack-indexer/shared"; import DirectGrantsLiteStrategy from "../abis/allo-v2/v1/DirectGrantsLiteStrategy.js"; -import DonationVotingMerkleDistributionDirectTransferStrategy from "../abis/allo-v2/v1/DonationVotingMerkleDistributionDirectTransferStrategy.js"; import { StrategyTimings } from "../internal.js"; import { getDateFromTimestamp } from "./utils.js"; -type SanitizedStrategyId = Branded; -type Strategy = { - id: SanitizedStrategyId; - name: string | null; - // TODO: check if groups are required - groups: string[]; -}; - -//TODO: refactor this into a mapping in Shared package from ID to the corresponding handler class -/* - * Extracts the strategy from the ID. - * @param _id - The ID of the strategy. - * @returns The strategy. - */ -export function extractStrategyFromId(_id: Address): Strategy | undefined { - const id = _id.toLowerCase(); - /* eslint-disable no-fallthrough */ - switch (id) { - // SQFSuperfluidv1 - case "0xf8a14294e80ff012e54157ec9d1b2827421f1e7f6bde38c06730b1c031b3f935": - return { - id: id as SanitizedStrategyId, - name: "allov2.SQFSuperFluidStrategy", - groups: ["allov2.SQFSuperFluidStrategy"], - }; - - // MicroGrantsv1 - case "0x697f0592ebd05466d2d24454477e11d69c475d7a7c4134f15ddc1ea9811bb16f": - return { - id: id as SanitizedStrategyId, - name: "allov2.MicroGrantsStrategy", - groups: ["allov2.MicroGrantsStrategy", "allov2.MicroGrantsCommon"], - }; - - // MicroGrantsGovv1 - case "0x741ac1e2f387d83f219f6b5349d35ec34902cf94019d117335e0045d2e0ed912": - return { - id: id as SanitizedStrategyId, - name: "allov2.MicroGrantsGovStrategy", - groups: ["allov2.MicroGrantsGovStrategy", "allov2.MicroGrantsCommon"], - }; - - // MicroGrantsHatsv1 - case "0x5aa24dcfcd55a1e059a172e987b3456736b4856c71e57aaf52e9a965897318dd": - return { - id: id as SanitizedStrategyId, - name: "allov2.MicroGrantsHatsStrategy", - groups: ["allov2.MicroGrantsHatsStrategy", "allov2.MicroGrantsCommon"], - }; - - // RFPSimpleStrategyv1.0 - case "0x0d459e12d9e91d2b2a8fa12be8c7eb2b4f1c35e74573990c34b436613bc2350f": - return { - id: id as SanitizedStrategyId, - name: "allov2.RFPSimpleStrategy", - groups: ["allov2.RFPSimpleStrategy"], - }; - - // RFPCommitteeStrategyv1.0 - case "0x7d143166a83c6a8a303ae32a6ccd287e48d79818f5d15d89e185391199909803": - return { - id: id as SanitizedStrategyId, - name: "allov2.RFPCommitteeStrategy", - groups: ["allov2.RFPCommitteeStrategy"], - }; - - // QVSimpleStrategyv1.0 - case "0x22d006e191d6dc5ff1a25bb0733f47f64a9c34860b6703df88dea7cb3987b4c3": - return { - id: id as SanitizedStrategyId, - name: "allov2.QVSimpleStrategy", - groups: ["allov2.QVSimpleStrategy"], - }; - - // DonationVotingMerkleDistributionDirectTransferStrategyv1.0 - case "0x6f9291df02b2664139cec5703c124e4ebce32879c74b6297faa1468aa5ff9ebf": - // DonationVotingMerkleDistributionDirectTransferStrategyv1.1 - case "0x2f46bf157821dc41daa51479e94783bb0c8699eac63bf75ec450508ab03867ce": - // DonationVotingMerkleDistributionDirectTransferStrategyv2.0 - case "0x2f0250d534b2d59b8b5cfa5eb0d0848a59ccbf5de2eaf72d2ba4bfe73dce7c6b": - // DonationVotingMerkleDistributionDirectTransferStrategyv2.1 - case "0x9fa6890423649187b1f0e8bf4265f0305ce99523c3d11aa36b35a54617bb0ec0": - return { - id: id as SanitizedStrategyId, - name: "allov2.DonationVotingMerkleDistributionDirectTransferStrategy", - groups: ["allov2.DonationVotingMerkleDistributionDirectTransferStrategy"], - }; - - // DonationVotingMerkleDistributionVaultStrategyv1.0 - case "0x7e75375f0a7cd9f7ea159c8b065976e4f764f9dcef1edf692f31dd1842f70c87": - // DonationVotingMerkleDistributionVaultStrategyv1.1 - case "0x093072375737c0e8872fef36808849aeba7f865e182d495f2b98308115c9ef13": - return { - id: id as SanitizedStrategyId, - name: "allov2.DonationVotingMerkleDistributionVaultStrategy", - groups: ["allov2.DonationVotingMerkleDistributionVaultStrategy"], - }; - - // DirectGrantsSimpleStrategyv1.1 - case "0x263cb916541b6fc1fb5543a244829ccdba75264b097726e6ecc3c3cfce824bf5": - // DirectGrantsSimpleStrategyv2.1 - case "0x53fb9d3bce0956ca2db5bb1441f5ca23050cb1973b33789e04a5978acfd9ca93": - return { - id: id as SanitizedStrategyId, - name: "allov2.DirectGrantsSimpleStrategy", - groups: ["allov2.DirectGrantsSimpleStrategy"], - }; - - // DirectGrantsLiteStrategyv1.0 - case "0x103732a8e473467a510d4128ee11065262bdd978f0d9dad89ba68f2c56127e27": - return { - id: id as SanitizedStrategyId, - name: "allov2.DirectGrantsLiteStrategy", - groups: ["allov2.DirectGrantsLiteStrategy"], - }; - - // EasyRPGFStrategy1.0 - case "0x662f5a0d3ea7e9b6ed1b351a9d96ac636a3c3ed727390aeff4ec931ae760d5ae": - return { - id: id as SanitizedStrategyId, - name: "allov2.EasyRPGFStrategy", - groups: ["allov2.EasyRPGFStrategy"], - }; - - // DirectAllocationStrategyv1.1 - case "0x4cd0051913234cdd7d165b208851240d334786d6e5afbb4d0eec203515a9c6f3": - return { - id: id as SanitizedStrategyId, - name: "allov2.DirectAllocationStrategy", - groups: ["allov2.DirectAllocationStrategy"], - }; - } - - return undefined; -} - -//TODO: refactor this into the StrategyHandler when implemented -// see if we can use a common interface or abstract class for all strategies -// so we don't have to do this switch statement -// most of the strategies don't need to fetch anything and just return null for all the times -export const getStrategyTimings = async ( - evmProvider: EvmProvider, - strategy: Strategy, - strategyAddress: Address, -): Promise => { - switch (strategy.name) { - case "allov2.DonationVotingMerkleDistributionDirectTransferStrategy": - return getDonationVotingMerkleDistributionDirectTransferStrategyTimings( - evmProvider, - strategyAddress, - ); - case "allov2.DirectGrantsSimpleStrategy": - case "allov2.DirectGrantsLiteStrategy": - return getDirectGrantsStrategyTimings(evmProvider, strategyAddress); - default: - return { - applicationsStartTime: null, - applicationsEndTime: null, - donationsStartTime: null, - donationsEndTime: null, - }; - } -}; - -/** - * Gets the strategy data for the DonationVotingMerkleDistributionDirectTransferStrategy - * @param evmProvider - The evm provider - * @param strategyId - The address of the strategy - * @returns The strategy data - */ -export const getDonationVotingMerkleDistributionDirectTransferStrategyTimings = async ( - evmProvider: EvmProvider, - strategyId: Address, -): Promise => { - let results: [bigint, bigint, bigint, bigint] = [0n, 0n, 0n, 0n]; - - const contractCalls = [ - { - abi: DonationVotingMerkleDistributionDirectTransferStrategy, - functionName: "registrationStartTime", - address: strategyId, - }, - { - abi: DonationVotingMerkleDistributionDirectTransferStrategy, - functionName: "registrationEndTime", - address: strategyId, - }, - { - abi: DonationVotingMerkleDistributionDirectTransferStrategy, - functionName: "allocationStartTime", - address: strategyId, - }, - { - abi: DonationVotingMerkleDistributionDirectTransferStrategy, - functionName: "allocationEndTime", - address: strategyId, - }, - ] as const; - - if (evmProvider.getMulticall3Address()) { - results = await evmProvider.multicall({ - contracts: contractCalls, - allowFailure: false, - }); - } else { - results = (await Promise.all( - contractCalls.map((call) => - evmProvider.readContract(call.address, call.abi, call.functionName), - ), - )) as [bigint, bigint, bigint, bigint]; - } - - return { - applicationsStartTime: getDateFromTimestamp(results[0]), - applicationsEndTime: getDateFromTimestamp(results[1]), - donationsStartTime: getDateFromTimestamp(results[2]), - donationsEndTime: getDateFromTimestamp(results[3]), - }; -}; - +//TODO: move this to the DirectGrantsStrategyHandler when implemented /** * Gets the strategy data for the DirectGrantsStrategy * @param evmProvider - The evm provider diff --git a/packages/processors/src/interfaces/strategyHandler.interface.ts b/packages/processors/src/interfaces/strategyHandler.interface.ts index d5c2ec4..5e0c047 100644 --- a/packages/processors/src/interfaces/strategyHandler.interface.ts +++ b/packages/processors/src/interfaces/strategyHandler.interface.ts @@ -1,5 +1,12 @@ -import { Changeset } from "@grants-stack-indexer/repository"; -import { ContractToEventName, ProtocolEvent } from "@grants-stack-indexer/shared"; +import type { Changeset } from "@grants-stack-indexer/repository"; +import type { + Address, + ContractToEventName, + ProtocolEvent, + Token, +} from "@grants-stack-indexer/shared"; + +import type { StrategyTimings } from "../internal.js"; /** * Interface for an event handler. @@ -7,9 +14,37 @@ import { ContractToEventName, ProtocolEvent } from "@grants-stack-indexer/shared * @template E - The event name. */ export interface IStrategyHandler> { + /** + * The name of the strategy. + */ + name: string; + /** * Handles the event. * @returns A promise that resolves to an array of changesets. */ handle(event: ProtocolEvent<"Strategy", E>): Promise; + + /** + * Fetch the strategy timings data from the strategy contract + * @param strategyAddress - The address of the strategy + * @returns The strategy timings + */ + fetchStrategyTimings(strategyAddress: Address): Promise; + + /** + * Fetch the match amount for a strategy + * @param matchingFundsAvailable - The matching funds available + * @param token - The token + * @param blockTimestamp - The block timestamp + * @returns The match amount and match amount in USD + */ + fetchMatchAmount( + matchingFundsAvailable: number, + token: Token, + blockTimestamp: number, + ): Promise<{ + matchAmount: bigint; + matchAmountInUsd: string; + }>; } diff --git a/packages/processors/src/internal.ts b/packages/processors/src/internal.ts index 471dcf2..fa8511a 100644 --- a/packages/processors/src/internal.ts +++ b/packages/processors/src/internal.ts @@ -1,7 +1,15 @@ -// Add your internal exports here +// Types and interfaces export * from "./types/index.js"; export * from "./interfaces/index.js"; + +// Exceptions export * from "./exceptions/index.js"; + +// Allo export * from "./allo/index.js"; + +// Strategy export * from "./strategy/common/index.js"; -export * from "./strategy/index.js"; +export * from "./strategy/strategyHandler.factory.js"; +export * from "./strategy/strategy.processor.js"; +export { getHandler, existsHandler } from "./strategy/mapping.js"; diff --git a/packages/processors/src/strategy/common/base.strategy.ts b/packages/processors/src/strategy/common/base.strategy.ts new file mode 100644 index 0000000..aaab7a8 --- /dev/null +++ b/packages/processors/src/strategy/common/base.strategy.ts @@ -0,0 +1,41 @@ +import { Changeset } from "@grants-stack-indexer/repository"; +import { Address, ProtocolEvent, StrategyEvent, Token } from "@grants-stack-indexer/shared"; + +import { IStrategyHandler, StrategyTimings } from "../../internal.js"; + +/** + * @abstract + * Base class for all strategy handlers. + * + * Implementations of this class should be named like `StrategyHandler`. + * + */ +export abstract class BaseStrategyHandler implements IStrategyHandler { + readonly name: string; + + constructor(name: string) { + this.name = name; + } + + async fetchStrategyTimings(_strategyAddress: Address): Promise { + return { + applicationsStartTime: null, + applicationsEndTime: null, + donationsStartTime: null, + donationsEndTime: null, + }; + } + + async fetchMatchAmount( + _matchingFundsAvailable: number, + _token: Token, + _blockTimestamp: number, + ): Promise<{ matchAmount: bigint; matchAmountInUsd: string }> { + return { + matchAmount: 0n, + matchAmountInUsd: "0", + }; + } + + abstract handle(event: ProtocolEvent<"Strategy", StrategyEvent>): Promise; +} diff --git a/packages/processors/src/strategy/common/index.ts b/packages/processors/src/strategy/common/index.ts index f1ca948..428bb60 100644 --- a/packages/processors/src/strategy/common/index.ts +++ b/packages/processors/src/strategy/common/index.ts @@ -1 +1,2 @@ export * from "./baseDistributed.handler.js"; +export * from "./base.strategy.js"; diff --git a/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.ts b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.ts index 551d339..9ba3e29 100644 --- a/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.ts +++ b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.ts @@ -1,15 +1,32 @@ +import { parseUnits } from "viem"; + import { Changeset } from "@grants-stack-indexer/repository"; -import { ChainId, ProtocolEvent, StrategyEvent } from "@grants-stack-indexer/shared"; +import { + Address, + ChainId, + ProtocolEvent, + StrategyEvent, + Token, +} from "@grants-stack-indexer/shared"; -import type { IStrategyHandler, ProcessorDependencies } from "../../internal.js"; -import { BaseDistributedHandler, UnsupportedEventException } from "../../internal.js"; +import type { ProcessorDependencies, StrategyTimings } from "../../internal.js"; +import DonationVotingMerkleDistributionDirectTransferStrategy from "../../abis/allo-v2/v1/DonationVotingMerkleDistributionDirectTransferStrategy.js"; +import { calculateAmountInUsd } from "../../helpers/tokenMath.js"; +import { getDateFromTimestamp } from "../../helpers/utils.js"; +import { TokenPriceNotFoundError, UnsupportedEventException } from "../../internal.js"; +import { BaseDistributedHandler, BaseStrategyHandler } from "../common/index.js"; import { DVMDRegisteredHandler } from "./handlers/index.js"; type Dependencies = Pick< ProcessorDependencies, - "projectRepository" | "roundRepository" | "metadataProvider" + "projectRepository" | "roundRepository" | "metadataProvider" | "evmProvider" | "pricingProvider" >; +const STRATEGY_NAME = "allov2.DonationVotingMerkleDistributionDirectTransferStrategy"; + +// sometimes coingecko returns no prices for 1 hour range, 2 hours works better +export const TIMESTAMP_DELTA_RANGE = 2 * 60 * 60 * 1000; + /** * This handler is responsible for processing events related to the * Donation Voting Merkle Distribution Direct Transfer strategy. @@ -19,11 +36,15 @@ type Dependencies = Pick< * - Distributed */ -export class DVMDDirectTransferHandler implements IStrategyHandler { +export class DVMDDirectTransferStrategyHandler extends BaseStrategyHandler { constructor( private readonly chainId: ChainId, private readonly dependencies: Dependencies, - ) {} + ) { + super(STRATEGY_NAME); + } + + /** @inheritdoc */ async handle(event: ProtocolEvent<"Strategy", StrategyEvent>): Promise { switch (event.eventName) { case "Registered": @@ -42,4 +63,89 @@ export class DVMDDirectTransferHandler implements IStrategyHandler { + const matchAmount = parseUnits(matchingFundsAvailable.toString(), token.decimals); + + const matchAmountInUsd = await this.getTokenAmountInUsd(token, matchAmount, blockTimestamp); + + return { + matchAmount, + matchAmountInUsd, + }; + } + + /** @inheritdoc */ + override async fetchStrategyTimings(strategyId: Address): Promise { + const { evmProvider } = this.dependencies; + let results: [bigint, bigint, bigint, bigint] = [0n, 0n, 0n, 0n]; + + const contractCalls = [ + { + abi: DonationVotingMerkleDistributionDirectTransferStrategy, + functionName: "registrationStartTime", + address: strategyId, + }, + { + abi: DonationVotingMerkleDistributionDirectTransferStrategy, + functionName: "registrationEndTime", + address: strategyId, + }, + { + abi: DonationVotingMerkleDistributionDirectTransferStrategy, + functionName: "allocationStartTime", + address: strategyId, + }, + { + abi: DonationVotingMerkleDistributionDirectTransferStrategy, + functionName: "allocationEndTime", + address: strategyId, + }, + ] as const; + + if (evmProvider.getMulticall3Address()) { + results = await evmProvider.multicall({ + contracts: contractCalls, + allowFailure: false, + }); + } else { + results = (await Promise.all( + contractCalls.map((call) => + evmProvider.readContract(call.address, call.abi, call.functionName), + ), + )) as [bigint, bigint, bigint, bigint]; + } + + return { + applicationsStartTime: getDateFromTimestamp(results[0]), + applicationsEndTime: getDateFromTimestamp(results[1]), + donationsStartTime: getDateFromTimestamp(results[2]), + donationsEndTime: getDateFromTimestamp(results[3]), + }; + } + + /** @inheritdoc */ + private async getTokenAmountInUsd( + token: Token, + amount: bigint, + timestamp: number, + ): Promise { + const { pricingProvider } = this.dependencies; + const tokenPrice = await pricingProvider.getTokenPrice( + token.priceSourceCode, + timestamp, + timestamp + TIMESTAMP_DELTA_RANGE, + ); + + if (!tokenPrice) { + throw new TokenPriceNotFoundError(token.address, timestamp); + } + + return calculateAmountInUsd(amount, tokenPrice.priceUsd, token.decimals); + } } diff --git a/packages/processors/src/strategy/index.ts b/packages/processors/src/strategy/index.ts index 3bc73f5..a98b82d 100644 --- a/packages/processors/src/strategy/index.ts +++ b/packages/processors/src/strategy/index.ts @@ -1 +1,5 @@ +export * from "./common/index.js"; +export * from "./strategyHandler.factory.js"; export * from "./strategy.processor.js"; +// Export mapping separately to avoid circular dependencies +export { getHandler, existsHandler } from "./mapping.js"; diff --git a/packages/processors/src/strategy/mapping.ts b/packages/processors/src/strategy/mapping.ts new file mode 100644 index 0000000..20c1c67 --- /dev/null +++ b/packages/processors/src/strategy/mapping.ts @@ -0,0 +1,33 @@ +import { Hex } from "viem"; + +import type { StrategyHandlerConstructor } from "../internal.js"; +import { DVMDDirectTransferStrategyHandler } from "./donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.js"; + +const strategyIdToHandler: Readonly> = { + "0x6f9291df02b2664139cec5703c124e4ebce32879c74b6297faa1468aa5ff9ebf": + DVMDDirectTransferStrategyHandler, // DonationVotingMerkleDistributionDirectTransferStrategyv1.0 + "0x2f46bf157821dc41daa51479e94783bb0c8699eac63bf75ec450508ab03867ce": + DVMDDirectTransferStrategyHandler, // DonationVotingMerkleDistributionDirectTransferStrategyv1.1 + "0x2f0250d534b2d59b8b5cfa5eb0d0848a59ccbf5de2eaf72d2ba4bfe73dce7c6b": + DVMDDirectTransferStrategyHandler, // DonationVotingMerkleDistributionDirectTransferStrategyv2.0 + "0x9fa6890423649187b1f0e8bf4265f0305ce99523c3d11aa36b35a54617bb0ec0": + DVMDDirectTransferStrategyHandler, // DonationVotingMerkleDistributionDirectTransferStrategyv2.1 +} as const; + +/** + * Get a handler for a given strategy ID + * @param strategyId - The strategy ID to get the handler for + * @returns The handler for the strategy ID or undefined if it doesn't exist + */ +export const getHandler = (strategyId: Hex): StrategyHandlerConstructor | undefined => { + return strategyIdToHandler[strategyId.toLowerCase()]; +}; + +/** + * Check if a handler exists for a given strategy ID + * @param strategyId - The strategy ID to check + * @returns True if a handler exists, false otherwise + */ +export const existsHandler = (strategyId: Hex): boolean => { + return strategyIdToHandler[strategyId.toLowerCase()] !== undefined; +}; diff --git a/packages/processors/src/strategy/strategy.processor.ts b/packages/processors/src/strategy/strategy.processor.ts index c082a96..3b8ea94 100644 --- a/packages/processors/src/strategy/strategy.processor.ts +++ b/packages/processors/src/strategy/strategy.processor.ts @@ -2,6 +2,7 @@ import { Changeset } from "@grants-stack-indexer/repository"; import { ChainId, ProtocolEvent, StrategyEvent } from "@grants-stack-indexer/shared"; import type { IProcessor, ProcessorDependencies } from "../internal.js"; +import { UnsupportedStrategy } from "../internal.js"; import { StrategyHandlerFactory } from "./strategyHandler.factory.js"; export class StrategyProcessor implements IProcessor<"Strategy", StrategyEvent> { @@ -13,10 +14,16 @@ export class StrategyProcessor implements IProcessor<"Strategy", StrategyEvent> async process(event: ProtocolEvent<"Strategy", StrategyEvent>): Promise { const strategyId = event.strategyId; - return StrategyHandlerFactory.createHandler( + const strategyHandler = StrategyHandlerFactory.createHandler( this.chainId, this.dependencies, strategyId, - ).handle(event); + ); + + if (!strategyHandler) { + throw new UnsupportedStrategy(strategyId); + } + + return strategyHandler.handle(event); } } diff --git a/packages/processors/src/strategy/strategyHandler.factory.ts b/packages/processors/src/strategy/strategyHandler.factory.ts index a084397..b0d1253 100644 --- a/packages/processors/src/strategy/strategyHandler.factory.ts +++ b/packages/processors/src/strategy/strategyHandler.factory.ts @@ -2,29 +2,28 @@ import { Hex } from "viem"; import { ChainId, StrategyEvent } from "@grants-stack-indexer/shared"; -import { IStrategyHandler, ProcessorDependencies, UnsupportedStrategy } from "../internal.js"; -import { DVMDDirectTransferHandler } from "./donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.js"; +import { getHandler, IStrategyHandler, ProcessorDependencies } from "../internal.js"; +/** + * Factory for creating strategy handlers + */ export class StrategyHandlerFactory { + /** + * Create a new instance of a strategy handler for the given strategy ID + * @param chainId - The chain ID + * @param dependencies - The processor dependencies + * @param strategyId - The strategy ID + * @returns The strategy handler or undefined if it doesn't exist + */ static createHandler( chainId: ChainId, dependencies: ProcessorDependencies, strategyId: Hex, - ): IStrategyHandler { - const _strategyId = strategyId.toLowerCase(); + ): IStrategyHandler | undefined { + const _strategyId = strategyId.toLowerCase() as Hex; + const StrategyHandlerClass = getHandler(_strategyId); - switch (_strategyId) { - case "0x6f9291df02b2664139cec5703c124e4ebce32879c74b6297faa1468aa5ff9ebf": - // DonationVotingMerkleDistributionDirectTransferStrategyv1.1 - case "0x2f46bf157821dc41daa51479e94783bb0c8699eac63bf75ec450508ab03867ce": - // DonationVotingMerkleDistributionDirectTransferStrategyv2.0 - case "0x2f0250d534b2d59b8b5cfa5eb0d0848a59ccbf5de2eaf72d2ba4bfe73dce7c6b": - // DonationVotingMerkleDistributionDirectTransferStrategyv2.1 - case "0x9fa6890423649187b1f0e8bf4265f0305ce99523c3d11aa36b35a54617bb0ec0": - return new DVMDDirectTransferHandler(chainId, dependencies); - - default: - throw new UnsupportedStrategy(strategyId); - } + console.log("StrategyHandlerClass", StrategyHandlerClass); + return StrategyHandlerClass ? new StrategyHandlerClass(chainId, dependencies) : undefined; } } diff --git a/packages/processors/src/types/strategy.types.ts b/packages/processors/src/types/strategy.types.ts index fc206a7..ff13ed4 100644 --- a/packages/processors/src/types/strategy.types.ts +++ b/packages/processors/src/types/strategy.types.ts @@ -1,3 +1,16 @@ +import { Branded, ChainId, StrategyEvent } from "@grants-stack-indexer/shared"; + +import { IStrategyHandler } from "../internal.js"; +import { ProcessorDependencies } from "./processor.types.js"; + +export type SanitizedStrategyId = Branded; +export type Strategy = { + id: SanitizedStrategyId; + name: string | null; + // TODO: check if groups are required + groups: string[]; +}; + /** * This type represents the time fields for a strategy. */ @@ -7,3 +20,8 @@ export type StrategyTimings = { donationsStartTime: Date | null; donationsEndTime: Date | null; }; + +export type StrategyHandlerConstructor = new ( + chainId: ChainId, + dependencies: ProcessorDependencies, +) => IStrategyHandler; diff --git a/packages/processors/test/strategy/common/base.strategy.spec.ts b/packages/processors/test/strategy/common/base.strategy.spec.ts new file mode 100644 index 0000000..7ff9c51 --- /dev/null +++ b/packages/processors/test/strategy/common/base.strategy.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { Changeset } from "@grants-stack-indexer/repository"; +import { Address, Token, TokenCode } from "@grants-stack-indexer/shared"; + +import { BaseStrategyHandler } from "../../../src/strategy/common/base.strategy.js"; + +// Create a concrete implementation of BaseStrategyHandler for testing +class TestStrategyHandler extends BaseStrategyHandler { + constructor() { + super("TestStrategy"); + } + + async handle(): Promise { + return []; + } +} + +describe("BaseStrategyHandler", () => { + const handler = new TestStrategyHandler(); + + it("has the correct name", () => { + expect(handler.name).toBe("TestStrategy"); + }); + + describe("fetchStrategyTimings", () => { + it("returns default timings", async () => { + const address: Address = "0x1234567890123456789012345678901234567890"; + const timings = await handler.fetchStrategyTimings(address); + + expect(timings).toEqual({ + applicationsStartTime: null, + applicationsEndTime: null, + donationsStartTime: null, + donationsEndTime: null, + }); + }); + }); + + describe("fetchMatchAmount", () => { + it("returns default match amount", async () => { + const matchingFundsAvailable = 1000; + const token: Token = { + address: "0x1234567890123456789012345678901234567890", + decimals: 18, + code: "ETH" as TokenCode, + priceSourceCode: "ETH" as TokenCode, + }; + const blockTimestamp = 1625097600; // Example timestamp + + const result = await handler.fetchMatchAmount( + matchingFundsAvailable, + token, + blockTimestamp, + ); + + expect(result).toEqual({ + matchAmount: 0n, + matchAmountInUsd: "0", + }); + }); + }); +}); diff --git a/packages/processors/test/strategy/common/baseDistributed.handler.spec.ts b/packages/processors/test/strategy/common/baseDistributed.handler.spec.ts index 395528d..c05d38e 100644 --- a/packages/processors/test/strategy/common/baseDistributed.handler.spec.ts +++ b/packages/processors/test/strategy/common/baseDistributed.handler.spec.ts @@ -45,7 +45,7 @@ describe("BaseDistributedHandler", () => { } as unknown as IRoundReadRepository; }); - it("increment round total distributed when round is found", async () => { + it("increments round total distributed when round is found", async () => { mockEvent = createMockEvent(); const mockRound = { id: "round1" } as Round; diff --git a/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.spec.ts b/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.spec.ts index d6acbb6..486da9e 100644 --- a/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.spec.ts +++ b/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.spec.ts @@ -1,3 +1,4 @@ +import { parseUnits } from "viem"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { IMetadataProvider } from "@grants-stack-indexer/metadata"; @@ -5,11 +6,19 @@ import type { IProjectReadRepository, IRoundReadRepository, } from "@grants-stack-indexer/repository"; -import { ChainId, ProtocolEvent, StrategyEvent } from "@grants-stack-indexer/shared"; - -import { UnsupportedEventException } from "../../../src/internal.js"; +import { EvmProvider } from "@grants-stack-indexer/chain-providers"; +import { IPricingProvider } from "@grants-stack-indexer/pricing"; +import { + ChainId, + ProtocolEvent, + StrategyEvent, + Token, + TokenCode, +} from "@grants-stack-indexer/shared"; + +import { TokenPriceNotFoundError, UnsupportedEventException } from "../../../src/internal.js"; import { BaseDistributedHandler } from "../../../src/strategy/common/index.js"; -import { DVMDDirectTransferHandler } from "../../../src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.js"; +import { DVMDDirectTransferStrategyHandler } from "../../../src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.js"; import { DVMDRegisteredHandler } from "../../../src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/index.js"; vi.mock( @@ -34,20 +43,32 @@ vi.mock("../../../src/strategy/common/baseDistributed.handler.js", () => { describe("DVMDDirectTransferHandler", () => { const mockChainId = 10 as ChainId; - let handler: DVMDDirectTransferHandler; + let handler: DVMDDirectTransferStrategyHandler; let mockMetadataProvider: IMetadataProvider; let mockRoundRepository: IRoundReadRepository; let mockProjectRepository: IProjectReadRepository; + let mockEVMProvider: EvmProvider; + let mockPricingProvider: IPricingProvider; beforeEach(() => { mockMetadataProvider = {} as IMetadataProvider; mockRoundRepository = {} as IRoundReadRepository; mockProjectRepository = {} as IProjectReadRepository; - - handler = new DVMDDirectTransferHandler(mockChainId, { + mockEVMProvider = { + getMulticall3Address: vi.fn(), + multicall: vi.fn(), + readContract: vi.fn(), + } as unknown as EvmProvider; + mockPricingProvider = { + getTokenPrice: vi.fn(), + } as IPricingProvider; + + handler = new DVMDDirectTransferStrategyHandler(mockChainId, { metadataProvider: mockMetadataProvider, roundRepository: mockRoundRepository, projectRepository: mockProjectRepository, + evmProvider: mockEVMProvider, + pricingProvider: mockPricingProvider, }); }); @@ -55,6 +76,10 @@ describe("DVMDDirectTransferHandler", () => { vi.clearAllMocks(); }); + it("gets correct name", () => { + expect(handler.name).toBe("allov2.DonationVotingMerkleDistributionDirectTransferStrategy"); + }); + it("calls RegisteredHandler for Registered event", async () => { const mockEvent = { eventName: "Registered", @@ -68,6 +93,8 @@ describe("DVMDDirectTransferHandler", () => { metadataProvider: mockMetadataProvider, roundRepository: mockRoundRepository, projectRepository: mockProjectRepository, + evmProvider: mockEVMProvider, + pricingProvider: mockPricingProvider, }); expect(DVMDRegisteredHandler.prototype.handle).toHaveBeenCalled(); }); @@ -85,10 +112,113 @@ describe("DVMDDirectTransferHandler", () => { metadataProvider: mockMetadataProvider, roundRepository: mockRoundRepository, projectRepository: mockProjectRepository, + evmProvider: mockEVMProvider, + pricingProvider: mockPricingProvider, }); expect(BaseDistributedHandler.prototype.handle).toHaveBeenCalled(); }); + describe("fetchMatchAmount", () => { + it("fetches the correct match amount and USD value", async () => { + const matchingFundsAvailable = 1000; + const token: Token = { + address: "0x1234567890123456789012345678901234567890", + decimals: 18, + code: "ETH" as TokenCode, + priceSourceCode: "ETH" as TokenCode, + }; + const blockTimestamp = 1625097600; + + vi.spyOn(mockPricingProvider, "getTokenPrice").mockResolvedValue({ + priceUsd: 2000, + timestampMs: blockTimestamp, + }); + + const result = await handler.fetchMatchAmount( + matchingFundsAvailable, + token, + blockTimestamp, + ); + + expect(result).toEqual({ + matchAmount: parseUnits("1000", 18), + matchAmountInUsd: "2000000", + }); + }); + + it("throws TokenPriceNotFoundError when price is not available", async () => { + const matchingFundsAvailable = 1000; + const token: Token = { + address: "0x1234567890123456789012345678901234567890", + decimals: 18, + code: "ETH" as TokenCode, + priceSourceCode: "ETH" as TokenCode, + }; + const blockTimestamp = 1625097600; + + vi.spyOn(mockPricingProvider, "getTokenPrice").mockResolvedValue(undefined); + + await expect( + handler.fetchMatchAmount(matchingFundsAvailable, token, blockTimestamp), + ).rejects.toThrow(TokenPriceNotFoundError); + }); + }); + + describe("fetchStrategyTimings", () => { + it("fetches correct timings using multicall", async () => { + const strategyId = "0x1234567890123456789012345678901234567890"; + const mockTimings = [1000n, 2000n, 3000n, 4000n]; + + vi.spyOn(mockEVMProvider, "getMulticall3Address").mockReturnValue("0xmulticalladdress"); + vi.spyOn(mockEVMProvider, "multicall").mockResolvedValue(mockTimings); + + const result = await handler.fetchStrategyTimings(strategyId); + + expect(result).toEqual({ + applicationsStartTime: new Date(Number(mockTimings[0]) * 1000), + applicationsEndTime: new Date(Number(mockTimings[1]) * 1000), + donationsStartTime: new Date(Number(mockTimings[2]) * 1000), + donationsEndTime: new Date(Number(mockTimings[3]) * 1000), + }); + + expect(mockEVMProvider.multicall).toHaveBeenCalled(); + expect(mockEVMProvider.readContract).not.toHaveBeenCalled(); + }); + + it("fetches correct timings when multicall is not available", async () => { + const strategyId = "0x1234567890123456789012345678901234567890"; + const mockTimings = [1000n, 2000n, 3000n, 4000n]; + + vi.spyOn(mockEVMProvider, "getMulticall3Address").mockReturnValue(undefined); + vi.spyOn(mockEVMProvider, "readContract").mockImplementation((_, __, functionName) => { + switch (functionName) { + case "registrationStartTime": + return Promise.resolve(mockTimings[0]); + case "registrationEndTime": + return Promise.resolve(mockTimings[1]); + case "allocationStartTime": + return Promise.resolve(mockTimings[2]); + case "allocationEndTime": + return Promise.resolve(mockTimings[3]); + default: + return Promise.resolve(undefined); + } + }); + + const result = await handler.fetchStrategyTimings(strategyId); + + expect(result).toEqual({ + applicationsStartTime: new Date(Number(mockTimings[0]) * 1000), + applicationsEndTime: new Date(Number(mockTimings[1]) * 1000), + donationsStartTime: new Date(Number(mockTimings[2]) * 1000), + donationsEndTime: new Date(Number(mockTimings[3]) * 1000), + }); + + expect(mockEVMProvider.readContract).toHaveBeenCalledTimes(4); + expect(mockEVMProvider.multicall).not.toHaveBeenCalled(); + }); + }); + it.skip("calls AllocatedHandler for Allocated event"); it.skip("calls TimestampsUpdatedHandler for TimestampsUpdated event"); it.skip("calls RecipientStatusUpdatedHandler for RecipientStatusUpdated event"); diff --git a/packages/processors/test/strategy/mapping.spec.ts b/packages/processors/test/strategy/mapping.spec.ts new file mode 100644 index 0000000..caa64a0 --- /dev/null +++ b/packages/processors/test/strategy/mapping.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; + +import { existsHandler } from "../../src/external.js"; +import { getHandler, StrategyHandlerConstructor } from "../../src/internal.js"; +import { DVMDDirectTransferStrategyHandler } from "../../src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.js"; + +describe("Strategy Mapping", () => { + describe("getHandler", () => { + it("should return the correct handler for a valid strategy ID", () => { + const validStrategyId = + "0x6f9291df02b2664139cec5703c124e4ebce32879c74b6297faa1468aa5ff9ebf"; + + const handler = getHandler(validStrategyId); + + expect(handler).toBeDefined(); + expect(handler).toBe(DVMDDirectTransferStrategyHandler); + expectTypeOf(handler).toEqualTypeOf(); + }); + + it("should return the correct handler for a valid strategy ID in uppercase", () => { + const validStrategyId = + "0x6F9291DF02B2664139CEC5703C124E4EBCE32879C74B6297FAA1468AA5FF9EBF"; + + const handler = getHandler(validStrategyId); + + expect(handler).toBeDefined(); + expect(handler).toBe(DVMDDirectTransferStrategyHandler); + expectTypeOf(handler).toEqualTypeOf(); + }); + + it("should return undefined for an invalid strategy ID", () => { + const invalidStrategyId = + "0x1234567890123456789012345678901234567890123456789012345678901234"; + + const handler = getHandler(invalidStrategyId); + + expect(handler).toBeUndefined(); + }); + }); + + describe("existsHandler", () => { + it("should return true for a valid strategy ID", () => { + const validStrategyId = + "0x2f46bf157821dc41daa51479e94783bb0c8699eac63bf75ec450508ab03867ce"; + + const exists = existsHandler(validStrategyId); + + expect(exists).toBe(true); + }); + + it("should return true for a valid strategy ID in uppercase", () => { + const validStrategyId = + "0x2F46BF157821DC41DAA51479E94783BB0C8699EAC63BF75EC450508AB03867CE"; + + const exists = existsHandler(validStrategyId); + + expect(exists).toBe(true); + }); + + it("should return false for an invalid strategy ID", () => { + const invalidStrategyId = + "0x1234567890123456789012345678901234567890123456789012345678901234"; + + const exists = existsHandler(invalidStrategyId); + + expect(exists).toBe(false); + }); + }); +}); diff --git a/packages/processors/test/strategy/strategy.processor.spec.ts b/packages/processors/test/strategy/strategy.processor.spec.ts new file mode 100644 index 0000000..d860b8c --- /dev/null +++ b/packages/processors/test/strategy/strategy.processor.spec.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { EvmProvider } from "@grants-stack-indexer/chain-providers"; +import type { IMetadataProvider } from "@grants-stack-indexer/metadata"; +import type { IPricingProvider } from "@grants-stack-indexer/pricing"; +import type { + IProjectReadRepository, + IRoundReadRepository, +} from "@grants-stack-indexer/repository"; +import type { ChainId, ProtocolEvent, StrategyEvent } from "@grants-stack-indexer/shared"; + +import { StrategyProcessor, UnsupportedStrategy } from "../../src/internal.js"; + +describe("StrategyProcessor", () => { + const mockChainId = 10 as ChainId; + let processor: StrategyProcessor; + let mockEvmProvider: EvmProvider; + let mockPricingProvider: IPricingProvider; + let mockMetadataProvider: IMetadataProvider; + let mockRoundRepository: IRoundReadRepository; + + beforeEach(() => { + mockEvmProvider = {} as EvmProvider; + mockPricingProvider = {} as IPricingProvider; + mockMetadataProvider = {} as IMetadataProvider; + mockRoundRepository = {} as IRoundReadRepository; + + processor = new StrategyProcessor(mockChainId, { + evmProvider: mockEvmProvider, + pricingProvider: mockPricingProvider, + metadataProvider: mockMetadataProvider, + roundRepository: mockRoundRepository, + projectRepository: {} as IProjectReadRepository, + }); + + // Reset mocks before each test + vi.clearAllMocks(); + }); + + it("throw an error for unknown strategyId", async () => { + const mockEvent = { + eventName: "UnknownEvent", + strategyId: "0xunknown", + } as unknown as ProtocolEvent<"Strategy", StrategyEvent>; + + await expect(() => processor.process(mockEvent)).rejects.toThrow(UnsupportedStrategy); + }); +}); diff --git a/packages/processors/test/strategy/strategyHandler.factory.spec.ts b/packages/processors/test/strategy/strategyHandler.factory.spec.ts index ef941bd..2bc3902 100644 --- a/packages/processors/test/strategy/strategyHandler.factory.spec.ts +++ b/packages/processors/test/strategy/strategyHandler.factory.spec.ts @@ -7,9 +7,8 @@ import { IPricingProvider } from "@grants-stack-indexer/pricing"; import { IProjectReadRepository, IRoundReadRepository } from "@grants-stack-indexer/repository"; import { ChainId } from "@grants-stack-indexer/shared"; -import { ProcessorDependencies, UnsupportedStrategy } from "../../src/internal.js"; -import { DVMDDirectTransferHandler } from "../../src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.js"; -import { StrategyHandlerFactory } from "../../src/strategy/strategyHandler.factory.js"; +import { ProcessorDependencies, StrategyHandlerFactory } from "../../src/internal.js"; +import { DVMDDirectTransferStrategyHandler } from "../../src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.js"; describe("StrategyHandlerFactory", () => { const chainId = 10 as ChainId; @@ -55,20 +54,20 @@ describe("StrategyHandlerFactory", () => { ); expect(handler).toBeDefined(); - expect(handler).toBeInstanceOf(DVMDDirectTransferHandler); + expect(handler).toBeInstanceOf(DVMDDirectTransferStrategyHandler); }); }); it.skip("creates a DirectGrantsLiteHandler"); it.skip("creates a DirectGrantsSimpleHandler"); - it("throws an error if the strategy id is not supported", () => { - expect(() => - StrategyHandlerFactory.createHandler( - chainId, - mockProcessorDependencies, - "0xnot-supported", - ), - ).toThrow(UnsupportedStrategy); + it("returns undefined if the strategy id is not supported", () => { + const handler = StrategyHandlerFactory.createHandler( + chainId, + mockProcessorDependencies, + "0xnot-supported", + ); + + expect(handler).toBeUndefined(); }); });