diff --git a/apps/indexer/config.yaml b/apps/indexer/config.yaml index 0e772ec..f825a01 100644 --- a/apps/indexer/config.yaml +++ b/apps/indexer/config.yaml @@ -15,10 +15,10 @@ contracts: handler: src/handlers/Allo.ts events: - event: PoolCreated(uint256 indexed poolId, bytes32 indexed profileId, address strategy, address token, uint256 amount, (uint256,string) metadata) - - event: RoleGranted(uint64 indexed roleId, address indexed account, uint32 delay, uint48 since, bool newMember) - event: PoolMetadataUpdated(uint256 indexed poolId, (uint256,string) metadata) - event: PoolFunded(uint256 indexed poolId, uint256 amount, uint256 fee) - - event: RoleRevoked(uint64 indexed roleId, address indexed account) + - event: RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + - event: RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); - name: Registry handler: src/handlers/Registry.ts events: @@ -26,6 +26,9 @@ contracts: - event: ProfileMetadataUpdated(bytes32 indexed profileId, (uint256,string) metadata) - event: ProfileNameUpdated(bytes32 indexed profileId, string name, address anchor) - event: ProfileOwnerUpdated(bytes32 indexed profileId, address owner) + - event: RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + - event: RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); + - name: Strategy handler: src/handlers/Strategy.ts events: diff --git a/apps/indexer/package.json b/apps/indexer/package.json index 180a03c..c47c442 100644 --- a/apps/indexer/package.json +++ b/apps/indexer/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "chai": "4.3.10", - "envio": "2.4.1", + "envio": "2.5.2", "ethers": "6.8.0", "yaml": "2.5.1" }, diff --git a/apps/indexer/pnpm-lock.yaml b/apps/indexer/pnpm-lock.yaml index c60513e..f000935 100644 --- a/apps/indexer/pnpm-lock.yaml +++ b/apps/indexer/pnpm-lock.yaml @@ -11,8 +11,8 @@ importers: specifier: 4.3.10 version: 4.3.10 envio: - specifier: 2.4.1 - version: 2.4.1 + specifier: 2.5.2 + version: 2.5.2 ethers: specifier: 6.8.0 version: 6.8.0 @@ -895,42 +895,42 @@ packages: integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==, } - envio-darwin-arm64@2.4.1: + envio-darwin-arm64@2.5.2: resolution: { - integrity: sha512-uwwKhThFH9dZhQKd6kmleeTyl//GlJGvhFXuhuJ7xn93oGbOOy3hsoYfJZSbVmb0SSSJkgWdAKN82mYHNfdVwQ==, + integrity: sha512-O/+sgImwhQJXlaypMgESWRUzVlkA3dH+2JpdI1LcIVuCpRN/OvBUbRxDHOU79uxeGXvUeeCn1zSGukod6hxG7g==, } cpu: [arm64] os: [darwin] - envio-darwin-x64@2.4.1: + envio-darwin-x64@2.5.2: resolution: { - integrity: sha512-SKqI2hQmjmqlDYCwFaEK6TDJMLL/UE/wWlUIzsAY5drJQTwAL0mBXzUfQM8JbMgDoxeWyKNegUV1vCuTmCDREw==, + integrity: sha512-/K1XCoIXnjTG4LWUqUUPpSdIBFC1tsryQLp1c040YNnsqVmE9EJufclUL0dc2Rf8SNqC/XgNPGWlndcBDSJWFw==, } cpu: [x64] os: [darwin] - envio-linux-arm64@2.4.1: + envio-linux-arm64@2.5.2: resolution: { - integrity: sha512-ujUUifyKM4kHkHfcudeH8iIf5xPiW9t+IzMCgY4gug2fs3Z3biyhmeaylYNpvNiIoQ58vZqJ23fBKaUn7CPPog==, + integrity: sha512-OqrxwwdfN0mcipnq8TQYQL9yZHoHgr7QB8tILjkjtRhz9LOhyjjqlwmNKiDjbpQ8nR2odORKvTlJGEvHb2AO5A==, } cpu: [arm64] os: [linux] - envio-linux-x64@2.4.1: + envio-linux-x64@2.5.2: resolution: { - integrity: sha512-aeVQ9I4zum6X+kBeJrFev/bTDePZRSlt6HytcnqJd7+iWagQggUejhcrSccp2eRSeQ95sGp+F+lQOFfWJHyEJQ==, + integrity: sha512-qpJDjNeY8b8o1v47ulQgu0RGiN8T3BTg2veCRQHeHgpBqh1f7otPEqX2apADo5K+wYlUsH9HXJfbf25SLEQBdw==, } cpu: [x64] os: [linux] - envio@2.4.1: + envio@2.5.2: resolution: { - integrity: sha512-GsHv4S/mAzr//XCyTHFBnfd1CZFlfaG+wcWJkpeguzhnttNSOMJXCR4UPG0PxV2cZYiA3eVrOSPn58fTzk2G5g==, + integrity: sha512-fWHmggBijehdtOkSErHgASUiEhPwDi4ouxOdgHsi0B/DkabgwTYwa8Z+pBhf324exHhGRhAVxZwBtoKI98MUGA==, } hasBin: true @@ -2956,24 +2956,24 @@ snapshots: dependencies: once: 1.4.0 - envio-darwin-arm64@2.4.1: + envio-darwin-arm64@2.5.2: optional: true - envio-darwin-x64@2.4.1: + envio-darwin-x64@2.5.2: optional: true - envio-linux-arm64@2.4.1: + envio-linux-arm64@2.5.2: optional: true - envio-linux-x64@2.4.1: + envio-linux-x64@2.5.2: optional: true - envio@2.4.1: + envio@2.5.2: optionalDependencies: - envio-darwin-arm64: 2.4.1 - envio-darwin-x64: 2.4.1 - envio-linux-arm64: 2.4.1 - envio-linux-x64: 2.4.1 + envio-darwin-arm64: 2.5.2 + envio-darwin-x64: 2.5.2 + envio-linux-arm64: 2.5.2 + envio-linux-x64: 2.5.2 es-define-property@1.0.0: dependencies: diff --git a/apps/indexer/src/handlers/Registry.ts b/apps/indexer/src/handlers/Registry.ts index 722ff0e..78e19b5 100644 --- a/apps/indexer/src/handlers/Registry.ts +++ b/apps/indexer/src/handlers/Registry.ts @@ -1,6 +1,12 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { Registry } from "../../generated"; +// Handler for RoleGranted event +Registry.RoleGranted.handler(async ({}) => {}); + +// Handler for RoleRevoked event +Registry.RoleRevoked.handler(async ({}) => {}); + // Handler for ProfileCreated event Registry.ProfileCreated.handler(async ({}) => {}); diff --git a/packages/indexer-client/test/unit/envioIndexerClient.spec.ts b/packages/indexer-client/test/unit/envioIndexerClient.spec.ts index a6d2d90..c15dd90 100644 --- a/packages/indexer-client/test/unit/envioIndexerClient.spec.ts +++ b/packages/indexer-client/test/unit/envioIndexerClient.spec.ts @@ -52,7 +52,10 @@ describe("EnvioIndexerClient", () => { srcAddress: "0x1234567890123456789012345678901234567890", logIndex: 0, params: { contractAddress: "0x1234" }, - transactionFields: { hash: "0x1234", transactionIndex: 0 }, + transactionFields: { + hash: "0x123", + transactionIndex: 1, + }, }, ]; diff --git a/packages/indexer-client/tsconfig.json b/packages/indexer-client/tsconfig.json index 66bb87a..21c1c5b 100644 --- a/packages/indexer-client/tsconfig.json +++ b/packages/indexer-client/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.json", - "include": ["src/**/*"] + "include": ["src/**/*", "test/**/*"] } diff --git a/packages/processors/src/allo/handlers/poolCreated.handler.ts b/packages/processors/src/allo/handlers/poolCreated.handler.ts index db79ff8..e46f915 100644 --- a/packages/processors/src/allo/handlers/poolCreated.handler.ts +++ b/packages/processors/src/allo/handlers/poolCreated.handler.ts @@ -7,10 +7,10 @@ import { getToken } from "@grants-stack-indexer/shared/dist/src/internal.js"; import type { IEventHandler, ProcessorDependencies, StrategyTimings } from "../../internal.js"; import { getRoundRoles } from "../../helpers/roles.js"; -import { RoundMetadataSchema } from "../../helpers/schemas.js"; import { extractStrategyFromId, getStrategyTimings } from "../../helpers/strategy.js"; import { calculateAmountInUsd } from "../../helpers/tokenMath.js"; import { TokenPriceNotFoundError } from "../../internal.js"; +import { RoundMetadataSchema } from "../../schemas/index.js"; type Dependencies = Pick< ProcessorDependencies, diff --git a/packages/processors/src/interfaces/factory.interface.ts b/packages/processors/src/interfaces/factory.interface.ts new file mode 100644 index 0000000..3c82fbc --- /dev/null +++ b/packages/processors/src/interfaces/factory.interface.ts @@ -0,0 +1,17 @@ +import { + ChainId, + ContractName, + ContractToEventName, + ProtocolEvent, +} from "@grants-stack-indexer/shared"; + +import { ProcessorDependencies } from "../types/processor.types.js"; +import { IEventHandler } from "./index.js"; + +export interface IEventHandlerFactory> { + createHandler( + event: ProtocolEvent, + chainId: ChainId, + dependencies: ProcessorDependencies, + ): IEventHandler; +} diff --git a/packages/processors/src/interfaces/index.ts b/packages/processors/src/interfaces/index.ts index 057ac8d..c8e38e6 100644 --- a/packages/processors/src/interfaces/index.ts +++ b/packages/processors/src/interfaces/index.ts @@ -1,2 +1,3 @@ export * from "./processor.interface.js"; +export * from "./factory.interface.js"; export * from "./eventHandler.interface.js"; diff --git a/packages/processors/src/registry/handlers/index.ts b/packages/processors/src/registry/handlers/index.ts new file mode 100644 index 0000000..59fea08 --- /dev/null +++ b/packages/processors/src/registry/handlers/index.ts @@ -0,0 +1,2 @@ +export * from "./profileCreated.handler.js"; +export * from "./roleGranted.handler.js"; diff --git a/packages/processors/src/registry/handlers/profileCreated.handler.ts b/packages/processors/src/registry/handlers/profileCreated.handler.ts new file mode 100644 index 0000000..9d89c83 --- /dev/null +++ b/packages/processors/src/registry/handlers/profileCreated.handler.ts @@ -0,0 +1,127 @@ +import { getAddress } from "viem"; + +import { Changeset, ProjectType } from "@grants-stack-indexer/repository"; +import { ChainId, ProtocolEvent } from "@grants-stack-indexer/shared"; + +import { IEventHandler, ProcessorDependencies } from "../../internal.js"; +import { ProjectMetadata, ProjectMetadataSchema } from "../../schemas/projectMetadata.js"; + +type Dependencies = Pick< + ProcessorDependencies, + "projectRepository" | "evmProvider" | "metadataProvider" +>; +/** + * Handles the ProfileCreated event for the Registry contract from Allo protocol. + */ +export class ProfileCreatedHandler implements IEventHandler<"Registry", "ProfileCreated"> { + constructor( + readonly event: ProtocolEvent<"Registry", "ProfileCreated">, + readonly chainId: ChainId, + private dependencies: Dependencies, + ) {} + async handle(): Promise { + const { metadataProvider, evmProvider, projectRepository } = this.dependencies; + const profileId = this.event.params.profileId; + const metadataCid = this.event.params.metadata[1]; + const metadata = await metadataProvider.getMetadata(metadataCid); + + const parsedMetadata = ProjectMetadataSchema.safeParse(metadata); + + let projectType: ProjectType = "canonical"; + let isProgram = false; + let metadataValue = null; + + if (parsedMetadata.success) { + projectType = this.getProjectTypeFromMetadata(parsedMetadata.data); + isProgram = parsedMetadata.data.type === "program"; + metadataValue = parsedMetadata.data; + } else { + //TODO: Replace with logger + console.warn({ + msg: `ProfileCreated: Failed to parse metadata for profile ${profileId}`, + event: this.event, + metadataCid, + metadata, + }); + } + + const createdBy = + this.event.transactionFields.from ?? + (await evmProvider.getTransaction(this.event.transactionFields.hash)).from; + const programTags = isProgram ? ["program"] : []; + + const changes: Changeset[] = [ + { + type: "InsertProject", + args: { + project: { + tags: ["allo-v2", ...programTags], + chainId: this.chainId, + registryAddress: getAddress(this.event.srcAddress), + id: profileId, + name: this.event.params.name, + nonce: this.event.params.nonce, + anchorAddress: getAddress(this.event.params.anchor), + projectNumber: null, + metadataCid: metadataCid, + metadata: metadataValue, + createdByAddress: getAddress(createdBy), + createdAtBlock: BigInt(this.event.blockNumber), + updatedAtBlock: BigInt(this.event.blockNumber), + projectType, + }, + }, + }, + { + type: "InsertProjectRole", + args: { + projectRole: { + chainId: this.chainId, + projectId: this.event.params.profileId, + address: getAddress(this.event.params.owner), + role: "owner", + createdAtBlock: BigInt(this.event.blockNumber), + }, + }, + }, + ]; + + const pendingProjectRoles = await projectRepository.getPendingProjectRolesByRole( + this.chainId, + profileId, + ); + + if (pendingProjectRoles.length !== 0) { + for (const role of pendingProjectRoles) { + changes.push({ + type: "InsertProjectRole", + args: { + projectRole: { + chainId: this.chainId, + projectId: profileId, + address: getAddress(role.address), + role: "member", + createdAtBlock: BigInt(this.event.blockNumber), + }, + }, + }); + } + + changes.push({ + type: "DeletePendingProjectRoles", + args: { ids: pendingProjectRoles.map((r) => r.id!) }, + }); + } + + return changes; + } + + private getProjectTypeFromMetadata(metadata: ProjectMetadata): ProjectType { + // if the metadata contains a canonical reference, it's a linked project + if ("canonical" in metadata) { + return "linked"; + } + + return "canonical"; + } +} diff --git a/packages/processors/src/registry/handlers/roleGranted.handler.ts b/packages/processors/src/registry/handlers/roleGranted.handler.ts new file mode 100644 index 0000000..1e8c22e --- /dev/null +++ b/packages/processors/src/registry/handlers/roleGranted.handler.ts @@ -0,0 +1,64 @@ +import { getAddress } from "viem"; + +import { Changeset } from "@grants-stack-indexer/repository"; +import { ALLO_OWNER_ROLE, ChainId, ProtocolEvent } from "@grants-stack-indexer/shared"; + +import { IEventHandler } from "../../internal.js"; +import { ProcessorDependencies } from "../../types/processor.types.js"; + +/** + * Handles the RoleGranted event for the Registry contract from Allo protocol. + */ +export class RoleGrantedHandler implements IEventHandler<"Registry", "RoleGranted"> { + constructor( + readonly event: ProtocolEvent<"Registry", "RoleGranted">, + readonly chainId: ChainId, + private readonly dependencies: ProcessorDependencies, + ) {} + async handle(): Promise { + const { projectRepository } = this.dependencies; + const role = this.event.params.role.toLowerCase(); + if (role === ALLO_OWNER_ROLE) { + return []; + } + + const account = getAddress(this.event.params.account); + const project = await projectRepository.getProjectById(this.chainId, role); + + // The member role for an Allo V2 profile, is the profileId itself. + // If a project exist with that id, we create the member role + // If it doesn't exist we create a pending project role. This can happen + // when a new project is created, since in Allo V2 the RoleGranted event for a member is + // emitted before the ProfileCreated event. + if (project) { + return [ + { + type: "InsertProjectRole", + args: { + projectRole: { + chainId: this.chainId, + projectId: project.id, + address: account, + role: "member", + createdAtBlock: BigInt(this.event.blockNumber), + }, + }, + }, + ]; + } + + return [ + { + type: "InsertPendingProjectRole", + args: { + pendingProjectRole: { + chainId: this.chainId, + role: role, + address: account, + createdAtBlock: BigInt(this.event.blockNumber), + }, + }, + }, + ]; + } +} diff --git a/packages/processors/src/registry/index.ts b/packages/processors/src/registry/index.ts new file mode 100644 index 0000000..ea4498b --- /dev/null +++ b/packages/processors/src/registry/index.ts @@ -0,0 +1 @@ +export * from "./registry.processor.js"; diff --git a/packages/processors/src/registry/registry.processor.ts b/packages/processors/src/registry/registry.processor.ts new file mode 100644 index 0000000..d96e363 --- /dev/null +++ b/packages/processors/src/registry/registry.processor.ts @@ -0,0 +1,34 @@ +import { Changeset } from "@grants-stack-indexer/repository"; +import { ChainId, ProtocolEvent, RegistryEvent } from "@grants-stack-indexer/shared"; + +import type { IProcessor } from "../internal.js"; +import { UnsupportedEventException } from "../internal.js"; +import { ProcessorDependencies } from "../types/processor.types.js"; +import { ProfileCreatedHandler, RoleGrantedHandler } from "./handlers/index.js"; + +export class RegistryProcessor implements IProcessor<"Registry", RegistryEvent> { + constructor( + private readonly chainId: ChainId, + private readonly dependencies: ProcessorDependencies, + ) {} + + async process(event: ProtocolEvent<"Registry", RegistryEvent>): Promise { + //TODO: Implement robust error handling and retry logic + switch (event.eventName) { + case "RoleGranted": + return new RoleGrantedHandler( + event as ProtocolEvent<"Registry", "RoleGranted">, + this.chainId, + this.dependencies, + ).handle(); + case "ProfileCreated": + return new ProfileCreatedHandler( + event as ProtocolEvent<"Registry", "ProfileCreated">, + this.chainId, + this.dependencies, + ).handle(); + default: + throw new UnsupportedEventException("Registry", event.eventName); + } + } +} diff --git a/packages/processors/src/schemas/index.ts b/packages/processors/src/schemas/index.ts new file mode 100644 index 0000000..d7789f5 --- /dev/null +++ b/packages/processors/src/schemas/index.ts @@ -0,0 +1,2 @@ +export * from "./projectMetadata.js"; +export * from "./roundMetadata.js"; diff --git a/packages/processors/src/schemas/projectMetadata.ts b/packages/processors/src/schemas/projectMetadata.ts new file mode 100644 index 0000000..1543c31 --- /dev/null +++ b/packages/processors/src/schemas/projectMetadata.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +export const ProjectMetadataSchema = z.union([ + z + .object({ + title: z.string(), + description: z.string(), + }) + .passthrough() + .transform((data) => ({ type: "project" as const, ...data })), + z + .object({ + canonical: z.object({ + registryAddress: z.string(), + chainId: z.coerce.number(), + }), + }) + .transform((data) => ({ type: "project" as const, ...data })), + z.object({ + type: z.literal("program"), + name: z.string(), + }), + z + .object({ + name: z.string(), + }) + .transform((data) => ({ type: "program" as const, ...data })), +]); + +export type ProjectMetadata = z.infer; diff --git a/packages/processors/src/helpers/schemas.ts b/packages/processors/src/schemas/roundMetadata.ts similarity index 100% rename from packages/processors/src/helpers/schemas.ts rename to packages/processors/src/schemas/roundMetadata.ts diff --git a/packages/processors/test/index.spec.ts b/packages/processors/test/index.spec.ts deleted file mode 100644 index e383c0a..0000000 --- a/packages/processors/test/index.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { describe, it } from "vitest"; - -describe("dummy", () => { - it.skip("dummy", () => {}); -}); diff --git a/packages/processors/test/registry/handlers/profileCreated.handler.spec.ts b/packages/processors/test/registry/handlers/profileCreated.handler.spec.ts new file mode 100644 index 0000000..d2b1828 --- /dev/null +++ b/packages/processors/test/registry/handlers/profileCreated.handler.spec.ts @@ -0,0 +1,356 @@ +import { getAddress } from "viem"; +import { afterEach, beforeEach, describe, expect, it, Mock, 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 { Bytes32String, ChainId, ProtocolEvent } from "@grants-stack-indexer/shared"; + +import { ProcessorDependencies } from "../../../src/internal.js"; +import { ProfileCreatedHandler } from "../../../src/registry/handlers/index.js"; + +describe("ProfileCreatedHandler", () => { + let mockEvent: ProtocolEvent<"Registry", "ProfileCreated">; + let mockChainId: ChainId; + let mockDependencies: ProcessorDependencies; + const mockedTxHash = "0x6e5a7115323ac1712f7c27adff46df2216324a4ad615a8c9ce488c32a1f3a035"; + const mockedAddress = "0x48f33AE41E1762e1688125C4f1C536B1491dF803"; + + beforeEach(() => { + mockEvent = { + blockTimestamp: 123123123, + chainId: 10 as ChainId, + contractName: "Registry", + eventName: "ProfileCreated", + logIndex: 10, + srcAddress: mockedAddress, + transactionFields: { + hash: mockedTxHash, + transactionIndex: 10, + }, + blockNumber: 123, + params: { + profileId: "0x1231231234" as Bytes32String, + metadata: [1n, "cid-metadata"], + name: "Test Project", + nonce: 1n, + anchor: mockedAddress, + owner: mockedAddress, + }, + } as ProtocolEvent<"Registry", "ProfileCreated">; + + mockChainId = 10 as ChainId; + + mockDependencies = { + projectRepository: { + getPendingProjectRolesByRole: vi.fn().mockResolvedValue([]), + } as unknown as IProjectReadRepository, + evmProvider: { + getTransaction: vi.fn().mockResolvedValue({ from: mockedAddress }), + } as unknown as EvmProvider, + pricingProvider: { + getTokenPrice: vi.fn(), + } as unknown as IPricingProvider, + metadataProvider: { + getMetadata: vi.fn(), + } as unknown as IMetadataProvider, + roundRepository: {} as unknown as IRoundReadRepository, + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("handles ProfileCreated event and return the correct changeset", async () => { + (mockDependencies.metadataProvider.getMetadata as Mock).mockResolvedValueOnce({ + type: "program", + name: "Test Project", + }); + const profileCreatedHandler = new ProfileCreatedHandler( + mockEvent, + mockChainId, + mockDependencies, + ); + + const result = await profileCreatedHandler.handle(); + + expect(result).toEqual([ + { + type: "InsertProject", + args: { + project: { + tags: ["allo-v2", "program"], + chainId: mockChainId, + registryAddress: mockEvent.srcAddress, + id: mockEvent.params.profileId, + name: "Test Project", + nonce: 1n, + anchorAddress: mockEvent.params.anchor, + projectNumber: null, + metadataCid: mockEvent.params.metadata[1], + metadata: { type: "program", name: "Test Project" }, + createdByAddress: mockEvent.srcAddress, + createdAtBlock: BigInt(123), + updatedAtBlock: BigInt(123), + projectType: "canonical", + }, + }, + }, + { + type: "InsertProjectRole", + args: { + projectRole: { + chainId: mockChainId, + projectId: mockEvent.params.profileId, + address: mockEvent.params.owner, + role: "owner", + createdAtBlock: BigInt(123), + }, + }, + }, + ]); + + expect( + mockDependencies.projectRepository.getPendingProjectRolesByRole, + ).toHaveBeenCalledWith(mockChainId, mockEvent.params.profileId); + expect(mockDependencies.evmProvider.getTransaction).toHaveBeenCalledWith( + mockEvent.transactionFields.hash, + ); + expect(mockDependencies.metadataProvider.getMetadata).toHaveBeenCalledWith( + mockEvent.params.metadata[1], + ); + }); + + it("logs a warning if metadata parsing fails", async () => { + (mockDependencies.metadataProvider.getMetadata as Mock).mockResolvedValueOnce({ + invalid: "data", + }); + + const handler = new ProfileCreatedHandler(mockEvent, mockChainId, mockDependencies); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await handler.handle(); + + expect(consoleWarnSpy).toHaveBeenCalledWith({ + msg: `ProfileCreated: Failed to parse metadata for profile ${mockEvent.params.profileId}`, + event: mockEvent, + metadataCid: "cid-metadata", + metadata: { invalid: "data" }, + }); + }); + + it("returns an null metadata on changeset parsing fails", async () => { + (mockDependencies.metadataProvider.getMetadata as Mock).mockResolvedValueOnce({ + invalid: "data", + }); + + const handler = new ProfileCreatedHandler(mockEvent, mockChainId, mockDependencies); + + const result = await handler.handle(); + + expect(mockDependencies.metadataProvider.getMetadata).toHaveBeenCalledWith( + mockEvent.params.metadata[1], + ); + expect(mockDependencies.evmProvider.getTransaction).toHaveBeenCalledWith( + mockEvent.transactionFields.hash, + ); + expect(result).toEqual([ + { + type: "InsertProject", + args: { + project: { + tags: ["allo-v2"], + chainId: mockChainId, + registryAddress: mockEvent.srcAddress, + id: mockEvent.params.profileId, + name: "Test Project", + nonce: 1n, + anchorAddress: mockEvent.params.anchor, + projectNumber: null, + metadataCid: mockEvent.params.metadata[1], + metadata: null, + createdByAddress: mockEvent.srcAddress, + createdAtBlock: BigInt(123), + updatedAtBlock: BigInt(123), + projectType: "canonical", + }, + }, + }, + { + type: "InsertProjectRole", + args: { + projectRole: { + chainId: mockChainId, + projectId: mockEvent.params.profileId, + address: mockEvent.params.owner, + role: "owner", + createdAtBlock: BigInt(123), + }, + }, + }, + ]); + }); + + it("includes pending project roles in the changeset", async () => { + ( + mockDependencies.projectRepository.getPendingProjectRolesByRole as Mock + ).mockResolvedValueOnce([{ id: "1", address: mockedAddress }]); + + const handler = new ProfileCreatedHandler(mockEvent, mockChainId, mockDependencies); + const result = await handler.handle(); + + expect(result).toContainEqual({ + type: "InsertProjectRole", + args: { + projectRole: { + chainId: mockChainId, + projectId: mockEvent.params.profileId, + address: getAddress(mockedAddress), + role: "member", + createdAtBlock: BigInt(123), + }, + }, + }); + expect(result).toContainEqual({ + type: "DeletePendingProjectRoles", + args: { ids: ["1"] }, + }); + }); + + it("throws an error if getTransaction fails", async () => { + (mockDependencies.evmProvider.getTransaction as Mock).mockRejectedValueOnce( + new Error("Transaction not found"), + ); + + const handler = new ProfileCreatedHandler(mockEvent, mockChainId, mockDependencies); + + await expect(handler.handle()).rejects.toThrow("Transaction not found"); + expect(mockDependencies.evmProvider.getTransaction).toHaveBeenCalledWith( + mockEvent.transactionFields.hash, + ); + }); + + it("processes valid metadata successfully", async () => { + (mockDependencies.metadataProvider.getMetadata as Mock).mockResolvedValueOnce({ + canonical: { + registryAddress: "0x1234567890abcdef", + chainId: 1, + }, + }); + + const handler = new ProfileCreatedHandler(mockEvent, mockChainId, mockDependencies); + + const result = await handler.handle(); + + expect(result).toContainEqual({ + type: "InsertProject", + args: { + project: { + tags: ["allo-v2"], + chainId: mockChainId, + registryAddress: getAddress(mockEvent.srcAddress), + id: mockEvent.params.profileId, + name: "Test Project", + nonce: 1n, + anchorAddress: getAddress(mockEvent.params.anchor), + projectNumber: null, + metadataCid: mockEvent.params.metadata[1], + metadata: { + type: "project", + canonical: { + registryAddress: "0x1234567890abcdef", + chainId: 1, + }, + }, + createdByAddress: getAddress(mockedAddress), + createdAtBlock: BigInt(mockEvent.blockNumber), + updatedAtBlock: BigInt(mockEvent.blockNumber), + projectType: "linked", // As the metadata contains canonical, it should be "linked" + }, + }, + }); + }); + + it("returns correct changeset without pending roles", async () => { + (mockDependencies.metadataProvider.getMetadata as Mock).mockResolvedValueOnce({ + canonical: { + registryAddress: "0x1234567890abcdef", + chainId: 1, + }, + }); + + ( + mockDependencies.projectRepository.getPendingProjectRolesByRole as Mock + ).mockResolvedValueOnce([]); + + const handler = new ProfileCreatedHandler(mockEvent, mockChainId, mockDependencies); + const result = await handler.handle(); + + expect(result).toEqual([ + { + type: "InsertProject", + args: { + project: { + tags: ["allo-v2"], + chainId: mockChainId, + registryAddress: getAddress(mockEvent.srcAddress), + id: mockEvent.params.profileId, + name: "Test Project", + nonce: 1n, + anchorAddress: getAddress(mockEvent.params.anchor), + projectNumber: null, + metadataCid: mockEvent.params.metadata[1], + metadata: { + type: "project", + canonical: { + registryAddress: "0x1234567890abcdef", + chainId: 1, + }, + }, + createdByAddress: getAddress(mockedAddress), + createdAtBlock: BigInt(mockEvent.blockNumber), + updatedAtBlock: BigInt(mockEvent.blockNumber), + projectType: "linked", // As the metadata contains canonical, it should be "linked" + }, + }, + }, + { + type: "InsertProjectRole", + args: { + projectRole: { + chainId: mockChainId, + projectId: mockEvent.params.profileId, + address: getAddress(mockEvent.params.owner), + role: "owner", + createdAtBlock: BigInt(mockEvent.blockNumber), + }, + }, + }, + ]); + + expect( + mockDependencies.projectRepository.getPendingProjectRolesByRole, + ).toHaveBeenCalledWith(mockChainId, mockEvent.params.profileId); + expect(mockDependencies.metadataProvider.getMetadata).toHaveBeenCalledWith( + mockEvent.params.metadata[1], + ); + expect(mockDependencies.evmProvider.getTransaction).toHaveBeenCalledWith( + mockEvent.transactionFields.hash, + ); + }); + + it("throws when metadata provider fails", async () => { + (mockDependencies.metadataProvider.getMetadata as Mock).mockRejectedValueOnce( + new Error("Failed to fetch metadata"), + ); + + const handler = new ProfileCreatedHandler(mockEvent, mockChainId, mockDependencies); + + await expect(handler.handle()).rejects.toThrow("Failed to fetch metadata"); + expect(mockDependencies.metadataProvider.getMetadata).toHaveBeenCalledWith( + mockEvent.params.metadata[1], + ); + }); +}); diff --git a/packages/processors/test/registry/handlers/roleGranted.handler.spec.ts b/packages/processors/test/registry/handlers/roleGranted.handler.spec.ts new file mode 100644 index 0000000..030ac33 --- /dev/null +++ b/packages/processors/test/registry/handlers/roleGranted.handler.spec.ts @@ -0,0 +1,125 @@ +import { getAddress, InvalidAddressError } from "viem"; +import { describe, expect, it, vi } from "vitest"; + +import { + ALLO_OWNER_ROLE, + Bytes32String, + ChainId, + ProtocolEvent, +} from "@grants-stack-indexer/shared"; + +import { ProcessorDependencies } from "../../../src/internal.js"; +import { RoleGrantedHandler } from "../../../src/registry/handlers/index.js"; // Adjust path if needed + +describe("RoleGrantedHandler", () => { + const mockProjectRepository = { + getProjectById: vi.fn(), + }; + + const dependencies = { + projectRepository: mockProjectRepository, + } as unknown as ProcessorDependencies; + + const chainId = 10 as ChainId; // Example chainId + const blockNumber = 123456; // Example blockNumber + const mockedAccount = "0x48f33AE41E1762e1688125C4f1C536B1491dF803"; + const mockedSender = "0xc0969723D577D31aB4bdF7e53C540c11298c56AF"; + const mockedEvent = { + blockTimestamp: 123123123, + chainId: 10, + contractName: "Registry", + eventName: "RoleGranted", + params: { + role: ALLO_OWNER_ROLE, + account: mockedAccount, + sender: mockedSender, + }, + blockNumber, + logIndex: 10, + srcAddress: mockedAccount, + transactionFields: { + hash: "0x123", + transactionIndex: 1, + }, + } as ProtocolEvent<"Registry", "RoleGranted">; + + it("returns an empty array if role is ALLO_OWNER_ROLE", async () => { + const event = mockedEvent; + const handler = new RoleGrantedHandler(event, chainId, dependencies); + + const result = await handler.handle(); + + expect(result).toEqual([]); + }); + + it("returns InsertProjectRole if project exists", async () => { + mockedEvent["params"]["role"] = "0x1231231234" as Bytes32String; + const event = mockedEvent; + + mockProjectRepository.getProjectById.mockResolvedValueOnce({ id: "projectId" }); + + const handler = new RoleGrantedHandler(event, chainId, dependencies); + + const result = await handler.handle(); + + expect(result).toEqual([ + { + type: "InsertProjectRole", + args: { + projectRole: { + chainId: chainId, + projectId: "projectId", + address: getAddress(mockedAccount), + role: "member", + createdAtBlock: BigInt(blockNumber), + }, + }, + }, + ]); + }); + + it("returns InsertPendingProjectRole if project does not exist", async () => { + mockedEvent["params"]["role"] = "0x1231231234" as Bytes32String; + const event = mockedEvent; + + mockProjectRepository.getProjectById.mockResolvedValueOnce(undefined); + + const handler = new RoleGrantedHandler(event, chainId, dependencies); + + const result = await handler.handle(); + + expect(result).toEqual([ + { + type: "InsertPendingProjectRole", + args: { + pendingProjectRole: { + chainId: chainId, + role: event.params.role.toLowerCase(), + address: getAddress(mockedAccount), + createdAtBlock: BigInt(blockNumber), + }, + }, + }, + ]); + }); + + it("throws an error if getAddress throws an error for an invalid account", async () => { + mockedEvent["params"]["account"] = "0xinvalid-address"; // Invalid account address + const event = mockedEvent; + + const handler = new RoleGrantedHandler(event, chainId, dependencies); + + await expect(handler.handle()).rejects.toThrow(InvalidAddressError); + }); + + it("throws an error if projectRepository throws an error", async () => { + mockedEvent["params"]["role"] = "0x1231231234" as Bytes32String; + const event = mockedEvent; + + mockProjectRepository.getProjectById.mockRejectedValueOnce(new Error()); + + const handler = new RoleGrantedHandler(event, chainId, dependencies); + + await expect(handler.handle()).rejects.toThrow(Error); + }); +}); diff --git a/packages/processors/test/registry/registry.processor.spec.ts b/packages/processors/test/registry/registry.processor.spec.ts new file mode 100644 index 0000000..7a8c5cf --- /dev/null +++ b/packages/processors/test/registry/registry.processor.spec.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { ChainId, ProtocolEvent, RegistryEvent } from "@grants-stack-indexer/shared"; + +import { ProcessorDependencies, UnsupportedEventException } from "../../src/internal.js"; +import { ProfileCreatedHandler } from "../../src/registry/handlers/profileCreated.handler.js"; +import { RoleGrantedHandler } from "../../src/registry/handlers/roleGranted.handler.js"; +import { RegistryProcessor } from "../../src/registry/registry.processor.js"; + +// Mock the handlers and their handle methods +vi.mock("../../src/registry/handlers/roleGranted.handler.js", () => { + const RoleGrantedHandler = vi.fn(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + RoleGrantedHandler.prototype.handle = vi.fn(); + return { + RoleGrantedHandler, + }; +}); + +// Mock the handlers and their handle methods +vi.mock("../../src/registry/handlers/profileCreated.handler.js", () => { + const ProfileCreatedHandler = vi.fn(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + ProfileCreatedHandler.prototype.handle = vi.fn(); + return { + ProfileCreatedHandler, + }; +}); + +describe("RegistryProcessor", () => { + const chainId: ChainId = 10 as ChainId; // Replace with appropriate chainId + const dependencies: ProcessorDependencies = {} as ProcessorDependencies; // Replace with actual dependencies + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("throws UnsupportedEventException for unsupported events", async () => { + const event: ProtocolEvent<"Registry", RegistryEvent> = { + eventName: "UnsupportedEvent", + } as unknown as ProtocolEvent<"Registry", RegistryEvent>; + + const processor = new RegistryProcessor(chainId, dependencies); + + await expect(processor.process(event)).rejects.toThrow(UnsupportedEventException); + }); + + it("should call ProfileCreatedHandler", async () => { + const event: ProtocolEvent<"Registry", "ProfileCreated"> = { + eventName: "ProfileCreated", + } as ProtocolEvent<"Registry", "ProfileCreated">; + + vi.spyOn(ProfileCreatedHandler.prototype, "handle").mockResolvedValue([]); + + const processor = new RegistryProcessor(chainId, dependencies); + const result = await processor.process(event); + + expect(ProfileCreatedHandler.prototype.handle).toHaveBeenCalled(); + expect(result).toEqual([]); // Check if handle returns [] + }); + + it("should call RoleGrantedHandler", async () => { + const event: ProtocolEvent<"Registry", "RoleGranted"> = { + eventName: "RoleGranted", + } as ProtocolEvent<"Registry", "RoleGranted">; + + vi.spyOn(RoleGrantedHandler.prototype, "handle").mockResolvedValue([]); + + const processor = new RegistryProcessor(chainId, dependencies); + const result = await processor.process(event); + + expect(RoleGrantedHandler.prototype.handle).toHaveBeenCalled(); + expect(result).toEqual([]); // Check if handle returns [] + }); +}); diff --git a/packages/shared/src/constants/address.ts b/packages/shared/src/constants/address.ts index 01a413e..ef87fba 100644 --- a/packages/shared/src/constants/address.ts +++ b/packages/shared/src/constants/address.ts @@ -1,5 +1,10 @@ import { Address } from "viem"; +import { Bytes32String } from "../internal.js"; + +export const ALLO_OWNER_ROLE = + "0x815b5a78dc333d344c7df9da23c04dbd432015cc701876ddb9ffe850e6882747" as Bytes32String; + export const NATIVE_TOKEN_ADDRESS: Address = "0x0000000000000000000000000000000000000001"; export const ALLO_NATIVE_TOKEN = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; diff --git a/packages/shared/src/external.ts b/packages/shared/src/external.ts index 05c7b32..2843728 100644 --- a/packages/shared/src/external.ts +++ b/packages/shared/src/external.ts @@ -4,6 +4,7 @@ export { NATIVE_TOKEN_ADDRESS, isNativeToken, ALLO_NATIVE_TOKEN, + ALLO_OWNER_ROLE, isAlloNativeToken, } from "./constants/index.js"; diff --git a/packages/shared/src/types/common.ts b/packages/shared/src/types/common.ts index d9128ec..4bd7ee2 100644 --- a/packages/shared/src/types/common.ts +++ b/packages/shared/src/types/common.ts @@ -1,3 +1,7 @@ +import { Hex } from "viem"; + import { Branded } from "../internal.js"; export type ChainId = Branded; + +export type Bytes32String = Branded; diff --git a/packages/shared/src/types/events/common.ts b/packages/shared/src/types/events/common.ts index 0f62eb7..005cd5b 100644 --- a/packages/shared/src/types/events/common.ts +++ b/packages/shared/src/types/events/common.ts @@ -1,9 +1,16 @@ import { Hex } from "viem"; import { Address } from "../../internal.js"; -import { AlloEvent, AlloEventParams, StrategyEvent, StrategyEventParams } from "./index.js"; +import { + AlloEvent, + AlloEventParams, + RegistryEvent, + RegistryEventParams, + StrategyEvent, + StrategyEventParams, +} from "./index.js"; -export type ContractName = "Strategy" | "Allo"; +export type ContractName = "Strategy" | "Allo" | "Registry"; export type AnyEvent = StrategyEvent | AlloEvent; type TransactionFields = { @@ -19,7 +26,9 @@ export type ContractToEventName = T extends "Allo" ? AlloEvent : T extends "Strategy" ? StrategyEvent - : never; + : T extends "Registry" + ? RegistryEvent + : never; /** * This type is used to map contract names to their respective event parameters. @@ -32,7 +41,11 @@ export type EventParams ? E extends StrategyEvent ? StrategyEventParams : never - : never; + : T extends "Registry" + ? E extends RegistryEvent + ? RegistryEventParams + : never + : never; /** * This type is used to represent a protocol event. @@ -48,15 +61,13 @@ export type ProtocolEvent; srcAddress: Address; transactionFields: TransactionFields; - // strategyId should be defined for Strategy events or PoolCreated events in Allo - strategyId: T extends "Strategy" - ? Address - : T extends "Allo" - ? E extends "PoolCreated" - ? Address - : never - : never; -}; +} & (T extends "Strategy" // strategyId should be defined for Strategy events or PoolCreated events in Allo + ? { strategyId: Address } + : T extends "Allo" + ? E extends "PoolCreated" + ? { strategyId: Address } + : object + : object); /** * TODO: This type is currently only used in the EventsFetcher and IndexerClient. diff --git a/packages/shared/src/types/events/registry.ts b/packages/shared/src/types/events/registry.ts index 46e4ac1..d47c2f6 100644 --- a/packages/shared/src/types/events/registry.ts +++ b/packages/shared/src/types/events/registry.ts @@ -4,3 +4,36 @@ // | "ProfileMetadataUpdated" // | "ProfileNameUpdated" // | "ProfileOwnerUpdated"; + +import { Address, Bytes32String } from "../../internal.js"; + +/** + * This type is used to represent a Registry events. + */ +export type RegistryEvent = "ProfileCreated" | "RoleGranted"; + +/** + * This type maps Registry events to their respective parameters. + */ +export type RegistryEventParams = T extends "ProfileCreated" + ? ProfileCreatedParams + : T extends "RoleGranted" + ? RoleGrantedParams + : never; + +// ============================================================================= +// =============================== Event Parameters ============================ +// ============================================================================= +export type ProfileCreatedParams = { + profileId: Bytes32String; + nonce: bigint; + name: string; + metadata: [protocol: bigint, pointer: string]; + owner: Address; + anchor: Address; +}; +export type RoleGrantedParams = { + role: Bytes32String; + account: Address; + sender: Address; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36e40e1..8cac965 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,8 +65,8 @@ importers: specifier: 4.3.10 version: 4.3.10 envio: - specifier: 2.4.1 - version: 2.4.1 + specifier: 2.5.2 + version: 2.5.2 ethers: specifier: 6.8.0 version: 6.8.0 @@ -2005,42 +2005,42 @@ packages: } engines: { node: ">=6" } - envio-darwin-arm64@2.4.1: + envio-darwin-arm64@2.5.2: resolution: { - integrity: sha512-uwwKhThFH9dZhQKd6kmleeTyl//GlJGvhFXuhuJ7xn93oGbOOy3hsoYfJZSbVmb0SSSJkgWdAKN82mYHNfdVwQ==, + integrity: sha512-O/+sgImwhQJXlaypMgESWRUzVlkA3dH+2JpdI1LcIVuCpRN/OvBUbRxDHOU79uxeGXvUeeCn1zSGukod6hxG7g==, } cpu: [arm64] os: [darwin] - envio-darwin-x64@2.4.1: + envio-darwin-x64@2.5.2: resolution: { - integrity: sha512-SKqI2hQmjmqlDYCwFaEK6TDJMLL/UE/wWlUIzsAY5drJQTwAL0mBXzUfQM8JbMgDoxeWyKNegUV1vCuTmCDREw==, + integrity: sha512-/K1XCoIXnjTG4LWUqUUPpSdIBFC1tsryQLp1c040YNnsqVmE9EJufclUL0dc2Rf8SNqC/XgNPGWlndcBDSJWFw==, } cpu: [x64] os: [darwin] - envio-linux-arm64@2.4.1: + envio-linux-arm64@2.5.2: resolution: { - integrity: sha512-ujUUifyKM4kHkHfcudeH8iIf5xPiW9t+IzMCgY4gug2fs3Z3biyhmeaylYNpvNiIoQ58vZqJ23fBKaUn7CPPog==, + integrity: sha512-OqrxwwdfN0mcipnq8TQYQL9yZHoHgr7QB8tILjkjtRhz9LOhyjjqlwmNKiDjbpQ8nR2odORKvTlJGEvHb2AO5A==, } cpu: [arm64] os: [linux] - envio-linux-x64@2.4.1: + envio-linux-x64@2.5.2: resolution: { - integrity: sha512-aeVQ9I4zum6X+kBeJrFev/bTDePZRSlt6HytcnqJd7+iWagQggUejhcrSccp2eRSeQ95sGp+F+lQOFfWJHyEJQ==, + integrity: sha512-qpJDjNeY8b8o1v47ulQgu0RGiN8T3BTg2veCRQHeHgpBqh1f7otPEqX2apADo5K+wYlUsH9HXJfbf25SLEQBdw==, } cpu: [x64] os: [linux] - envio@2.4.1: + envio@2.5.2: resolution: { - integrity: sha512-GsHv4S/mAzr//XCyTHFBnfd1CZFlfaG+wcWJkpeguzhnttNSOMJXCR4UPG0PxV2cZYiA3eVrOSPn58fTzk2G5g==, + integrity: sha512-fWHmggBijehdtOkSErHgASUiEhPwDi4ouxOdgHsi0B/DkabgwTYwa8Z+pBhf324exHhGRhAVxZwBtoKI98MUGA==, } hasBin: true @@ -5631,24 +5631,24 @@ snapshots: env-paths@2.2.1: {} - envio-darwin-arm64@2.4.1: + envio-darwin-arm64@2.5.2: optional: true - envio-darwin-x64@2.4.1: + envio-darwin-x64@2.5.2: optional: true - envio-linux-arm64@2.4.1: + envio-linux-arm64@2.5.2: optional: true - envio-linux-x64@2.4.1: + envio-linux-x64@2.5.2: optional: true - envio@2.4.1: + envio@2.5.2: optionalDependencies: - envio-darwin-arm64: 2.4.1 - envio-darwin-x64: 2.4.1 - envio-linux-arm64: 2.4.1 - envio-linux-x64: 2.4.1 + envio-darwin-arm64: 2.5.2 + envio-darwin-x64: 2.5.2 + envio-linux-arm64: 2.5.2 + envio-linux-x64: 2.5.2 environment@1.1.0: {}