diff --git a/.husky/pre-commit b/.husky/pre-commit index e57b46b..ef863e8 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -pnpm lint-staged && pnpm check-types \ No newline at end of file +pnpm lint-staged && pnpm check-types --force \ No newline at end of file diff --git a/packages/data-flow/test/unit/eventsFetcher.spec.ts b/packages/data-flow/test/unit/eventsFetcher.spec.ts index c2f1dad..ee65a4e 100644 --- a/packages/data-flow/test/unit/eventsFetcher.spec.ts +++ b/packages/data-flow/test/unit/eventsFetcher.spec.ts @@ -27,7 +27,7 @@ describe("EventsFetcher", () => { eventName: "PoolCreated", srcAddress: "0x1234567890123456789012345678901234567890", logIndex: 0, - params: { contractAddress: "0x1234" }, + params: { contractAddress: "0x1234", tokenAddress: "0x1234", amount: 1000 }, transactionFields: { hash: "0x1234", transactionIndex: 0 }, }, { @@ -38,7 +38,11 @@ describe("EventsFetcher", () => { eventName: "PoolCreated", srcAddress: "0x1234567890123456789012345678901234567890", logIndex: 0, - params: { contractAddress: "0x1234" }, + params: { + contractAddress: "0x1234", + tokenAddress: "0x1234", + amount: 1000, + }, transactionFields: { hash: "0x1234", transactionIndex: 1 }, }, ]; diff --git a/packages/indexer-client/test/unit/envioIndexerClient.spec.ts b/packages/indexer-client/test/unit/envioIndexerClient.spec.ts index c15dd90..cffa08f 100644 --- a/packages/indexer-client/test/unit/envioIndexerClient.spec.ts +++ b/packages/indexer-client/test/unit/envioIndexerClient.spec.ts @@ -51,7 +51,7 @@ describe("EnvioIndexerClient", () => { eventName: "PoolCreated", srcAddress: "0x1234567890123456789012345678901234567890", logIndex: 0, - params: { contractAddress: "0x1234" }, + params: { contractAddress: "0x1234", tokenAddress: "0x1234", amount: 1000 }, transactionFields: { hash: "0x123", transactionIndex: 1, diff --git a/packages/processors/src/allo/handlers/poolCreated.handler.ts b/packages/processors/src/allo/handlers/poolCreated.handler.ts index e46f915..1a38ac4 100644 --- a/packages/processors/src/allo/handlers/poolCreated.handler.ts +++ b/packages/processors/src/allo/handlers/poolCreated.handler.ts @@ -1,15 +1,12 @@ -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"; -import { isAlloNativeToken } from "@grants-stack-indexer/shared"; -import { getToken } from "@grants-stack-indexer/shared/dist/src/internal.js"; +import { getToken, isAlloNativeToken } from "@grants-stack-indexer/shared"; 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 { calculateAmountInUsd, getRoundRoles } from "../../helpers/index.js"; +import { StrategyHandlerFactory, TokenPriceNotFoundError } from "../../internal.js"; import { RoundMetadataSchema } from "../../schemas/index.js"; type Dependencies = Pick< @@ -61,7 +58,11 @@ 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 token = getToken(this.chainId, matchTokenAddress); @@ -72,26 +73,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 matchAmountObj = { + matchAmount: 0n, + matchAmountInUsd: "0", + }; - matchAmountInUsd = await this.getTokenAmountInUsd( + if (strategyHandler) { + strategyTimings = await strategyHandler.fetchStrategyTimings(strategyAddress); + if (parsedRoundMetadata.success && token) { + matchAmountObj = await strategyHandler.fetchMatchAmount( + Number(parsedRoundMetadata.data.quadraticFundingConfig.matchingFundsAvailable), token, - matchAmount, this.event.blockTimestamp, ); } @@ -120,8 +112,8 @@ export class PoolCreatedHandler implements IEventHandler<"Allo", "PoolCreated"> totalAmountDonatedInUsd: "0", uniqueDonorsCount: 0, matchTokenAddress, - matchAmount, - matchAmountInUsd, + matchAmount: matchAmountObj.matchAmount, + matchAmountInUsd: matchAmountObj.matchAmountInUsd, fundedAmount, fundedAmountInUsd, applicationMetadataCid: metadataPointer, @@ -132,7 +124,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/exceptions/index.ts b/packages/processors/src/exceptions/index.ts index 746c664..327f9db 100644 --- a/packages/processors/src/exceptions/index.ts +++ b/packages/processors/src/exceptions/index.ts @@ -1,3 +1,6 @@ export * from "./tokenPriceNotFound.exception.js"; export * from "./unsupportedEvent.exception.js"; export * from "./invalidArgument.exception.js"; +export * from "./unsupportedStrategy.exception.js"; +export * from "./projectNotFound.exception.js"; +export * from "./roundNotFound.exception.js"; diff --git a/packages/processors/src/exceptions/projectNotFound.exception.ts b/packages/processors/src/exceptions/projectNotFound.exception.ts new file mode 100644 index 0000000..ba9d367 --- /dev/null +++ b/packages/processors/src/exceptions/projectNotFound.exception.ts @@ -0,0 +1,7 @@ +import { ChainId } from "@grants-stack-indexer/shared"; + +export class ProjectNotFound extends Error { + constructor(chainId: ChainId, anchorAddress: string) { + super(`Project not found for chainId: ${chainId} and anchorAddress: ${anchorAddress}`); + } +} diff --git a/packages/processors/src/exceptions/roundNotFound.exception.ts b/packages/processors/src/exceptions/roundNotFound.exception.ts new file mode 100644 index 0000000..6b68c91 --- /dev/null +++ b/packages/processors/src/exceptions/roundNotFound.exception.ts @@ -0,0 +1,7 @@ +import { ChainId } from "@grants-stack-indexer/shared"; + +export class RoundNotFound extends Error { + constructor(chainId: ChainId, strategyAddress: string) { + super(`Round not found for chainId: ${chainId} and strategyAddress: ${strategyAddress}`); + } +} diff --git a/packages/processors/src/exceptions/unsupportedStrategy.exception.ts b/packages/processors/src/exceptions/unsupportedStrategy.exception.ts new file mode 100644 index 0000000..c1ddbb1 --- /dev/null +++ b/packages/processors/src/exceptions/unsupportedStrategy.exception.ts @@ -0,0 +1,7 @@ +import { Hex } from "viem"; + +export class UnsupportedStrategy extends Error { + constructor(strategyId: Hex) { + super(`Strategy ${strategyId} unsupported`); + } +} 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/index.ts b/packages/processors/src/helpers/index.ts new file mode 100644 index 0000000..0160fc0 --- /dev/null +++ b/packages/processors/src/helpers/index.ts @@ -0,0 +1,3 @@ +export * from "./roles.js"; +export * from "./utils.js"; +export * from "./tokenMath.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/index.ts b/packages/processors/src/interfaces/index.ts index c8e38e6..f76718c 100644 --- a/packages/processors/src/interfaces/index.ts +++ b/packages/processors/src/interfaces/index.ts @@ -1,3 +1,4 @@ export * from "./processor.interface.js"; export * from "./factory.interface.js"; export * from "./eventHandler.interface.js"; +export * from "./strategyHandler.interface.js"; diff --git a/packages/processors/src/interfaces/strategyHandler.interface.ts b/packages/processors/src/interfaces/strategyHandler.interface.ts new file mode 100644 index 0000000..5e0c047 --- /dev/null +++ b/packages/processors/src/interfaces/strategyHandler.interface.ts @@ -0,0 +1,50 @@ +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. + * @template C - The contract name. + * @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 9b54404..b71289f 100644 --- a/packages/processors/src/internal.ts +++ b/packages/processors/src/internal.ts @@ -1,6 +1,12 @@ -// 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/index.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..eacefbf --- /dev/null +++ b/packages/processors/src/strategy/common/base.strategy.ts @@ -0,0 +1,43 @@ +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; + } + + /** @inheritdoc */ + async fetchStrategyTimings(_strategyAddress: Address): Promise { + return { + applicationsStartTime: null, + applicationsEndTime: null, + donationsStartTime: null, + donationsEndTime: null, + }; + } + + /** @inheritdoc */ + 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/baseDistributed.handler.ts b/packages/processors/src/strategy/common/baseDistributed.handler.ts new file mode 100644 index 0000000..138e918 --- /dev/null +++ b/packages/processors/src/strategy/common/baseDistributed.handler.ts @@ -0,0 +1,54 @@ +import { getAddress } from "viem"; + +import { Changeset } from "@grants-stack-indexer/repository"; +import { ChainId, ProtocolEvent } from "@grants-stack-indexer/shared"; + +import { IEventHandler, ProcessorDependencies } from "../../internal.js"; + +type Dependencies = Pick; + +/** + * BaseDistributedHandler: Processes 'Distributed' events + * + * - Handles distribution events across all strategies. + * - Creates a changeset to increment the total distributed amount for a round. + * - Serves as a base class as all strategies share the same logic for this event. + * + * @dev: + * - Strategy handlers that want to handle the Distributed event should create an instance of this class corresponding to the event. + * + */ + +export class BaseDistributedHandler implements IEventHandler<"Strategy", "Distributed"> { + constructor( + readonly event: ProtocolEvent<"Strategy", "Distributed">, + private readonly chainId: ChainId, + private readonly dependencies: Dependencies, + ) {} + + async handle(): Promise { + const { roundRepository } = this.dependencies; + const strategyAddress = getAddress(this.event.srcAddress); + const round = await roundRepository.getRoundByStrategyAddress( + this.chainId, + strategyAddress, + ); + + if (!round) { + //TODO: add logging that round was not found + console.log("Round not found for strategy address", strategyAddress); + return []; + } + + return [ + { + type: "IncrementRoundTotalDistributed", + args: { + chainId: this.chainId, + roundId: round.id, + amount: BigInt(this.event.params.amount), + }, + }, + ]; + } +} diff --git a/packages/processors/src/strategy/common/index.ts b/packages/processors/src/strategy/common/index.ts new file mode 100644 index 0000000..428bb60 --- /dev/null +++ b/packages/processors/src/strategy/common/index.ts @@ -0,0 +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 new file mode 100644 index 0000000..fb1ee1e --- /dev/null +++ b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.ts @@ -0,0 +1,158 @@ +import { parseUnits } from "viem"; + +import { Changeset } from "@grants-stack-indexer/repository"; +import { + Address, + ChainId, + ProtocolEvent, + StrategyEvent, + Token, +} from "@grants-stack-indexer/shared"; + +import type { ProcessorDependencies, StrategyTimings } from "../../internal.js"; +import DonationVotingMerkleDistributionDirectTransferStrategy from "../../abis/allo-v2/v1/DonationVotingMerkleDistributionDirectTransferStrategy.js"; +import { calculateAmountInUsd, getDateFromTimestamp } from "../../helpers/index.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" | "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. + * + * The following events are currently handled by this strategy: + * - Registered + * - Distributed + */ + +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": + return new DVMDRegisteredHandler( + event as ProtocolEvent<"Strategy", "Registered">, + this.chainId, + this.dependencies, + ).handle(); + case "Distributed": + return new BaseDistributedHandler( + event as ProtocolEvent<"Strategy", "Distributed">, + this.chainId, + this.dependencies, + ).handle(); + default: + throw new UnsupportedEventException("Strategy", event.eventName); + } + } + + /** @inheritdoc */ + override async fetchMatchAmount( + matchingFundsAvailable: number, + token: Token, + blockTimestamp: number, + ): Promise<{ matchAmount: bigint; matchAmountInUsd: string }> { + 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; + + // TODO: refactor when evmProvider implements this natively + 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]), + }; + } + + /** + * Get the amount in USD for a given token and amount and timestamp + * @param token - The token + * @param amount - The amount + * @param timestamp - The timestamp + * @returns The amount in USD + * @throws TokenPriceNotFoundError if the token price is not found + */ + 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/donationVotingMerkleDistributionDirectTransfer/handlers/index.ts b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/index.ts new file mode 100644 index 0000000..00c2f86 --- /dev/null +++ b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/index.ts @@ -0,0 +1 @@ +export * from "./registered.handler.js"; diff --git a/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/registered.handler.ts b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/registered.handler.ts new file mode 100644 index 0000000..8fd10c8 --- /dev/null +++ b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/registered.handler.ts @@ -0,0 +1,99 @@ +import { getAddress } from "viem"; + +import { Changeset, NewApplication } from "@grants-stack-indexer/repository"; +import { ChainId, ProtocolEvent } from "@grants-stack-indexer/shared"; + +import { + IEventHandler, + ProcessorDependencies, + ProjectNotFound, + RoundNotFound, +} from "../../../internal.js"; +import { decodeDVMDApplicationData } from "../helpers/index.js"; + +type Dependencies = Pick< + ProcessorDependencies, + "roundRepository" | "projectRepository" | "metadataProvider" +>; + +/** + * Handles the Registered event for the Donation Voting Merkle Distribution Direct Transfer strategy. + * + * This handler performs the following core actions when a project registers for a round: + * - Validates that both the project and round exist + * - Decodes the application data from the event + * - Retrieves the application metadata + * - Creates a new application record with PENDING status + * - Links the application to both the project and round + */ + +export class DVMDRegisteredHandler implements IEventHandler<"Strategy", "Registered"> { + constructor( + readonly event: ProtocolEvent<"Strategy", "Registered">, + private readonly chainId: ChainId, + private readonly dependencies: Dependencies, + ) {} + + /** @inheritdoc */ + async handle(): Promise { + const { projectRepository, roundRepository, metadataProvider } = this.dependencies; + const { data: encodedData, recipientId, sender } = this.event.params; + const { blockNumber, blockTimestamp } = this.event; + + const anchorAddress = getAddress(recipientId); + const project = await projectRepository.getProjectByAnchor(this.chainId, anchorAddress); + + if (!project) { + throw new ProjectNotFound(this.chainId, anchorAddress); + } + + const strategyAddress = getAddress(this.event.srcAddress); + const round = await roundRepository.getRoundByStrategyAddress( + this.chainId, + strategyAddress, + ); + + if (!round) { + throw new RoundNotFound(this.chainId, strategyAddress); + } + + const values = decodeDVMDApplicationData(encodedData); + // ID is defined as recipientsCounter - 1, which is a value emitted by the strategy + const id = (Number(values.recipientsCounter) - 1).toString(); + + const metadata = await metadataProvider.getMetadata(values.metadata.pointer); + + const application: NewApplication = { + chainId: this.chainId, + id: id, + projectId: project.id, + anchorAddress, + roundId: round.id, + status: "PENDING", + metadataCid: values.metadata.pointer, + metadata: metadata ?? null, + createdAtBlock: BigInt(blockNumber), + createdByAddress: getAddress(sender), + statusUpdatedAtBlock: BigInt(blockNumber), + statusSnapshots: [ + { + status: "PENDING", + updatedAtBlock: blockNumber.toString(), + updatedAt: new Date(blockTimestamp * 1000), // timestamp is in seconds, convert to ms + }, + ], + distributionTransaction: null, + totalAmountDonatedInUsd: 0, + totalDonationsCount: 0, + uniqueDonorsCount: 0, + tags: ["allo-v2"], + }; + + return [ + { + type: "InsertApplication", + args: application, + }, + ]; + } +} diff --git a/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/helpers/decoder.ts b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/helpers/decoder.ts new file mode 100644 index 0000000..f9378ac --- /dev/null +++ b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/helpers/decoder.ts @@ -0,0 +1,41 @@ +import { decodeAbiParameters, Hex } from "viem"; + +import { Address } from "@grants-stack-indexer/shared"; + +import { DVMDApplicationData } from "../types/index.js"; + +const DVMD_EVENT_DATA_DECODER = [ + { name: "data", type: "bytes" }, + { name: "recipientsCounter", type: "uint256" }, +] as const; + +const DVMD_DATA_DECODER = [ + { name: "registryAnchor", type: "address" }, + { name: "recipientAddress", type: "address" }, + { + name: "metadata", + type: "tuple", + components: [ + { name: "protocol", type: "uint256" }, + { name: "pointer", type: "string" }, + ], + }, +] as const; + +export const decodeDVMDApplicationData = (encodedData: Hex): DVMDApplicationData => { + const values = decodeAbiParameters(DVMD_EVENT_DATA_DECODER, encodedData); + + const decodedData = decodeAbiParameters(DVMD_DATA_DECODER, values[0]); + + const results: DVMDApplicationData = { + recipientsCounter: values[1].toString(), + anchorAddress: decodedData[0] as Address, + recipientAddress: decodedData[1] as Address, + metadata: { + protocol: Number(decodedData[2].protocol), + pointer: decodedData[2].pointer, + }, + }; + + return results; +}; diff --git a/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/helpers/index.ts b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/helpers/index.ts new file mode 100644 index 0000000..1616d7f --- /dev/null +++ b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/helpers/index.ts @@ -0,0 +1 @@ +export * from "./decoder.js"; diff --git a/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/types/index.ts b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/types/index.ts new file mode 100644 index 0000000..10f42ff --- /dev/null +++ b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/types/index.ts @@ -0,0 +1,11 @@ +import { Address } from "@grants-stack-indexer/shared"; + +export type DVMDApplicationData = { + recipientsCounter: string; + anchorAddress: Address; + recipientAddress: Address; + metadata: { + protocol: number; + pointer: string; + }; +}; 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..9913bf2 --- /dev/null +++ b/packages/processors/src/strategy/mapping.ts @@ -0,0 +1,39 @@ +import { Hex } from "viem"; + +import type { StrategyHandlerConstructor } from "../internal.js"; +import { DVMDDirectTransferStrategyHandler } from "./donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.js"; + +/** + * This mapping connects strategy IDs to their corresponding handler classes. + * When a new strategy event is received, the system uses this mapping to instantiate the appropriate handler + * based on the strategy ID from the event. Each handler implements specific logic for processing events + * from that strategy type. + */ +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 9c2c4ae..3b8ea94 100644 --- a/packages/processors/src/strategy/strategy.processor.ts +++ b/packages/processors/src/strategy/strategy.processor.ts @@ -1,11 +1,29 @@ import { Changeset } from "@grants-stack-indexer/repository"; -import { ProtocolEvent, StrategyEvent } from "@grants-stack-indexer/shared"; +import { ChainId, ProtocolEvent, StrategyEvent } from "@grants-stack-indexer/shared"; -import type { IProcessor } from "../internal.js"; +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> { - process(_event: ProtocolEvent<"Strategy", StrategyEvent>): Promise { - //TODO: Implement - throw new Error("Method not implemented."); + constructor( + private readonly chainId: ChainId, + private readonly dependencies: ProcessorDependencies, + ) {} + + async process(event: ProtocolEvent<"Strategy", StrategyEvent>): Promise { + const strategyId = event.strategyId; + + const strategyHandler = StrategyHandlerFactory.createHandler( + this.chainId, + this.dependencies, + strategyId, + ); + + 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 new file mode 100644 index 0000000..7b466db --- /dev/null +++ b/packages/processors/src/strategy/strategyHandler.factory.ts @@ -0,0 +1,28 @@ +import { Hex } from "viem"; + +import { ChainId, StrategyEvent } from "@grants-stack-indexer/shared"; + +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 | undefined { + const _strategyId = strategyId.toLowerCase() as Hex; + const StrategyHandlerClass = getHandler(_strategyId); + + 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 new file mode 100644 index 0000000..c05d38e --- /dev/null +++ b/packages/processors/test/strategy/common/baseDistributed.handler.spec.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { IRoundReadRepository, Round } from "@grants-stack-indexer/repository"; +import { ChainId, ProtocolEvent } from "@grants-stack-indexer/shared"; + +import { BaseDistributedHandler } from "../../../src/strategy/common/baseDistributed.handler.js"; + +function createMockEvent( + overrides: Partial> = {}, +): ProtocolEvent<"Strategy", "Distributed"> { + const defaultEvent: ProtocolEvent<"Strategy", "Distributed"> = { + params: { + amount: 1000, + recipientAddress: "0x1234567890123456789012345678901234567890", + recipientId: "0x1234567890123456789012345678901234567890", + sender: "0x1234567890123456789012345678901234567890", + }, + eventName: "Distributed", + srcAddress: "0x1234567890123456789012345678901234567890", + blockNumber: 12345, + blockTimestamp: 1000000000, + chainId: 10 as ChainId, + contractName: "Strategy", + logIndex: 1, + transactionFields: { + hash: "0xd2352acdcd59e312370831ea927d51a1917654697a72434cd905a60897a5bb8b", + transactionIndex: 6, + from: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + }, + strategyId: "0x9fa6890423649187b1f0e8bf4265f0305ce99523c3d11aa36b35a54617bb0ec0", + }; + + return { ...defaultEvent, ...overrides }; +} + +describe("BaseDistributedHandler", () => { + let handler: BaseDistributedHandler; + let mockRoundRepository: IRoundReadRepository; + let mockEvent: ProtocolEvent<"Strategy", "Distributed">; + const chainId = 10 as ChainId; + + beforeEach(() => { + mockRoundRepository = { + getRoundByStrategyAddress: vi.fn(), + } as unknown as IRoundReadRepository; + }); + + it("increments round total distributed when round is found", async () => { + mockEvent = createMockEvent(); + const mockRound = { id: "round1" } as Round; + + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddress").mockResolvedValue(mockRound); + + handler = new BaseDistributedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + }); + + const result = await handler.handle(); + + expect(result).toEqual([ + { + type: "IncrementRoundTotalDistributed", + args: { + chainId, + roundId: "round1", + amount: BigInt(mockEvent.params.amount), + }, + }, + ]); + }); + + it("returns an empty array when round is not found", async () => { + mockEvent = createMockEvent(); + + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddress").mockResolvedValue(undefined); + + handler = new BaseDistributedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + }); + + const result = await handler.handle(); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.spec.ts b/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.spec.ts new file mode 100644 index 0000000..486da9e --- /dev/null +++ b/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.spec.ts @@ -0,0 +1,236 @@ +import { parseUnits } from "viem"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { IMetadataProvider } from "@grants-stack-indexer/metadata"; +import type { + IProjectReadRepository, + IRoundReadRepository, +} from "@grants-stack-indexer/repository"; +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 { DVMDDirectTransferStrategyHandler } from "../../../src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.js"; +import { DVMDRegisteredHandler } from "../../../src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/index.js"; + +vi.mock( + "../../../src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/index.js", + () => { + const DVMDRegisteredHandler = vi.fn(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + DVMDRegisteredHandler.prototype.handle = vi.fn(); + return { + DVMDRegisteredHandler, + }; + }, +); +vi.mock("../../../src/strategy/common/baseDistributed.handler.js", () => { + const BaseDistributedHandler = vi.fn(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + BaseDistributedHandler.prototype.handle = vi.fn(); + return { + BaseDistributedHandler, + }; +}); + +describe("DVMDDirectTransferHandler", () => { + const mockChainId = 10 as ChainId; + 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; + 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, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("gets correct name", () => { + expect(handler.name).toBe("allov2.DonationVotingMerkleDistributionDirectTransferStrategy"); + }); + + it("calls RegisteredHandler for Registered event", async () => { + const mockEvent = { + eventName: "Registered", + } as ProtocolEvent<"Strategy", "Registered">; + + vi.spyOn(DVMDRegisteredHandler.prototype, "handle").mockResolvedValue([]); + + await handler.handle(mockEvent); + + expect(DVMDRegisteredHandler).toHaveBeenCalledWith(mockEvent, mockChainId, { + metadataProvider: mockMetadataProvider, + roundRepository: mockRoundRepository, + projectRepository: mockProjectRepository, + evmProvider: mockEVMProvider, + pricingProvider: mockPricingProvider, + }); + expect(DVMDRegisteredHandler.prototype.handle).toHaveBeenCalled(); + }); + + it("calls DistributedHandler for Distributed event", async () => { + const mockEvent = { + eventName: "Distributed", + } as ProtocolEvent<"Strategy", "Distributed">; + + vi.spyOn(BaseDistributedHandler.prototype, "handle").mockResolvedValue([]); + + await handler.handle(mockEvent); + + expect(BaseDistributedHandler).toHaveBeenCalledWith(mockEvent, mockChainId, { + 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"); + it.skip("calls DistributionUpdatedHandler for DistributionUpdated event"); + it.skip("calls UpdatedRegistrationHandler for UpdatedRegistration event"); + it.skip("calls FundsDistributedHandler for FundsDistributed event"); + + it("throws UnsupportedEventException for unknown event names", async () => { + const mockEvent = { eventName: "UnknownEvent" } as unknown as ProtocolEvent< + "Strategy", + StrategyEvent + >; + await expect(() => handler.handle(mockEvent)).rejects.toThrow(UnsupportedEventException); + }); +}); diff --git a/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/registered.handler.spec.ts b/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/registered.handler.spec.ts new file mode 100644 index 0000000..ff494ee --- /dev/null +++ b/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/registered.handler.spec.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { IMetadataProvider } from "@grants-stack-indexer/metadata"; +import { + IProjectReadRepository, + IRoundReadRepository, + NewApplication, + Project, + Round, +} from "@grants-stack-indexer/repository"; +import { ChainId, DeepPartial, mergeDeep, ProtocolEvent } from "@grants-stack-indexer/shared"; + +import { ProjectNotFound, RoundNotFound } from "../../../../src/exceptions/index.js"; +import { DVMDRegisteredHandler } from "../../../../src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/index.js"; + +function createMockEvent( + overrides: DeepPartial> = {}, +): ProtocolEvent<"Strategy", "Registered"> { + const defaultEvent: ProtocolEvent<"Strategy", "Registered"> = { + params: { + recipientId: "0x1234567890123456789012345678901234567890", + sender: "0x0987654321098765432109876543210987654321", + data: "0x0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000001000000000000000000000000002c7296a5ec0539f0a018c7176c97c92a9c44e2b4000000000000000000000000e7eb5d2b5b188777df902e89c54570e7ef4f59ce000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000003b6261666b72656967796334336366696e786c6e6168713561617773676869626574763675737273376b6b78663776786d7a626a79726f37366977790000000000", + }, + eventName: "Registered", + srcAddress: "0x1234567890123456789012345678901234567890", + blockNumber: 12345, + blockTimestamp: 1000000000, + chainId: 10 as ChainId, + contractName: "Strategy", + logIndex: 1, + transactionFields: { + hash: "0xd2352acdcd59e312370831ea927d51a1917654697a72434cd905a60897a5bb8b", + transactionIndex: 6, + from: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + }, + strategyId: "0x9fa6890423649187b1f0e8bf4265f0305ce99523c3d11aa36b35a54617bb0ec0", + }; + + return mergeDeep(defaultEvent, overrides) as ProtocolEvent<"Strategy", "Registered">; +} + +describe("DVMDRegisteredHandler", () => { + let handler: DVMDRegisteredHandler; + let mockRoundRepository: IRoundReadRepository; + let mockProjectRepository: IProjectReadRepository; + let mockMetadataProvider: IMetadataProvider; + let mockEvent: ProtocolEvent<"Strategy", "Registered">; + const chainId = 10 as ChainId; + + beforeEach(() => { + mockRoundRepository = { + getRoundByStrategyAddress: vi.fn(), + } as unknown as IRoundReadRepository; + mockProjectRepository = { + getProjectByAnchor: vi.fn(), + } as unknown as IProjectReadRepository; + mockMetadataProvider = { + getMetadata: vi.fn(), + } as unknown as IMetadataProvider; + }); + + it("handle a valid registration event", async () => { + mockEvent = createMockEvent(); + const mockProject = { id: "project1" } as Project; + const mockRound = { id: "round1" } as Round; + const mockMetadata = { name: "Test Project" }; + + vi.spyOn(mockProjectRepository, "getProjectByAnchor").mockResolvedValue(mockProject); + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddress").mockResolvedValue(mockRound); + vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue(mockMetadata); + + handler = new DVMDRegisteredHandler(mockEvent, chainId, { + projectRepository: mockProjectRepository, + roundRepository: mockRoundRepository, + metadataProvider: mockMetadataProvider, + }); + + const result = await handler.handle(); + + expect(result).toEqual([ + { + type: "InsertApplication", + args: { + chainId, + id: "0", + projectId: "project1", + anchorAddress: "0x1234567890123456789012345678901234567890", + roundId: "round1", + status: "PENDING", + metadataCid: "bafkreigyc43cfinxlnahq5aawsghibetv6usrs7kkxf7vxmzbjyro76iwy", + metadata: mockMetadata, + createdAtBlock: BigInt(mockEvent.blockNumber), + createdByAddress: "0x0987654321098765432109876543210987654321", + statusUpdatedAtBlock: BigInt(mockEvent.blockNumber), + statusSnapshots: [ + { + status: "PENDING", + updatedAtBlock: mockEvent.blockNumber.toString(), + updatedAt: new Date(mockEvent.blockTimestamp * 1000), + }, + ], + distributionTransaction: null, + totalAmountDonatedInUsd: 0, + totalDonationsCount: 0, + uniqueDonorsCount: 0, + tags: ["allo-v2"], + }, + }, + ]); + }); + + it("throw ProjectNotFound if project is not found", async () => { + mockEvent = createMockEvent(); + vi.spyOn(mockProjectRepository, "getProjectByAnchor").mockResolvedValue(undefined); + + handler = new DVMDRegisteredHandler(mockEvent, chainId, { + projectRepository: mockProjectRepository, + roundRepository: mockRoundRepository, + metadataProvider: mockMetadataProvider, + }); + await expect(handler.handle()).rejects.toThrow(ProjectNotFound); + }); + + it("throw RoundNotFound if round is not found", async () => { + mockEvent = createMockEvent(); + const mockProject = { id: "project1" } as Project; + vi.spyOn(mockProjectRepository, "getProjectByAnchor").mockResolvedValue(mockProject); + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddress").mockResolvedValue(undefined); + + handler = new DVMDRegisteredHandler(mockEvent, chainId, { + projectRepository: mockProjectRepository, + roundRepository: mockRoundRepository, + metadataProvider: mockMetadataProvider, + }); + await expect(handler.handle()).rejects.toThrow(RoundNotFound); + }); + + it("handle registration with null metadata", async () => { + mockEvent = createMockEvent(); + const mockProject = { id: "project1" } as Project; + const mockRound = { id: "round1" } as Round; + + vi.spyOn(mockProjectRepository, "getProjectByAnchor").mockResolvedValue(mockProject); + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddress").mockResolvedValue(mockRound); + vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue(undefined); + + handler = new DVMDRegisteredHandler(mockEvent, chainId, { + projectRepository: mockProjectRepository, + roundRepository: mockRoundRepository, + metadataProvider: mockMetadataProvider, + }); + const result = await handler.handle(); + + const changeset = result[0] as { type: "InsertApplication"; args: NewApplication }; + expect(result).toBeDefined(); + expect(changeset.args.metadata).toBeNull(); + }); + + it("correctly calculate application ID based on recipientsCounter", async () => { + // recipientsCounter = 5 + const mockEvent = createMockEvent({ + params: { + data: "0x0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000001000000000000000000000000002c7296a5ec0539f0a018c7176c97c92a9c44e2b4000000000000000000000000e7eb5d2b5b188777df902e89c54570e7ef4f59ce000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000003b6261666b72656967796334336366696e786c6e6168713561617773676869626574763675737273376b6b78663776786d7a626a79726f37366977790000000000", + }, + }); + const mockProject = { id: "project1" } as Project; + const mockRound = { id: "round1" } as Round; + + vi.spyOn(mockProjectRepository, "getProjectByAnchor").mockResolvedValue(mockProject); + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddress").mockResolvedValue(mockRound); + + handler = new DVMDRegisteredHandler(mockEvent, chainId, { + projectRepository: mockProjectRepository, + roundRepository: mockRoundRepository, + metadataProvider: mockMetadataProvider, + }); + const result = await handler.handle(); + + const changeset = result[0] as { type: "InsertApplication"; args: NewApplication }; + expect(changeset.args.id).toBe("4"); + }); +}); diff --git a/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/helpers/decoder.spec.ts b/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/helpers/decoder.spec.ts new file mode 100644 index 0000000..51aa787 --- /dev/null +++ b/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/helpers/decoder.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { decodeDVMDApplicationData } from "../../../../src/strategy/donationVotingMerkleDistributionDirectTransfer/helpers/decoder.js"; +import { DVMDApplicationData } from "../../../../src/strategy/donationVotingMerkleDistributionDirectTransfer/types/index.js"; + +describe("decodeDVMDApplicationData", () => { + it("should correctly decode the encoded data", () => { + const encodedData = + "0x0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000001000000000000000000000000002c7296a5ec0539f0a018c7176c97c92a9c44e2b4000000000000000000000000e7eb5d2b5b188777df902e89c54570e7ef4f59ce000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000003b6261666b72656967796334336366696e786c6e6168713561617773676869626574763675737273376b6b78663776786d7a626a79726f37366977790000000000"; + + const expectedResult: DVMDApplicationData = { + recipientsCounter: "1", + anchorAddress: "0x2c7296a5eC0539f0A018C7176c97c92A9C44E2B4", + recipientAddress: "0xE7eB5D2b5b188777df902e89c54570E7Ef4F59CE", + metadata: { + protocol: 1, + pointer: "bafkreigyc43cfinxlnahq5aawsghibetv6usrs7kkxf7vxmzbjyro76iwy", + }, + }; + + const result = decodeDVMDApplicationData(encodedData); + + expect(result).toEqual(expectedResult); + }); + + it("throw an error for invalid encoded data", () => { + const invalidEncodedData = "0x1234"; + + expect(() => decodeDVMDApplicationData(invalidEncodedData)).toThrow(); + }); +}); 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 new file mode 100644 index 0000000..2bc3902 --- /dev/null +++ b/packages/processors/test/strategy/strategyHandler.factory.spec.ts @@ -0,0 +1,73 @@ +import { Hex } from "viem"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { EvmProvider } from "@grants-stack-indexer/chain-providers"; +import { IMetadataProvider } from "@grants-stack-indexer/metadata"; +import { IPricingProvider } from "@grants-stack-indexer/pricing"; +import { IProjectReadRepository, IRoundReadRepository } from "@grants-stack-indexer/repository"; +import { ChainId } from "@grants-stack-indexer/shared"; + +import { ProcessorDependencies, StrategyHandlerFactory } from "../../src/internal.js"; +import { DVMDDirectTransferStrategyHandler } from "../../src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.js"; + +describe("StrategyHandlerFactory", () => { + const chainId = 10 as ChainId; + let mockEvmProvider: EvmProvider; + let mockPricingProvider: IPricingProvider; + let mockMetadataProvider: IMetadataProvider; + let mockRoundRepository: IRoundReadRepository; + let mockProjectRepository: IProjectReadRepository; + let mockProcessorDependencies: ProcessorDependencies; + + beforeEach(() => { + mockEvmProvider = {} as EvmProvider; + mockPricingProvider = {} as IPricingProvider; + mockMetadataProvider = {} as IMetadataProvider; + mockRoundRepository = {} as IRoundReadRepository; + mockProjectRepository = {} as IProjectReadRepository; + mockProcessorDependencies = { + evmProvider: mockEvmProvider, + pricingProvider: mockPricingProvider, + metadataProvider: mockMetadataProvider, + roundRepository: mockRoundRepository, + projectRepository: mockProjectRepository, + }; + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("creates a DVMDDirectTransferHandler", () => { + const strategies: Hex[] = [ + "0x6f9291df02b2664139cec5703c124e4ebce32879c74b6297faa1468aa5ff9ebf", + "0x2f46bf157821dc41daa51479e94783bb0c8699eac63bf75ec450508ab03867ce", + "0x2f0250d534b2d59b8b5cfa5eb0d0848a59ccbf5de2eaf72d2ba4bfe73dce7c6b", + "0x9fa6890423649187b1f0e8bf4265f0305ce99523c3d11aa36b35a54617bb0ec0", + ]; + + strategies.forEach((strategyId) => { + const handler = StrategyHandlerFactory.createHandler( + chainId, + mockProcessorDependencies, + strategyId, + ); + + expect(handler).toBeDefined(); + expect(handler).toBeInstanceOf(DVMDDirectTransferStrategyHandler); + }); + }); + + it.skip("creates a DirectGrantsLiteHandler"); + it.skip("creates a DirectGrantsSimpleHandler"); + + it("returns undefined if the strategy id is not supported", () => { + const handler = StrategyHandlerFactory.createHandler( + chainId, + mockProcessorDependencies, + "0xnot-supported", + ); + + expect(handler).toBeUndefined(); + }); +}); diff --git a/packages/repository/src/external.ts b/packages/repository/src/external.ts index a37b59b..5a6ea55 100644 --- a/packages/repository/src/external.ts +++ b/packages/repository/src/external.ts @@ -25,6 +25,14 @@ export type { PendingRoundRole, } from "./types/round.types.js"; +export type { + ApplicationStatus, + StatusSnapshot, + Application, + NewApplication, + PartialApplication, +} from "./types/application.types.js"; + export type { Changeset } from "./types/index.js"; export { KyselyRoundRepository, KyselyProjectRepository } from "./repositories/kysely/index.js"; diff --git a/packages/repository/src/types/application.types.ts b/packages/repository/src/types/application.types.ts new file mode 100644 index 0000000..62453d1 --- /dev/null +++ b/packages/repository/src/types/application.types.ts @@ -0,0 +1,35 @@ +import { Address, ChainId } from "@grants-stack-indexer/shared"; + +export type ApplicationStatus = "PENDING" | "REJECTED" | "APPROVED"; + +export type StatusSnapshot = { + status: ApplicationStatus; + updatedAtBlock: string; + updatedAt: Date; +}; + +export type Application = { + id: string; + chainId: ChainId; + roundId: Address | string; + projectId: string; + anchorAddress: Address | null; + status: ApplicationStatus; + statusSnapshots: StatusSnapshot[] | string; + distributionTransaction: string | null; + metadataCid: string | null; + metadata: unknown | null; + createdByAddress: Address; + createdAtBlock: bigint; + statusUpdatedAtBlock: bigint; + totalDonationsCount: number; + totalAmountDonatedInUsd: number; + uniqueDonorsCount: number; + + tags: string[]; +}; + +export type NewApplication = Application; +export type PartialApplication = Partial; + +//TODO: create the corresponding repository implementation diff --git a/packages/repository/src/types/changeset.types.ts b/packages/repository/src/types/changeset.types.ts index 526332c..3704fde 100644 --- a/packages/repository/src/types/changeset.types.ts +++ b/packages/repository/src/types/changeset.types.ts @@ -1,5 +1,6 @@ import type { Address, ChainId } from "@grants-stack-indexer/shared"; +import { NewApplication } from "./application.types.js"; import { NewPendingProjectRole, NewProject, @@ -139,4 +140,8 @@ export type Changeset = args: { roundRole: Pick; }; + } + | { + type: "InsertApplication"; + args: NewApplication; }; diff --git a/packages/shared/src/external.ts b/packages/shared/src/external.ts index 2843728..0216b8e 100644 --- a/packages/shared/src/external.ts +++ b/packages/shared/src/external.ts @@ -16,4 +16,4 @@ export { BigNumber } from "./internal.js"; export type { BigNumberType } from "./internal.js"; export type { TokenCode, Token } from "./internal.js"; -export { TOKENS } from "./tokens/tokens.js"; +export { TOKENS, getToken } from "./tokens/tokens.js"; diff --git a/packages/shared/src/types/events/strategy.ts b/packages/shared/src/types/events/strategy.ts index e52e79d..e4b3d19 100644 --- a/packages/shared/src/types/events/strategy.ts +++ b/packages/shared/src/types/events/strategy.ts @@ -1,25 +1,42 @@ +import { Hex } from "viem"; + import { Address } from "../../internal.js"; /** * This type is used to represent a Strategy events. */ -export type StrategyEvent = "Registered" | "TimestampsUpdated" | "AllocatedWithToken"; +export type StrategyEvent = + | "Registered" + | "Distributed" + | "TimestampsUpdated" + | "AllocatedWithToken"; /** * This type maps Strategy events to their respective parameters. */ export type StrategyEventParams = T extends "Registered" ? RegisteredParams - : T extends "TimestampsUpdated" - ? TimestampsUpdatedParams - : T extends "AllocatedWithToken" - ? AllocatedWithTokenParams - : never; + : T extends "Distributed" + ? DistributedParams + : T extends "TimestampsUpdated" + ? TimestampsUpdatedParams + : T extends "AllocatedWithToken" + ? AllocatedWithTokenParams + : never; // ============================================================================= // =============================== Event Parameters ============================ // ============================================================================= export type RegisteredParams = { - contractAddress: Address; + recipientId: Address; + data: Hex; + sender: Address; +}; + +export type DistributedParams = { + recipientAddress: Address; + recipientId: Address; + sender: Address; + amount: number; }; export type TimestampsUpdatedParams = {