From 2ebd4cf2eb921c36ff9cc0c68ed4be5572858ed3 Mon Sep 17 00:00:00 2001 From: nigiri <168690269+0xnigir1@users.noreply.github.com> Date: Fri, 15 Nov 2024 11:28:06 -0300 Subject: [PATCH] feat: direct grants lite event handlers --- .../directGrantsLite.handler.ts | 118 +++++++ .../handlers/allocated.handler.ts | 97 ++++++ .../directGrantsLite/handlers/index.ts | 4 + .../handlers/registered.handler.ts | 89 +++++ .../handlers/timestampsUpdated.handler.ts | 59 ++++ .../handlers/updatedRegistration.handler.ts | 112 ++++++ .../strategy/directGrantsLite/index.ts | 2 + .../processors/strategy/helpers/decoder.ts | 40 +++ .../src/processors/strategy/mapping.ts | 3 + .../directGrantsLite.handler.spec.ts | 246 ++++++++++++++ .../handlers/allocated.handler.spec.ts | 228 +++++++++++++ .../handlers/registered.handler.spec.ts | 205 +++++++++++ .../timestampsUpdated.handler.spec.ts | 98 ++++++ .../updatedRegistration.handler.spec.ts | 321 ++++++++++++++++++ .../helpers/decoder.spec.ts | 2 +- 15 files changed, 1623 insertions(+), 1 deletion(-) create mode 100644 packages/processors/src/processors/strategy/directGrantsLite/directGrantsLite.handler.ts create mode 100644 packages/processors/src/processors/strategy/directGrantsLite/handlers/allocated.handler.ts create mode 100644 packages/processors/src/processors/strategy/directGrantsLite/handlers/index.ts create mode 100644 packages/processors/src/processors/strategy/directGrantsLite/handlers/registered.handler.ts create mode 100644 packages/processors/src/processors/strategy/directGrantsLite/handlers/timestampsUpdated.handler.ts create mode 100644 packages/processors/src/processors/strategy/directGrantsLite/handlers/updatedRegistration.handler.ts create mode 100644 packages/processors/src/processors/strategy/directGrantsLite/index.ts create mode 100644 packages/processors/test/strategy/directGrantsLite/directGrantsLite.handler.spec.ts create mode 100644 packages/processors/test/strategy/directGrantsLite/handlers/allocated.handler.spec.ts create mode 100644 packages/processors/test/strategy/directGrantsLite/handlers/registered.handler.spec.ts create mode 100644 packages/processors/test/strategy/directGrantsLite/handlers/timestampsUpdated.handler.spec.ts create mode 100644 packages/processors/test/strategy/directGrantsLite/handlers/updatedRegistration.handler.spec.ts diff --git a/packages/processors/src/processors/strategy/directGrantsLite/directGrantsLite.handler.ts b/packages/processors/src/processors/strategy/directGrantsLite/directGrantsLite.handler.ts new file mode 100644 index 0000000..9b17a97 --- /dev/null +++ b/packages/processors/src/processors/strategy/directGrantsLite/directGrantsLite.handler.ts @@ -0,0 +1,118 @@ +import { Changeset } from "@grants-stack-indexer/repository"; +import { Address, ChainId, ProcessorEvent, StrategyEvent } from "@grants-stack-indexer/shared"; + +import DirectGrantsLiteStrategy from "../../../abis/allo-v2/v1/DirectGrantsLiteStrategy.js"; +import { getDateFromTimestamp } from "../../../helpers/index.js"; +import { + BaseRecipientStatusUpdatedHandler, + ProcessorDependencies, + StrategyTimings, + UnsupportedEventException, +} from "../../../internal.js"; +import { BaseStrategyHandler } from "../common/base.strategy.js"; +import { + DGLiteAllocatedHandler, + DGLiteRegisteredHandler, + DGLiteTimestampsUpdatedHandler, + DGLiteUpdatedRegistrationHandler, +} from "./handlers/index.js"; + +const STRATEGY_NAME = "allov2.DirectGrantsLiteStrategy"; + +/** + * This handler is responsible for processing events related to the + * Direct Grants Lite strategy. + * + * The following events are currently handled by this strategy: + * - Registered + * - UpdatedRegistrationWithStatus + * - TimestampsUpdated + * - AllocatedWithToken + * - RecipientStatusUpdatedWithFullRow + */ +export class DirectGrantsLiteStrategyHandler extends BaseStrategyHandler { + constructor( + private readonly chainId: ChainId, + private readonly dependencies: ProcessorDependencies, + ) { + super(STRATEGY_NAME); + } + + /** @inheritdoc */ + async handle(event: ProcessorEvent<"Strategy", StrategyEvent>): Promise { + switch (event.eventName) { + case "RecipientStatusUpdatedWithFullRow": + return new BaseRecipientStatusUpdatedHandler( + event as ProcessorEvent<"Strategy", "RecipientStatusUpdatedWithFullRow">, + this.chainId, + this.dependencies, + ).handle(); + case "RegisteredWithSender": + return new DGLiteRegisteredHandler( + event as ProcessorEvent<"Strategy", "RegisteredWithSender">, + this.chainId, + this.dependencies, + ).handle(); + case "UpdatedRegistrationWithStatus": + return new DGLiteUpdatedRegistrationHandler( + event as ProcessorEvent<"Strategy", "UpdatedRegistrationWithStatus">, + this.chainId, + this.dependencies, + ).handle(); + case "TimestampsUpdated": + return new DGLiteTimestampsUpdatedHandler( + event as ProcessorEvent<"Strategy", "TimestampsUpdated">, + this.chainId, + this.dependencies, + ).handle(); + case "AllocatedWithToken": + return new DGLiteAllocatedHandler( + event as ProcessorEvent<"Strategy", "AllocatedWithToken">, + this.chainId, + this.dependencies, + ).handle(); + default: + throw new UnsupportedEventException("Strategy", event.eventName, this.name); + } + } + + /** @inheritdoc */ + override async fetchStrategyTimings(strategyId: Address): Promise { + const { evmProvider } = this.dependencies; + let results: [bigint, bigint] = [0n, 0n]; + + const contractCalls = [ + { + abi: DirectGrantsLiteStrategy, + functionName: "registrationStartTime", + address: strategyId, + }, + { + abi: DirectGrantsLiteStrategy, + functionName: "registrationEndTime", + 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]; + } + + return { + applicationsStartTime: getDateFromTimestamp(results[0]), + applicationsEndTime: getDateFromTimestamp(results[1]), + donationsStartTime: null, + donationsEndTime: null, + }; + } +} diff --git a/packages/processors/src/processors/strategy/directGrantsLite/handlers/allocated.handler.ts b/packages/processors/src/processors/strategy/directGrantsLite/handlers/allocated.handler.ts new file mode 100644 index 0000000..639e0e1 --- /dev/null +++ b/packages/processors/src/processors/strategy/directGrantsLite/handlers/allocated.handler.ts @@ -0,0 +1,97 @@ +import { getAddress } from "viem"; + +import { Changeset } from "@grants-stack-indexer/repository"; +import { ChainId, getTokenOrThrow, ProcessorEvent } from "@grants-stack-indexer/shared"; + +import { getTokenAmountInUsd, getUsdInTokenAmount } from "../../../../helpers/index.js"; +import { IEventHandler, ProcessorDependencies } from "../../../../internal.js"; + +type Dependencies = Pick< + ProcessorDependencies, + "roundRepository" | "applicationRepository" | "pricingProvider" +>; + +/** + * Handler for processing AllocatedWithToken events from the DirectGrantsLite strategy. + * + * When a round operator allocates funds to a recipient, this handler: + * 1. Retrieves the round and application based on the strategy address and recipient + * 2. Converts the allocated token amount to USD value + * 3. Calculates the equivalent amount in the round's match token + * 4. Updates the application with the allocation details + */ + +export class DGLiteAllocatedHandler implements IEventHandler<"Strategy", "AllocatedWithToken"> { + constructor( + readonly event: ProcessorEvent<"Strategy", "AllocatedWithToken">, + private readonly chainId: ChainId, + private readonly dependencies: Dependencies, + ) {} + + /** @inheritdoc */ + async handle(): Promise { + const { roundRepository, applicationRepository } = this.dependencies; + const { srcAddress } = this.event; + const { recipientId: _recipientId, amount: strAmount, token: _token } = this.event.params; + + const amount = BigInt(strAmount); + + const round = await roundRepository.getRoundByStrategyAddressOrThrow( + this.chainId, + getAddress(srcAddress), + ); + + const recipientId = getAddress(_recipientId); + const tokenAddress = getAddress(_token); + const application = await applicationRepository.getApplicationByAnchorAddressOrThrow( + this.chainId, + round.id, + recipientId, + ); + + const token = getTokenOrThrow(this.chainId, tokenAddress); + const matchToken = getTokenOrThrow(this.chainId, round.matchTokenAddress); + + const { amountInUsd } = await getTokenAmountInUsd( + this.dependencies.pricingProvider, + token, + amount, + this.event.blockTimestamp, + ); + + let amountInRoundMatchToken: bigint | null = null; + amountInRoundMatchToken = + matchToken.address === token.address + ? amount + : ( + await getUsdInTokenAmount( + this.dependencies.pricingProvider, + matchToken, + amountInUsd, + this.event.blockTimestamp, + ) + ).amount; + + const timestamp = this.event.blockTimestamp; + + return [ + { + type: "InsertApplicationPayout", + args: { + applicationPayout: { + amount, + applicationId: application.id, + roundId: round.id, + chainId: this.chainId, + tokenAddress, + amountInRoundMatchToken, + amountInUsd, + transactionHash: this.event.transactionFields.hash, + sender: getAddress(this.event.params.sender), + timestamp: new Date(timestamp), + }, + }, + }, + ]; + } +} diff --git a/packages/processors/src/processors/strategy/directGrantsLite/handlers/index.ts b/packages/processors/src/processors/strategy/directGrantsLite/handlers/index.ts new file mode 100644 index 0000000..b75c01c --- /dev/null +++ b/packages/processors/src/processors/strategy/directGrantsLite/handlers/index.ts @@ -0,0 +1,4 @@ +export * from "./registered.handler.js"; +export * from "./updatedRegistration.handler.js"; +export * from "./timestampsUpdated.handler.js"; +export * from "./allocated.handler.js"; diff --git a/packages/processors/src/processors/strategy/directGrantsLite/handlers/registered.handler.ts b/packages/processors/src/processors/strategy/directGrantsLite/handlers/registered.handler.ts new file mode 100644 index 0000000..1dd5250 --- /dev/null +++ b/packages/processors/src/processors/strategy/directGrantsLite/handlers/registered.handler.ts @@ -0,0 +1,89 @@ +import { getAddress } from "viem"; + +import { Changeset, NewApplication } from "@grants-stack-indexer/repository"; +import { ChainId, ProcessorEvent } from "@grants-stack-indexer/shared"; + +import { IEventHandler, ProcessorDependencies } from "../../../../internal.js"; +import { decodeDVMDExtendedApplicationData } from "../../helpers/index.js"; + +type Dependencies = Pick< + ProcessorDependencies, + "roundRepository" | "projectRepository" | "metadataProvider" +>; + +/** + * Handles the Registered event for the Direct Grants Lite 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 DGLiteRegisteredHandler implements IEventHandler<"Strategy", "RegisteredWithSender"> { + constructor( + readonly event: ProcessorEvent<"Strategy", "RegisteredWithSender">, + 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.getProjectByAnchorOrThrow( + this.chainId, + anchorAddress, + ); + + const strategyAddress = getAddress(this.event.srcAddress); + const round = await roundRepository.getRoundByStrategyAddressOrThrow( + this.chainId, + strategyAddress, + ); + + const values = decodeDVMDExtendedApplicationData(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/processors/strategy/directGrantsLite/handlers/timestampsUpdated.handler.ts b/packages/processors/src/processors/strategy/directGrantsLite/handlers/timestampsUpdated.handler.ts new file mode 100644 index 0000000..0e69546 --- /dev/null +++ b/packages/processors/src/processors/strategy/directGrantsLite/handlers/timestampsUpdated.handler.ts @@ -0,0 +1,59 @@ +import { getAddress } from "viem"; + +import { Changeset } from "@grants-stack-indexer/repository"; +import { ChainId, ProcessorEvent } from "@grants-stack-indexer/shared"; + +import { getDateFromTimestamp } from "../../../../helpers/index.js"; +import { IEventHandler, ProcessorDependencies } from "../../../../internal.js"; + +type Dependencies = Pick; + +/** + * Handles the TimestampsUpdated event for the Direct Grants Lite strategy. + * + * This handler processes updates to the round timestamps: + * - Validates the round exists for the strategy address + * - Converts the updated registration timestamps to dates + * - Returns a changeset to update the round's application timestamps + */ +export class DGLiteTimestampsUpdatedHandler + implements IEventHandler<"Strategy", "TimestampsUpdated"> +{ + constructor( + readonly event: ProcessorEvent<"Strategy", "TimestampsUpdated">, + private readonly chainId: ChainId, + private readonly dependencies: Dependencies, + ) {} + + /** + * Handles the TimestampsUpdated event for the Direct Grants Lite strategy. + * @returns The changeset with an UpdateRound operation. + * @throws RoundNotFound if the round is not found. + */ + async handle(): Promise { + const strategyAddress = getAddress(this.event.srcAddress); + const round = await this.dependencies.roundRepository.getRoundByStrategyAddressOrThrow( + this.chainId, + strategyAddress, + ); + + const { startTime: strStartTime, endTime: strEndTime } = this.event.params; + + const applicationsStartTime = getDateFromTimestamp(BigInt(strStartTime)); + const applicationsEndTime = getDateFromTimestamp(BigInt(strEndTime)); + + return [ + { + type: "UpdateRound", + args: { + chainId: this.chainId, + roundId: round.id, + round: { + applicationsStartTime, + applicationsEndTime, + }, + }, + }, + ]; + } +} diff --git a/packages/processors/src/processors/strategy/directGrantsLite/handlers/updatedRegistration.handler.ts b/packages/processors/src/processors/strategy/directGrantsLite/handlers/updatedRegistration.handler.ts new file mode 100644 index 0000000..044af71 --- /dev/null +++ b/packages/processors/src/processors/strategy/directGrantsLite/handlers/updatedRegistration.handler.ts @@ -0,0 +1,112 @@ +import { getAddress } from "viem"; + +import { Application, Changeset } from "@grants-stack-indexer/repository"; +import { ChainId, ProcessorEvent } from "@grants-stack-indexer/shared"; + +import { ApplicationStatus, IEventHandler, ProcessorDependencies } from "../../../../internal.js"; +import { + createStatusUpdate, + decodeDVMDApplicationData, + isValidApplicationStatus, +} from "../../helpers/index.js"; + +type Dependencies = Pick< + ProcessorDependencies, + | "logger" + | "roundRepository" + | "applicationRepository" + | "projectRepository" + | "metadataProvider" +>; + +/** + * Handles the UpdatedRegistration event for the Direct Grants Lite strategy. + * + * This handler processes updates to project registrations/applications in a round: + * - Validates the updated application status is valid (between 1-3) + * - Decodes the updated application metadata and data + * - Returns a changeset to update the application record + */ + +export class DGLiteUpdatedRegistrationHandler + implements IEventHandler<"Strategy", "UpdatedRegistrationWithStatus"> +{ + constructor( + readonly event: ProcessorEvent<"Strategy", "UpdatedRegistrationWithStatus">, + private readonly chainId: ChainId, + private readonly dependencies: Dependencies, + ) {} + + /** + * Handles the UpdatedRegistrationWithStatus event for the Direct Grants Lite strategy. + * @returns The changeset with an UpdateApplication operation. + * @throws ProjectNotFound if the project is not found. + * @throws RoundNotFound if the round is not found. + * @throws ApplicationNotFound if the application is not found. + */ + async handle(): Promise { + const { + metadataProvider, + logger, + roundRepository, + applicationRepository, + projectRepository, + } = this.dependencies; + + const { status: strStatus } = this.event.params; + const status = Number(strStatus); + + if (!isValidApplicationStatus(status)) { + logger.warn( + `[DGLiteUpdatedRegistrationHandler] Invalid status: ${this.event.params.status}`, + ); + + return []; + } + + const project = await projectRepository.getProjectByAnchorOrThrow( + this.chainId, + getAddress(this.event.params.recipientId), + ); + const round = await roundRepository.getRoundByStrategyAddressOrThrow( + this.chainId, + getAddress(this.event.srcAddress), + ); + const application = await applicationRepository.getApplicationByAnchorAddressOrThrow( + this.chainId, + round.id, + project.anchorAddress!, + ); + + const encodedData = this.event.params.data; + const values = decodeDVMDApplicationData(encodedData); + + const metadata = await metadataProvider.getMetadata(values.metadata.pointer); + + const statusString = ApplicationStatus[status] as Application["status"]; + + const statusUpdates = createStatusUpdate({ + application, + newStatus: statusString, + blockNumber: this.event.blockNumber, + blockTimestamp: this.event.blockTimestamp, + }); + + return [ + { + type: "UpdateApplication", + args: { + chainId: this.chainId, + roundId: round.id, + applicationId: application.id, + application: { + ...application, + ...statusUpdates, + metadataCid: values.metadata.pointer, + metadata: metadata ?? null, + }, + }, + }, + ]; + } +} diff --git a/packages/processors/src/processors/strategy/directGrantsLite/index.ts b/packages/processors/src/processors/strategy/directGrantsLite/index.ts new file mode 100644 index 0000000..48ad29a --- /dev/null +++ b/packages/processors/src/processors/strategy/directGrantsLite/index.ts @@ -0,0 +1,2 @@ +export * from "./handlers/index.js"; +export * from "./directGrantsLite.handler.js"; diff --git a/packages/processors/src/processors/strategy/helpers/decoder.ts b/packages/processors/src/processors/strategy/helpers/decoder.ts index 6438187..7b495e5 100644 --- a/packages/processors/src/processors/strategy/helpers/decoder.ts +++ b/packages/processors/src/processors/strategy/helpers/decoder.ts @@ -23,6 +23,30 @@ const DVMD_DATA_DECODER = [ }, ] as const; +const DG_DATA_DECODER = [ + { name: "recipientId", type: "address" }, + { name: "registryAnchor", type: "address" }, + { name: "grantAmount", type: "uint256" }, + { + name: "metadata", + type: "tuple", + components: [ + { name: "protocol", type: "uint256" }, + { name: "pointer", type: "string" }, + ], + }, +] as const; + +export type DGApplicationData = { + recipientAddress: string; + anchorAddress: string; + grantAmount: bigint; + metadata: { + protocol: number; + pointer: string; + }; +}; + export const decodeDVMDApplicationData = (encodedData: Hex): DVMDApplicationData => { const decodedData = decodeAbiParameters(DVMD_DATA_DECODER, encodedData); @@ -50,3 +74,19 @@ export const decodeDVMDExtendedApplicationData = ( recipientsCounter: values[1].toString(), }; }; + +export const decodeDGApplicationData = (encodedData: Hex): DGApplicationData => { + const decodedData = decodeAbiParameters(DG_DATA_DECODER, encodedData); + + const results: DGApplicationData = { + recipientAddress: decodedData[0], + anchorAddress: decodedData[1], + grantAmount: decodedData[2], + metadata: { + protocol: Number(decodedData[3].protocol), + pointer: decodedData[3].pointer, + }, + }; + + return results; +}; diff --git a/packages/processors/src/processors/strategy/mapping.ts b/packages/processors/src/processors/strategy/mapping.ts index 83c92e9..29d9b6b 100644 --- a/packages/processors/src/processors/strategy/mapping.ts +++ b/packages/processors/src/processors/strategy/mapping.ts @@ -2,6 +2,7 @@ import { Hex } from "viem"; import type { StrategyHandlerConstructor } from "../../internal.js"; import { DirectAllocationStrategyHandler } from "./directAllocation/index.js"; +import { DirectGrantsLiteStrategyHandler } from "./directGrantsLite/index.js"; import { DVMDDirectTransferStrategyHandler } from "./donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.js"; /** @@ -21,6 +22,8 @@ const strategyIdToHandler: Readonly> DVMDDirectTransferStrategyHandler, // DonationVotingMerkleDistributionDirectTransferStrategyv2.1 "0x4cd0051913234cdd7d165b208851240d334786d6e5afbb4d0eec203515a9c6f3": DirectAllocationStrategyHandler, + "0x103732a8e473467a510d4128ee11065262bdd978f0d9dad89ba68f2c56127e27": + DirectGrantsLiteStrategyHandler, } as const; /** diff --git a/packages/processors/test/strategy/directGrantsLite/directGrantsLite.handler.spec.ts b/packages/processors/test/strategy/directGrantsLite/directGrantsLite.handler.spec.ts new file mode 100644 index 0000000..2af0acd --- /dev/null +++ b/packages/processors/test/strategy/directGrantsLite/directGrantsLite.handler.spec.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { IMetadataProvider } from "@grants-stack-indexer/metadata"; +import type { + IApplicationReadRepository, + IProjectReadRepository, + IRoundReadRepository, +} from "@grants-stack-indexer/repository"; +import { EvmProvider } from "@grants-stack-indexer/chain-providers"; +import { IPricingProvider } from "@grants-stack-indexer/pricing"; +import { ChainId, ILogger, ProcessorEvent, StrategyEvent } from "@grants-stack-indexer/shared"; + +import { ProcessorDependencies, UnsupportedEventException } from "../../../src/internal.js"; +import { BaseRecipientStatusUpdatedHandler } from "../../../src/processors/strategy/common/index.js"; +import { + DGLiteAllocatedHandler, + DGLiteRegisteredHandler, + DGLiteTimestampsUpdatedHandler, + DGLiteUpdatedRegistrationHandler, + DirectGrantsLiteStrategyHandler, +} from "../../../src/processors/strategy/directGrantsLite/index.js"; + +vi.mock("../../../src/processors/strategy/directGrantsLite/handlers/index.js", () => { + const DGLiteRegisteredHandler = vi.fn(); + const DGLiteAllocatedHandler = vi.fn(); + const DGLiteTimestampsUpdatedHandler = vi.fn(); + const DGLiteUpdatedRegistrationHandler = vi.fn(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + DGLiteRegisteredHandler.prototype.handle = vi.fn(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + DGLiteAllocatedHandler.prototype.handle = vi.fn(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + DGLiteTimestampsUpdatedHandler.prototype.handle = vi.fn(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + DGLiteUpdatedRegistrationHandler.prototype.handle = vi.fn(); + + return { + DGLiteRegisteredHandler, + DGLiteAllocatedHandler, + DGLiteTimestampsUpdatedHandler, + DGLiteUpdatedRegistrationHandler, + }; +}); +vi.mock("../../../src/processors/strategy/common/index.js", async (importOriginal) => { + const original = + await importOriginal(); + + const BaseRecipientStatusUpdatedHandler = vi.fn(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + BaseRecipientStatusUpdatedHandler.prototype.handle = vi.fn(); + return { + ...original, + BaseRecipientStatusUpdatedHandler, + }; +}); + +describe("DirectGrantsLiteStrategyHandler", () => { + const mockChainId = 10 as ChainId; + let handler: DirectGrantsLiteStrategyHandler; + let mockMetadataProvider: IMetadataProvider; + let mockRoundRepository: IRoundReadRepository; + let mockProjectRepository: IProjectReadRepository; + let mockEVMProvider: EvmProvider; + let mockPricingProvider: IPricingProvider; + let mockApplicationRepository: IApplicationReadRepository; + let dependencies: ProcessorDependencies; + + const logger: ILogger = { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }; + 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; + mockApplicationRepository = {} as IApplicationReadRepository; + dependencies = { + metadataProvider: mockMetadataProvider, + roundRepository: mockRoundRepository, + projectRepository: mockProjectRepository, + evmProvider: mockEVMProvider, + pricingProvider: mockPricingProvider, + applicationRepository: mockApplicationRepository, + logger, + }; + handler = new DirectGrantsLiteStrategyHandler(mockChainId, dependencies); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("gets correct name", () => { + expect(handler.name).toBe("allov2.DirectGrantsLiteStrategy"); + }); + + it("calls RegisteredHandler for RegisteredWithSender event", async () => { + const mockEvent = { + eventName: "RegisteredWithSender", + } as ProcessorEvent<"Strategy", "RegisteredWithSender">; + + vi.spyOn(DGLiteRegisteredHandler.prototype, "handle").mockResolvedValue([]); + + await handler.handle(mockEvent); + + expect(DGLiteRegisteredHandler).toHaveBeenCalledWith(mockEvent, mockChainId, dependencies); + expect(DGLiteRegisteredHandler.prototype.handle).toHaveBeenCalled(); + }); + + it("calls AllocatedHandler for AllocatedWithToken event", async () => { + const mockEvent = { + eventName: "AllocatedWithToken", + } as ProcessorEvent<"Strategy", "AllocatedWithToken">; + + vi.spyOn(DGLiteAllocatedHandler.prototype, "handle").mockResolvedValue([]); + + await handler.handle(mockEvent); + + expect(DGLiteAllocatedHandler).toHaveBeenCalledWith(mockEvent, mockChainId, dependencies); + expect(DGLiteAllocatedHandler.prototype.handle).toHaveBeenCalled(); + }); + + it("calls TimestampsUpdatedHandler for TimestampsUpdated event", async () => { + const mockEvent = { + eventName: "TimestampsUpdated", + } as ProcessorEvent<"Strategy", "TimestampsUpdated">; + + vi.spyOn(DGLiteTimestampsUpdatedHandler.prototype, "handle").mockResolvedValue([]); + + await handler.handle(mockEvent); + + expect(DGLiteTimestampsUpdatedHandler).toHaveBeenCalledWith( + mockEvent, + mockChainId, + dependencies, + ); + expect(DGLiteTimestampsUpdatedHandler.prototype.handle).toHaveBeenCalled(); + }); + + it("calls RecipientStatusUpdatedHandler for RecipientStatusUpdatedWithFullRow event", async () => { + const mockEvent = { + eventName: "RecipientStatusUpdatedWithFullRow", + } as ProcessorEvent<"Strategy", "RecipientStatusUpdatedWithFullRow">; + + vi.spyOn(BaseRecipientStatusUpdatedHandler.prototype, "handle").mockResolvedValue([]); + + await handler.handle(mockEvent); + + expect(BaseRecipientStatusUpdatedHandler).toHaveBeenCalledWith( + mockEvent, + mockChainId, + dependencies, + ); + expect(BaseRecipientStatusUpdatedHandler.prototype.handle).toHaveBeenCalled(); + }); + + it("calls UpdatedRegistrationHandler for UpdatedRegistrationWithStatus event", async () => { + const mockEvent = { + eventName: "UpdatedRegistrationWithStatus", + } as ProcessorEvent<"Strategy", "UpdatedRegistrationWithStatus">; + + vi.spyOn(DGLiteUpdatedRegistrationHandler.prototype, "handle").mockResolvedValue([]); + + await handler.handle(mockEvent); + + expect(DGLiteUpdatedRegistrationHandler).toHaveBeenCalledWith( + mockEvent, + mockChainId, + dependencies, + ); + expect(DGLiteUpdatedRegistrationHandler.prototype.handle).toHaveBeenCalled(); + }); + + describe("fetchStrategyTimings", () => { + it("fetches correct timings using multicall", async () => { + const strategyId = "0x1234567890123456789012345678901234567890"; + const mockTimings = [1000n, 2000n]; + + 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: null, + donationsEndTime: null, + }); + + 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]; + + 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]); + 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: null, + donationsEndTime: null, + }); + + expect(mockEVMProvider.readContract).toHaveBeenCalledTimes(2); + expect(mockEVMProvider.multicall).not.toHaveBeenCalled(); + }); + }); + + it("throws UnsupportedEventException for unknown event names", async () => { + const mockEvent = { eventName: "UnknownEvent" } as unknown as ProcessorEvent< + "Strategy", + StrategyEvent + >; + await expect(() => handler.handle(mockEvent)).rejects.toThrow( + new UnsupportedEventException("Strategy", "UnknownEvent", handler.name), + ); + }); +}); diff --git a/packages/processors/test/strategy/directGrantsLite/handlers/allocated.handler.spec.ts b/packages/processors/test/strategy/directGrantsLite/handlers/allocated.handler.spec.ts new file mode 100644 index 0000000..aa9b33d --- /dev/null +++ b/packages/processors/test/strategy/directGrantsLite/handlers/allocated.handler.spec.ts @@ -0,0 +1,228 @@ +import { getAddress, parseEther } from "viem"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { IPricingProvider } from "@grants-stack-indexer/pricing"; +import { + Application, + ApplicationNotFound, + IApplicationRepository, + IRoundRepository, + Round, + RoundNotFound, +} from "@grants-stack-indexer/repository"; +import { ChainId, DeepPartial, mergeDeep, ProcessorEvent } from "@grants-stack-indexer/shared"; + +import { TokenPriceNotFoundError } from "../../../../src/exceptions/index.js"; +import { DGLiteAllocatedHandler } from "../../../../src/processors/strategy/directGrantsLite/handlers/allocated.handler.js"; + +function createMockEvent( + overrides: DeepPartial> = {}, +): ProcessorEvent<"Strategy", "AllocatedWithToken"> { + const defaultEvent: ProcessorEvent<"Strategy", "AllocatedWithToken"> = { + params: { + recipientId: "0x1234567890123456789012345678901234567890", + amount: parseEther("10").toString(), + token: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", + sender: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + }, + eventName: "AllocatedWithToken", + srcAddress: "0x1234567890123456789012345678901234567890", + blockNumber: 118034410, + blockTimestamp: 1000000000, + chainId: 10 as ChainId, + contractName: "Strategy", + logIndex: 92, + transactionFields: { + hash: "0xd2352acdcd59e312370831ea927d51a1917654697a72434cd905a60897a5bb8b", + transactionIndex: 6, + from: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + }, + strategyId: "0x9fa6890423649187b1f0e8bf4265f0305ce99523c3d11aa36b35a54617bb0ec0", + }; + + return mergeDeep(defaultEvent, overrides); +} + +describe("DGLiteAllocatedHandler", () => { + let handler: DGLiteAllocatedHandler; + let mockRoundRepository: IRoundRepository; + let mockApplicationRepository: IApplicationRepository; + let mockPricingProvider: IPricingProvider; + let mockEvent: ProcessorEvent<"Strategy", "AllocatedWithToken">; + const chainId = 10 as ChainId; + + beforeEach(() => { + mockRoundRepository = { + getRoundByStrategyAddressOrThrow: vi.fn(), + } as unknown as IRoundRepository; + mockApplicationRepository = { + getApplicationByAnchorAddressOrThrow: vi.fn(), + } as unknown as IApplicationRepository; + mockPricingProvider = { + getTokenPrice: vi.fn(), + } as IPricingProvider; + }); + + it("should handle a valid allocation event", async () => { + const amount = parseEther("10").toString(); + mockEvent = createMockEvent({ params: { amount } }); + const mockRound = { + id: "round1", + matchTokenAddress: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", + matchAmount: BigInt(0), + matchAmountInUsd: "0", + fundedAmount: BigInt(0), + fundedAmountInUsd: "0", + totalAmountDonatedInUsd: "0", + totalDonationsCount: 0, + uniqueDonorsCount: 0, + tags: [], + } as unknown as Round; + + const mockApplication = { + id: "app1", + projectId: "project1", + } as unknown as Application; + + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddressOrThrow").mockResolvedValue( + mockRound, + ); + vi.spyOn( + mockApplicationRepository, + "getApplicationByAnchorAddressOrThrow", + ).mockResolvedValue(mockApplication); + vi.spyOn(mockPricingProvider, "getTokenPrice").mockResolvedValue({ + timestampMs: 1000000000, + priceUsd: 2000, + }); + + handler = new DGLiteAllocatedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + pricingProvider: mockPricingProvider, + }); + + const result = await handler.handle(); + + expect(result[0]).toEqual({ + type: "InsertApplicationPayout", + args: { + applicationPayout: { + amount: BigInt(amount), + applicationId: "app1", + roundId: "round1", + chainId, + tokenAddress: getAddress(mockEvent.params.token), + amountInRoundMatchToken: BigInt(amount), + amountInUsd: "20000", + transactionHash: mockEvent.transactionFields.hash, + sender: getAddress(mockEvent.params.sender), + timestamp: new Date(mockEvent.blockTimestamp), + }, + }, + }); + }); + + it("should throw RoundNotFound if round is not found", async () => { + mockEvent = createMockEvent(); + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddressOrThrow").mockRejectedValue( + new RoundNotFound(chainId, mockEvent.strategyId), + ); + + handler = new DGLiteAllocatedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + pricingProvider: mockPricingProvider, + }); + + await expect(handler.handle()).rejects.toThrow(RoundNotFound); + }); + + it("should throw ApplicationNotFound if application is not found", async () => { + mockEvent = createMockEvent(); + const mockRound = { + id: "round1", + chainId, + matchTokenAddress: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", + matchAmount: BigInt(0), + matchAmountInUsd: "0", + fundedAmount: BigInt(0), + fundedAmountInUsd: "0", + totalAmountDonatedInUsd: "0", + totalDonationsCount: 0, + uniqueDonorsCount: 0, + tags: [], + } as unknown as Round; + + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddressOrThrow").mockResolvedValue( + mockRound, + ); + vi.spyOn( + mockApplicationRepository, + "getApplicationByAnchorAddressOrThrow", + ).mockRejectedValue( + new ApplicationNotFound(chainId, mockRound.id, mockEvent.params.recipientId), + ); + + handler = new DGLiteAllocatedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + pricingProvider: mockPricingProvider, + }); + + await expect(handler.handle()).rejects.toThrow(ApplicationNotFound); + }); + + it("should handle different token and match token", async () => { + const amount = parseEther("10").toString(); + mockEvent = createMockEvent({ params: { amount } }); + const mockRound = { + id: "round1", + chainId, + matchTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + matchAmount: BigInt(0), + matchAmountInUsd: "0", + fundedAmount: BigInt(0), + fundedAmountInUsd: "0", + totalAmountDonatedInUsd: "0", + totalDonationsCount: 0, + uniqueDonorsCount: 0, + tags: [], + } as unknown as Round; + + const mockApplication = { + id: "app1", + projectId: "project1", + } as unknown as Application; + + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddressOrThrow").mockResolvedValue( + mockRound, + ); + vi.spyOn( + mockApplicationRepository, + "getApplicationByAnchorAddressOrThrow", + ).mockResolvedValue(mockApplication); + vi.spyOn(mockPricingProvider, "getTokenPrice") + .mockResolvedValueOnce({ + timestampMs: 1000000000, + priceUsd: 1, + }) + .mockResolvedValueOnce({ + timestampMs: 1000000000, + priceUsd: 2000, + }); + + handler = new DGLiteAllocatedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + pricingProvider: mockPricingProvider, + }); + + const result = await handler.handle(); + const changeset = result[0] as { + type: "InsertApplicationPayout"; + args: { applicationPayout: { amountInRoundMatchToken: bigint } }; + }; + expect(changeset.args.applicationPayout.amountInRoundMatchToken).toBe(parseEther("0.005")); + }); +}); diff --git a/packages/processors/test/strategy/directGrantsLite/handlers/registered.handler.spec.ts b/packages/processors/test/strategy/directGrantsLite/handlers/registered.handler.spec.ts new file mode 100644 index 0000000..1cffeda --- /dev/null +++ b/packages/processors/test/strategy/directGrantsLite/handlers/registered.handler.spec.ts @@ -0,0 +1,205 @@ +import { getAddress } from "viem"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { IMetadataProvider } from "@grants-stack-indexer/metadata"; +import { + IProjectRepository, + IRoundRepository, + NewApplication, + Project, + ProjectNotFound, + Round, + RoundNotFound, +} from "@grants-stack-indexer/repository"; +import { ChainId, DeepPartial, mergeDeep, ProcessorEvent } from "@grants-stack-indexer/shared"; + +import { DGLiteRegisteredHandler } from "../../../../src/processors/strategy/directGrantsLite/handlers/registered.handler.js"; + +function createMockEvent( + overrides: DeepPartial> = {}, +): ProcessorEvent<"Strategy", "RegisteredWithSender"> { + const defaultEvent: ProcessorEvent<"Strategy", "RegisteredWithSender"> = { + params: { + recipientId: "0x1234567890123456789012345678901234567890", + data: "0x000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000100000000000000000000000000accc041f3d1f576198ac88ede32e58c3476710a700000000000000000000000058338e95caef17861916ef10dad5fafe20421005000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000003b6261666b72656966736e77736a6c6b74746632626d6f646a6c646e76766c366677707271766a6976786b67367a6e74376a656c62786a75717a33650000000000", + sender: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + }, + eventName: "RegisteredWithSender", + 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); +} + +describe("DGLiteRegisteredHandler", () => { + let handler: DGLiteRegisteredHandler; + let mockRoundRepository: IRoundRepository; + let mockProjectRepository: IProjectRepository; + let mockMetadataProvider: IMetadataProvider; + let mockEvent: ProcessorEvent<"Strategy", "RegisteredWithSender">; + const chainId = 10 as ChainId; + + beforeEach(() => { + mockRoundRepository = { + getRoundByStrategyAddressOrThrow: vi.fn(), + } as unknown as IRoundRepository; + mockProjectRepository = { + getProjectByAnchorOrThrow: vi.fn(), + } as unknown as IProjectRepository; + mockMetadataProvider = { + getMetadata: vi.fn(), + } as unknown as IMetadataProvider; + }); + + it("handles a valid registration event", async () => { + mockEvent = createMockEvent(); + const mockProject = { + id: "project1", + anchorAddress: mockEvent.params.recipientId, + } as unknown as Project; + const mockRound = { + id: "round1", + chainId, + } as unknown as Round; + const mockMetadata = { name: "Test Project" }; + + vi.spyOn(mockProjectRepository, "getProjectByAnchorOrThrow").mockResolvedValue(mockProject); + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddressOrThrow").mockResolvedValue( + mockRound, + ); + vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue(mockMetadata); + + handler = new DGLiteRegisteredHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + projectRepository: mockProjectRepository, + metadataProvider: mockMetadataProvider, + }); + + const result = await handler.handle(); + + // parsed data: + // { + // anchorAddress: '0xAcCC041f3D1F576198AC88eDE32E58C3476710A7', + // recipientAddress: '0x58338E95caEf17861916Ef10daD5fAFE20421005', + // metadata: { + // protocol: 1, + // pointer: 'bafkreifsnwsjlkttf2bmodjldnvvl6fwprqvjivxkg6znt7jelbxjuqz3e' + // }, + // recipientsCounter: '2' + // } + + expect(result).toEqual([ + { + type: "InsertApplication", + args: { + chainId, + id: "1", + projectId: "project1", + anchorAddress: getAddress(mockEvent.params.recipientId), + roundId: "round1", + status: "PENDING", + metadataCid: "bafkreifsnwsjlkttf2bmodjldnvvl6fwprqvjivxkg6znt7jelbxjuqz3e", + metadata: mockMetadata, + createdAtBlock: BigInt(mockEvent.blockNumber), + createdByAddress: getAddress(mockEvent.params.sender), + 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"], + }, + }, + ]); + + expect(mockMetadataProvider.getMetadata).toHaveBeenCalledWith( + "bafkreifsnwsjlkttf2bmodjldnvvl6fwprqvjivxkg6znt7jelbxjuqz3e", + ); + }); + + it("handles null metadata", async () => { + mockEvent = createMockEvent(); + const mockProject = { + id: "project1", + anchorAddress: mockEvent.params.recipientId, + } as unknown as Project; + const mockRound = { + id: "round1", + chainId, + } as unknown as Round; + + vi.spyOn(mockProjectRepository, "getProjectByAnchorOrThrow").mockResolvedValue(mockProject); + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddressOrThrow").mockResolvedValue( + mockRound, + ); + vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue(null); + + handler = new DGLiteRegisteredHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + projectRepository: mockProjectRepository, + metadataProvider: mockMetadataProvider, + }); + + const result = await handler.handle(); + expect(result).toHaveLength(1); + const changeset = result[0] as { + type: "InsertApplication"; + args: NewApplication; + }; + expect(changeset.args.metadata).toBeNull(); + }); + + it("throws ProjectNotFound if project is not found", async () => { + mockEvent = createMockEvent(); + vi.spyOn(mockProjectRepository, "getProjectByAnchorOrThrow").mockRejectedValue( + new ProjectNotFound(chainId, mockEvent.params.recipientId), + ); + + handler = new DGLiteRegisteredHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + projectRepository: mockProjectRepository, + metadataProvider: mockMetadataProvider, + }); + + await expect(handler.handle()).rejects.toThrow(ProjectNotFound); + }); + + it("throws RoundNotFound if round is not found", async () => { + mockEvent = createMockEvent(); + const mockProject = { + id: "project1", + anchorAddress: mockEvent.params.recipientId, + } as unknown as Project; + + vi.spyOn(mockProjectRepository, "getProjectByAnchorOrThrow").mockResolvedValue(mockProject); + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddressOrThrow").mockRejectedValue( + new RoundNotFound(chainId, mockEvent.strategyId), + ); + + handler = new DGLiteRegisteredHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + projectRepository: mockProjectRepository, + metadataProvider: mockMetadataProvider, + }); + + await expect(handler.handle()).rejects.toThrow(RoundNotFound); + }); +}); diff --git a/packages/processors/test/strategy/directGrantsLite/handlers/timestampsUpdated.handler.spec.ts b/packages/processors/test/strategy/directGrantsLite/handlers/timestampsUpdated.handler.spec.ts new file mode 100644 index 0000000..02ba4a6 --- /dev/null +++ b/packages/processors/test/strategy/directGrantsLite/handlers/timestampsUpdated.handler.spec.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { IRoundRepository, Round, RoundNotFound } from "@grants-stack-indexer/repository"; +import { ChainId, DeepPartial, mergeDeep, ProcessorEvent } from "@grants-stack-indexer/shared"; + +import { DGLiteTimestampsUpdatedHandler } from "../../../../src/processors/strategy/directGrantsLite/handlers/timestampsUpdated.handler.js"; + +function createMockEvent( + overrides: DeepPartial> = {}, +): ProcessorEvent<"Strategy", "TimestampsUpdated"> { + const defaultEvent: ProcessorEvent<"Strategy", "TimestampsUpdated"> = { + params: { + startTime: "1704067200", // 2024-01-01 00:00:00 + endTime: "1704153600", // 2024-01-02 00:00:00 + sender: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + }, + eventName: "TimestampsUpdated", + 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); +} + +describe("DGLiteTimestampsUpdatedHandler", () => { + let handler: DGLiteTimestampsUpdatedHandler; + let mockRoundRepository: IRoundRepository; + let mockEvent: ProcessorEvent<"Strategy", "TimestampsUpdated">; + const chainId = 10 as ChainId; + + beforeEach(() => { + mockRoundRepository = { + getRoundByStrategyAddressOrThrow: vi.fn(), + } as unknown as IRoundRepository; + }); + + it("handles a valid timestamps update event", async () => { + mockEvent = createMockEvent(); + const mockRound = { id: "round1" } as unknown as Round; + + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddressOrThrow").mockResolvedValue( + mockRound, + ); + + handler = new DGLiteTimestampsUpdatedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + }); + + const result = await handler.handle(); + + const updateRound = result[0] as { + type: "UpdateRound"; + args: { + chainId: ChainId; + roundId: string; + round: { + applicationsStartTime: Date; + applicationsEndTime: Date; + }; + }; + }; + + expect(updateRound).toEqual({ + type: "UpdateRound", + args: { + chainId, + roundId: "round1", + round: { + applicationsStartTime: new Date("2024-01-01T00:00:00.000Z"), + applicationsEndTime: new Date("2024-01-02T00:00:00.000Z"), + }, + }, + }); + }); + + it("throws RoundNotFound if round is not found", async () => { + mockEvent = createMockEvent(); + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddressOrThrow").mockRejectedValue( + new RoundNotFound(chainId, mockEvent.strategyId), + ); + + handler = new DGLiteTimestampsUpdatedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + }); + + await expect(handler.handle()).rejects.toThrow(RoundNotFound); + }); +}); diff --git a/packages/processors/test/strategy/directGrantsLite/handlers/updatedRegistration.handler.spec.ts b/packages/processors/test/strategy/directGrantsLite/handlers/updatedRegistration.handler.spec.ts new file mode 100644 index 0000000..055897b --- /dev/null +++ b/packages/processors/test/strategy/directGrantsLite/handlers/updatedRegistration.handler.spec.ts @@ -0,0 +1,321 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { IMetadataProvider } from "@grants-stack-indexer/metadata"; +import { + Application, + ApplicationNotFound, + IApplicationRepository, + IProjectRepository, + IRoundRepository, + PartialApplication, + Project, + ProjectNotFound, + Round, + RoundNotFound, +} from "@grants-stack-indexer/repository"; +import { + ChainId, + DeepPartial, + Logger, + mergeDeep, + ProcessorEvent, +} from "@grants-stack-indexer/shared"; + +import { DGLiteUpdatedRegistrationHandler } from "../../../../src/processors/strategy/directGrantsLite/handlers/updatedRegistration.handler.js"; + +function createMockEvent( + overrides: DeepPartial> = {}, +): ProcessorEvent<"Strategy", "UpdatedRegistrationWithStatus"> { + const defaultEvent: ProcessorEvent<"Strategy", "UpdatedRegistrationWithStatus"> = { + params: { + recipientId: "0x1234567890123456789012345678901234567890", + status: "2", + data: "0x0000000000000000000000002c7296a5ec0539f0a018c7176c97c92a9c44e2b4000000000000000000000000e7eb5d2b5b188777df902e89c54570e7ef4f59ce000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000003b6261666b72656967796334336366696e786c6e6168713561617773676869626574763675737273376b6b78663776786d7a626a79726f37366977790000000000", + sender: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + }, + eventName: "UpdatedRegistrationWithStatus", + 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); +} + +describe("DGLiteUpdatedRegistrationHandler", () => { + let handler: DGLiteUpdatedRegistrationHandler; + let mockRoundRepository: IRoundRepository; + let mockApplicationRepository: IApplicationRepository; + let mockProjectRepository: IProjectRepository; + let mockMetadataProvider: IMetadataProvider; + let mockLogger: Logger; + let mockEvent: ProcessorEvent<"Strategy", "UpdatedRegistrationWithStatus">; + const chainId = 10 as ChainId; + + beforeEach(() => { + mockRoundRepository = { + getRoundByStrategyAddressOrThrow: vi.fn(), + } as unknown as IRoundRepository; + mockApplicationRepository = { + getApplicationByAnchorAddressOrThrow: vi.fn(), + } as unknown as IApplicationRepository; + mockProjectRepository = { + getProjectByAnchorOrThrow: vi.fn(), + } as unknown as IProjectRepository; + mockMetadataProvider = { + getMetadata: vi.fn(), + } as unknown as IMetadataProvider; + mockLogger = { + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + } as unknown as Logger; + }); + + it("handles a valid registration update event", async () => { + mockEvent = createMockEvent(); + const mockProject = { + id: "project1", + anchorAddress: mockEvent.params.recipientId, + } as Project; + const mockRound = { id: "round1" } as Round; + const mockApplication = { + id: "app1", + status: "PENDING", + statusSnapshots: [], + statusUpdatedAtBlock: 12344n, + } as unknown as Application; + const mockMetadata = { name: "Test Project" }; + + vi.spyOn(mockProjectRepository, "getProjectByAnchorOrThrow").mockResolvedValue(mockProject); + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddressOrThrow").mockResolvedValue( + mockRound, + ); + vi.spyOn( + mockApplicationRepository, + "getApplicationByAnchorAddressOrThrow", + ).mockResolvedValue(mockApplication); + vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue(mockMetadata); + + handler = new DGLiteUpdatedRegistrationHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + projectRepository: mockProjectRepository, + metadataProvider: mockMetadataProvider, + logger: mockLogger, + }); + + const result = await handler.handle(); + + expect(result[0]).toEqual({ + type: "UpdateApplication", + args: { + chainId, + roundId: "round1", + applicationId: "app1", + application: { + ...mockApplication, + metadata: mockMetadata, + metadataCid: "bafkreigyc43cfinxlnahq5aawsghibetv6usrs7kkxf7vxmzbjyro76iwy", + status: "APPROVED", + statusUpdatedAtBlock: BigInt(mockEvent.blockNumber), + statusSnapshots: [ + { + status: "APPROVED", + updatedAtBlock: mockEvent.blockNumber.toString(), + updatedAt: new Date(mockEvent.blockTimestamp), + }, + ], + }, + }, + }); + }); + + it("returns empty array for invalid status", async () => { + mockEvent = createMockEvent({ params: { status: "4" } }); // Invalid status + + handler = new DGLiteUpdatedRegistrationHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + projectRepository: mockProjectRepository, + metadataProvider: mockMetadataProvider, + logger: mockLogger, + }); + + const result = await handler.handle(); + expect(result).toEqual([]); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining( + `[DGLiteUpdatedRegistrationHandler] Invalid status: ${mockEvent.params.status}`, + ), + ); + }); + + it("throws ProjectNotFound if project is not found", async () => { + mockEvent = createMockEvent(); + vi.spyOn(mockProjectRepository, "getProjectByAnchorOrThrow").mockRejectedValue( + new ProjectNotFound(chainId, mockEvent.params.recipientId), + ); + + handler = new DGLiteUpdatedRegistrationHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + projectRepository: mockProjectRepository, + metadataProvider: mockMetadataProvider, + logger: mockLogger, + }); + + await expect(handler.handle()).rejects.toThrow(ProjectNotFound); + }); + + it("throws RoundNotFound if round is not found", async () => { + mockEvent = createMockEvent(); + const mockProject = { + id: "project1", + anchorAddress: mockEvent.params.recipientId, + } as Project; + + vi.spyOn(mockProjectRepository, "getProjectByAnchorOrThrow").mockResolvedValue(mockProject); + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddressOrThrow").mockRejectedValue( + new RoundNotFound(chainId, mockEvent.srcAddress), + ); + + handler = new DGLiteUpdatedRegistrationHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + projectRepository: mockProjectRepository, + metadataProvider: mockMetadataProvider, + logger: mockLogger, + }); + + await expect(handler.handle()).rejects.toThrow(RoundNotFound); + }); + it("throws ApplicationNotFound if application is not found", async () => { + mockEvent = createMockEvent(); + const mockProject = { + id: "project1", + anchorAddress: mockEvent.params.recipientId, + } as Project; + const mockRound = { id: "round1" } as Round; + + vi.spyOn(mockProjectRepository, "getProjectByAnchorOrThrow").mockResolvedValue(mockProject); + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddressOrThrow").mockResolvedValue( + mockRound, + ); + vi.spyOn( + mockApplicationRepository, + "getApplicationByAnchorAddressOrThrow", + ).mockRejectedValue( + new ApplicationNotFound(chainId, mockRound.id, mockEvent.params.recipientId), + ); + + handler = new DGLiteUpdatedRegistrationHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + projectRepository: mockProjectRepository, + metadataProvider: mockMetadataProvider, + logger: mockLogger, + }); + + await expect(handler.handle()).rejects.toThrow(ApplicationNotFound); + }); + + it("handles undefined metadata", async () => { + mockEvent = createMockEvent(); + const mockProject = { + id: "project1", + anchorAddress: mockEvent.params.recipientId, + } as Project; + const mockRound = { id: "round1" } as Round; + const mockApplication = { + id: "app1", + status: "PENDING", + statusSnapshots: [], + statusUpdatedAtBlock: 12344n, + } as unknown as Application; + + vi.spyOn(mockProjectRepository, "getProjectByAnchorOrThrow").mockResolvedValue(mockProject); + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddressOrThrow").mockResolvedValue( + mockRound, + ); + vi.spyOn( + mockApplicationRepository, + "getApplicationByAnchorAddressOrThrow", + ).mockResolvedValue(mockApplication); + vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue(undefined); + + handler = new DGLiteUpdatedRegistrationHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + projectRepository: mockProjectRepository, + metadataProvider: mockMetadataProvider, + logger: mockLogger, + }); + + const result = await handler.handle(); + + expect(result).toBeDefined(); + expect(result).toHaveLength(1); + const changeset = result[0] as { + type: "UpdateApplication"; + args: { application: PartialApplication }; + }; + expect(changeset.args.application.metadata).toBeNull(); + }); + + it("doesn't add status snapshot if status hasn't changed", async () => { + mockEvent = createMockEvent({ params: { status: "1" } }); // 1 is PENDING + const mockProject = { + id: "project1", + anchorAddress: mockEvent.params.recipientId, + } as Project; + const mockRound = { id: "round1" } as Round; + const mockApplication = { + id: "app1", + status: "PENDING", // Same as new status + statusSnapshots: [ + { + status: "PENDING", + updatedAtBlock: "12344", + updatedAt: new Date(1000000000), + }, + ], + statusUpdatedAtBlock: 12344n, + } as Application; + + vi.spyOn(mockProjectRepository, "getProjectByAnchorOrThrow").mockResolvedValue(mockProject); + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddressOrThrow").mockResolvedValue( + mockRound, + ); + vi.spyOn( + mockApplicationRepository, + "getApplicationByAnchorAddressOrThrow", + ).mockResolvedValue(mockApplication); + vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue(null); + + handler = new DGLiteUpdatedRegistrationHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + projectRepository: mockProjectRepository, + metadataProvider: mockMetadataProvider, + logger: mockLogger, + }); + + const result = await handler.handle(); + const changeset = result[0] as { + type: "UpdateApplication"; + args: { application: { statusSnapshots: unknown[] } }; + }; + expect(changeset.args.application.statusSnapshots).toHaveLength(1); + }); +}); diff --git a/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/helpers/decoder.spec.ts b/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/helpers/decoder.spec.ts index 5d583ed..02251a3 100644 --- a/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/helpers/decoder.spec.ts +++ b/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/helpers/decoder.spec.ts @@ -9,7 +9,7 @@ import { decodeDVMDExtendedApplicationData, } from "../../../../src/processors/strategy/helpers/index.js"; -describe("decodeDVMDApplicationData", () => { +describe("decodeDVMDExtendedApplicationData", () => { it("correctly decodes the encoded data", () => { const encodedData = "0x0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000001000000000000000000000000002c7296a5ec0539f0a018c7176c97c92a9c44e2b4000000000000000000000000e7eb5d2b5b188777df902e89c54570e7ef4f59ce000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000003b6261666b72656967796334336366696e786c6e6168713561617773676869626574763675737273376b6b78663776786d7a626a79726f37366977790000000000";