diff --git a/apps/processing/src/services/sharedDependencies.service.ts b/apps/processing/src/services/sharedDependencies.service.ts index 3b927ee..32ceec6 100644 --- a/apps/processing/src/services/sharedDependencies.service.ts +++ b/apps/processing/src/services/sharedDependencies.service.ts @@ -9,6 +9,7 @@ import { CoingeckoProvider } from "@grants-stack-indexer/pricing"; import { createKyselyDatabase, KyselyApplicationRepository, + KyselyDonationRepository, KyselyProjectRepository, KyselyRoundRepository, } from "@grants-stack-indexer/repository"; @@ -45,6 +46,10 @@ export class SharedDependenciesService { kyselyDatabase, env.DATABASE_SCHEMA, ); + const donationRepository = new KyselyDonationRepository( + kyselyDatabase, + env.DATABASE_SCHEMA, + ); const pricingProvider = new CoingeckoProvider( { apiKey: env.COINGECKO_API_KEY, @@ -71,6 +76,7 @@ export class SharedDependenciesService { roundRepository, applicationRepository, pricingProvider, + donationRepository, metadataProvider, }, registries: { diff --git a/packages/data-flow/src/data-loader/dataLoader.ts b/packages/data-flow/src/data-loader/dataLoader.ts index 6797f79..9aefdef 100644 --- a/packages/data-flow/src/data-loader/dataLoader.ts +++ b/packages/data-flow/src/data-loader/dataLoader.ts @@ -1,6 +1,7 @@ import { Changeset, IApplicationRepository, + IDonationRepository, IProjectRepository, IRoundRepository, } from "@grants-stack-indexer/repository"; @@ -9,6 +10,7 @@ import { ILogger, stringify } from "@grants-stack-indexer/shared"; import { ExecutionResult, IDataLoader, InvalidChangeset } from "../internal.js"; import { createApplicationHandlers, + createDonationHandlers, createProjectHandlers, createRoundHandlers, } from "./handlers/index.js"; @@ -35,6 +37,7 @@ export class DataLoader implements IDataLoader { project: IProjectRepository; round: IRoundRepository; application: IApplicationRepository; + donation: IDonationRepository; }, private readonly logger: ILogger, ) { @@ -42,6 +45,7 @@ export class DataLoader implements IDataLoader { ...createProjectHandlers(repositories.project), ...createRoundHandlers(repositories.round), ...createApplicationHandlers(repositories.application), + ...createDonationHandlers(repositories.donation), }; } diff --git a/packages/data-flow/src/data-loader/handlers/donation.handlers.ts b/packages/data-flow/src/data-loader/handlers/donation.handlers.ts new file mode 100644 index 0000000..b42d9e0 --- /dev/null +++ b/packages/data-flow/src/data-loader/handlers/donation.handlers.ts @@ -0,0 +1,27 @@ +import { DonationChangeset, IDonationRepository } from "@grants-stack-indexer/repository"; + +import { ChangesetHandler } from "../types/index.js"; + +/** + * Collection of handlers for application-related operations. + * Each handler corresponds to a specific Application changeset type. + */ +export type DonationHandlers = { + [K in DonationChangeset["type"]]: ChangesetHandler; +}; + +/** + * Creates handlers for managing application-related operations. + * + * @param repository - The application repository instance used for database operations + * @returns An object containing all application-related handlers + */ +export const createDonationHandlers = (repository: IDonationRepository): DonationHandlers => ({ + InsertDonation: (async (changeset): Promise => { + await repository.insertDonation(changeset.args.donation); + }) satisfies ChangesetHandler<"InsertDonation">, + + InsertManyDonations: (async (changeset): Promise => { + await repository.insertManyDonations(changeset.args.donations); + }) satisfies ChangesetHandler<"InsertManyDonations">, +}); diff --git a/packages/data-flow/src/data-loader/handlers/index.ts b/packages/data-flow/src/data-loader/handlers/index.ts index e92262b..dc27b0b 100644 --- a/packages/data-flow/src/data-loader/handlers/index.ts +++ b/packages/data-flow/src/data-loader/handlers/index.ts @@ -1,3 +1,4 @@ export * from "./application.handlers.js"; export * from "./project.handlers.js"; export * from "./round.handlers.js"; +export * from "./donation.handlers.js"; diff --git a/packages/data-flow/src/orchestrator.ts b/packages/data-flow/src/orchestrator.ts index 1cb623a..52fbd2d 100644 --- a/packages/data-flow/src/orchestrator.ts +++ b/packages/data-flow/src/orchestrator.ts @@ -90,6 +90,7 @@ export class Orchestrator { project: this.dependencies.projectRepository, round: this.dependencies.roundRepository, application: this.dependencies.applicationRepository, + donation: this.dependencies.donationRepository, }, this.logger, ); diff --git a/packages/data-flow/src/types/index.ts b/packages/data-flow/src/types/index.ts index af4aa25..7778122 100644 --- a/packages/data-flow/src/types/index.ts +++ b/packages/data-flow/src/types/index.ts @@ -2,6 +2,7 @@ import { ProcessorDependencies } from "@grants-stack-indexer/processors"; import { Changeset, IApplicationRepository, + IDonationRepository, IProjectRepository, IRoundRepository, } from "@grants-stack-indexer/repository"; @@ -31,4 +32,5 @@ export type CoreDependencies = Pick< roundRepository: IRoundRepository; projectRepository: IProjectRepository; applicationRepository: IApplicationRepository; + donationRepository: IDonationRepository; }; diff --git a/packages/data-flow/test/data-loader/dataLoader.spec.ts b/packages/data-flow/test/data-loader/dataLoader.spec.ts index ee3130d..3eef1fc 100644 --- a/packages/data-flow/test/data-loader/dataLoader.spec.ts +++ b/packages/data-flow/test/data-loader/dataLoader.spec.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { Changeset, IApplicationRepository, + IDonationRepository, IProjectRepository, IRoundRepository, } from "@grants-stack-indexer/repository"; @@ -27,6 +28,11 @@ describe("DataLoader", () => { updateApplication: vi.fn(), } as unknown as IApplicationRepository; + const mockDonationRepository = { + insertDonation: vi.fn(), + insertManyDonations: vi.fn(), + } as IDonationRepository; + const logger: ILogger = { debug: vi.fn(), error: vi.fn(), @@ -39,6 +45,7 @@ describe("DataLoader", () => { project: mockProjectRepository, round: mockRoundRepository, application: mockApplicationRepository, + donation: mockDonationRepository, }, logger, ); diff --git a/packages/data-flow/test/unit/eventsFetcher.spec.ts b/packages/data-flow/test/unit/eventsFetcher.spec.ts index 89694f2..f13fa5b 100644 --- a/packages/data-flow/test/unit/eventsFetcher.spec.ts +++ b/packages/data-flow/test/unit/eventsFetcher.spec.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, Mocked, vi } from "vitest"; import { IIndexerClient } from "@grants-stack-indexer/indexer-client"; -import { AnyIndexerFetchedEvent, ChainId } from "@grants-stack-indexer/shared"; +import { AnyIndexerFetchedEvent, ChainId, PoolCreatedParams } from "@grants-stack-indexer/shared"; import { EventsFetcher } from "../../src/eventsFetcher.js"; @@ -27,7 +27,11 @@ describe("EventsFetcher", () => { eventName: "PoolCreated", srcAddress: "0x1234567890123456789012345678901234567890", logIndex: 0, - params: { contractAddress: "0x1234", tokenAddress: "0x1234", amount: 1000 }, + params: { + contractAddress: "0x1234", + tokenAddress: "0x1234", + amount: 1000n, + } as unknown as PoolCreatedParams, transactionFields: { hash: "0x1234", transactionIndex: 0 }, }, { @@ -41,8 +45,8 @@ describe("EventsFetcher", () => { params: { contractAddress: "0x1234", tokenAddress: "0x1234", - amount: 1000, - }, + amount: 1000n, + } as unknown as PoolCreatedParams, transactionFields: { hash: "0x1234", transactionIndex: 1 }, }, ]; diff --git a/packages/data-flow/test/unit/orchestrator.spec.ts b/packages/data-flow/test/unit/orchestrator.spec.ts index db80a70..d625834 100644 --- a/packages/data-flow/test/unit/orchestrator.spec.ts +++ b/packages/data-flow/test/unit/orchestrator.spec.ts @@ -7,6 +7,7 @@ import { UnsupportedStrategy } from "@grants-stack-indexer/processors"; import { Changeset, IApplicationRepository, + IDonationRepository, IProjectRepository, IRoundRepository, } from "@grants-stack-indexer/repository"; @@ -92,6 +93,7 @@ describe("Orchestrator", { sequential: true }, () => { projectRepository: {} as unknown as IProjectRepository, roundRepository: {} as unknown as IRoundRepository, applicationRepository: {} as unknown as IApplicationRepository, + donationRepository: {} as unknown as IDonationRepository, pricingProvider: { getTokenPrice: vi.fn(), }, @@ -269,6 +271,10 @@ describe("Orchestrator", { sequential: true }, () => { RegisteredWithData: "", DistributedWithData: "", DistributedWithFlowRate: "", + AllocatedWithOrigin: "", + AllocatedWithData: "", + AllocatedWithVotes: "", + AllocatedWithStatus: "", }; for (const event of Object.keys(strategyEvents) as StrategyEvent[]) { diff --git a/packages/indexer-client/test/unit/envioIndexerClient.spec.ts b/packages/indexer-client/test/unit/envioIndexerClient.spec.ts index 400886e..b8ccadb 100644 --- a/packages/indexer-client/test/unit/envioIndexerClient.spec.ts +++ b/packages/indexer-client/test/unit/envioIndexerClient.spec.ts @@ -1,7 +1,7 @@ import { GraphQLClient, RequestDocument, RequestOptions } from "graphql-request"; import { afterEach, beforeEach, describe, expect, it, Mocked, vi } from "vitest"; -import { AnyIndexerFetchedEvent, ChainId } from "@grants-stack-indexer/shared"; +import { AnyIndexerFetchedEvent, ChainId, PoolCreatedParams } from "@grants-stack-indexer/shared"; import { IndexerClientError, InvalidIndexerResponse } from "../../src/exceptions/index.js"; import { EnvioIndexerClient } from "../../src/providers/envioIndexerClient.js"; @@ -32,7 +32,11 @@ describe("EnvioIndexerClient", () => { eventName: "PoolCreated", srcAddress: "0x1234", logIndex: 1, - params: { contractAddress: "0x1234", tokenAddress: "0x1234", amount: 1000 }, + params: { + contractAddress: "0x1234", + tokenAddress: "0x1234", + amount: 1000n, + } as unknown as PoolCreatedParams, transactionFields: { hash: "0x123", transactionIndex: 1 }, }, { @@ -43,7 +47,11 @@ describe("EnvioIndexerClient", () => { eventName: "PoolCreated", srcAddress: "0x1234", logIndex: 3, - params: { contractAddress: "0x1234", tokenAddress: "0x1234", amount: 1000 }, + params: { + contractAddress: "0x1234", + tokenAddress: "0x1234", + amount: 1000n, + } as unknown as PoolCreatedParams, transactionFields: { hash: "0x123", transactionIndex: 1 }, }, { @@ -54,7 +62,11 @@ describe("EnvioIndexerClient", () => { eventName: "PoolCreated", srcAddress: "0x1234", logIndex: 1, - params: { contractAddress: "0x1234", tokenAddress: "0x1234", amount: 1000 }, + params: { + contractAddress: "0x1234", + tokenAddress: "0x1234", + amount: 1000n, + } as unknown as PoolCreatedParams, transactionFields: { hash: "0x123", transactionIndex: 1 }, }, { @@ -65,7 +77,11 @@ describe("EnvioIndexerClient", () => { eventName: "PoolCreated", srcAddress: "0x1234", logIndex: 1, - params: { contractAddress: "0x1234", tokenAddress: "0x1234", amount: 1000 }, + params: { + contractAddress: "0x1234", + tokenAddress: "0x1234", + amount: 1000n, + } as unknown as PoolCreatedParams, transactionFields: { hash: "0x123", transactionIndex: 1 }, }, ]; diff --git a/packages/processors/src/exceptions/applicationNotFound.exception.ts b/packages/processors/src/exceptions/applicationNotFound.exception.ts new file mode 100644 index 0000000..b0bfc45 --- /dev/null +++ b/packages/processors/src/exceptions/applicationNotFound.exception.ts @@ -0,0 +1,9 @@ +import { ChainId } from "@grants-stack-indexer/shared"; + +export class ApplicationNotFound extends Error { + constructor(chainId: ChainId, roundId: string, recipientId: string) { + super( + `Application not found on chain ${chainId} for round ${roundId} and recipient ${recipientId}`, + ); + } +} diff --git a/packages/processors/src/exceptions/index.ts b/packages/processors/src/exceptions/index.ts index 327f9db..8bd458f 100644 --- a/packages/processors/src/exceptions/index.ts +++ b/packages/processors/src/exceptions/index.ts @@ -4,3 +4,6 @@ export * from "./invalidArgument.exception.js"; export * from "./unsupportedStrategy.exception.js"; export * from "./projectNotFound.exception.js"; export * from "./roundNotFound.exception.js"; +export * from "./applicationNotFound.exception.js"; +export * from "./unknownToken.exception.js"; +export * from "./metadataParsingFailed.exception.js"; diff --git a/packages/processors/src/exceptions/metadataParsingFailed.exception.ts b/packages/processors/src/exceptions/metadataParsingFailed.exception.ts new file mode 100644 index 0000000..a62cce1 --- /dev/null +++ b/packages/processors/src/exceptions/metadataParsingFailed.exception.ts @@ -0,0 +1,5 @@ +export class MetadataParsingFailed extends Error { + constructor(additionalInfo?: string) { + super(`Failed to parse application metadata: ${additionalInfo}`); + } +} diff --git a/packages/processors/src/exceptions/unknownToken.exception.ts b/packages/processors/src/exceptions/unknownToken.exception.ts new file mode 100644 index 0000000..3e29931 --- /dev/null +++ b/packages/processors/src/exceptions/unknownToken.exception.ts @@ -0,0 +1,7 @@ +import { ChainId } from "@grants-stack-indexer/shared"; + +export class UnknownToken extends Error { + constructor(tokenAddress: string, chainId?: ChainId) { + super(`Unknown token: ${tokenAddress} ${chainId ? `on chain ${chainId}` : ""}`); + } +} diff --git a/packages/processors/src/helpers/index.ts b/packages/processors/src/helpers/index.ts index 0160fc0..fa1255d 100644 --- a/packages/processors/src/helpers/index.ts +++ b/packages/processors/src/helpers/index.ts @@ -1,3 +1,4 @@ export * from "./roles.js"; export * from "./utils.js"; export * from "./tokenMath.js"; +export * from "./pricing.js"; diff --git a/packages/processors/src/helpers/pricing.ts b/packages/processors/src/helpers/pricing.ts new file mode 100644 index 0000000..351927f --- /dev/null +++ b/packages/processors/src/helpers/pricing.ts @@ -0,0 +1,71 @@ +import { IPricingProvider } from "@grants-stack-indexer/pricing"; +import { Token } from "@grants-stack-indexer/shared"; + +import { TokenPriceNotFoundError } from "../internal.js"; +import { calculateAmountInToken, calculateAmountInUsd } from "./index.js"; + +// sometimes coingecko returns no prices for 1 hour range, 2 hours works better +const TIMESTAMP_DELTA_RANGE = 2 * 60 * 60 * 1000; + +/** + * Get the amount in USD for a given amount in the token + * @param pricingProvider - The pricing provider to use + * @param token - The token to get the amount in + * @param amount - The amount in the token + * @param timestamp - The timestamp to get the price at + * @returns The amount in USD + * @throws TokenPriceNotFoundError if the price is not found + */ +export const getTokenAmountInUsd = async ( + pricingProvider: IPricingProvider, + token: Token, + amount: bigint, + timestamp: number, +): Promise<{ amountInUsd: string; timestamp: number }> => { + const tokenPrice = await pricingProvider.getTokenPrice( + token.priceSourceCode, + timestamp, + timestamp + TIMESTAMP_DELTA_RANGE, + ); + + if (!tokenPrice) { + throw new TokenPriceNotFoundError(token.address, timestamp); + } + + return { + amountInUsd: calculateAmountInUsd(amount, tokenPrice.priceUsd, token.decimals), + timestamp: tokenPrice.timestampMs, + }; +}; + +/** + * Get the amount in the token for a given amount in USD + * @param pricingProvider - The pricing provider to use + * @param token - The token to get the amount in + * @param amountInUSD - The amount in USD + * @param timestamp - The timestamp to get the price at + * @returns The amount in the token + * @throws TokenPriceNotFoundError if the price is not found + */ +export const getUsdInTokenAmount = async ( + pricingProvider: IPricingProvider, + token: Token, + amountInUSD: string, + timestamp: number, +): Promise<{ amount: bigint; price: number; timestamp: Date }> => { + const closestPrice = await pricingProvider.getTokenPrice( + token.priceSourceCode, + timestamp, + timestamp + TIMESTAMP_DELTA_RANGE, + ); + + if (!closestPrice) { + throw new TokenPriceNotFoundError(token.address, timestamp); + } + + return { + amount: calculateAmountInToken(amountInUSD, closestPrice.priceUsd, token.decimals), + timestamp: new Date(closestPrice.timestampMs), + price: 1 / closestPrice.priceUsd, // price is the token price in USD, we return the inverse + }; +}; diff --git a/packages/processors/src/helpers/tokenMath.ts b/packages/processors/src/helpers/tokenMath.ts index e48da9b..d801cb4 100644 --- a/packages/processors/src/helpers/tokenMath.ts +++ b/packages/processors/src/helpers/tokenMath.ts @@ -32,3 +32,28 @@ export const calculateAmountInUsd = ( return amountInUsd.toString(); }; + +/** + * Calculates the amount in token + * @param amountInUSD - The amount in USD + * @param tokenPriceInUsd - The price of the token in USD + * @param tokenDecimals - The number of decimals the token has + * @returns The amount in token + * @throws Error if tokenPriceInUsd is 0 (division by zero) + */ +export const calculateAmountInToken = ( + amountInUSD: string, + tokenPriceInUsd: string | number, + tokenDecimals: number, +): bigint => { + const amountInUsdBN = new BigNumber(amountInUSD); + const tokenPriceInUsdBN = new BigNumber(tokenPriceInUsd); + const scaleFactor = new BigNumber(10).pow(tokenDecimals); + + return BigInt( + amountInUsdBN + .multipliedBy(scaleFactor) + .dividedBy(tokenPriceInUsdBN) + .toFixed(0, BigNumber.ROUND_FLOOR), + ); +}; diff --git a/packages/processors/src/schemas/applicationMetadata.ts b/packages/processors/src/schemas/applicationMetadata.ts new file mode 100644 index 0000000..ea2aa86 --- /dev/null +++ b/packages/processors/src/schemas/applicationMetadata.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const ApplicationMetadataSchema = z + .object({ + application: z.object({ + round: z.string(), + recipient: z.string(), + }), + }) + .transform((data) => ({ type: "application" as const, ...data })); + +export type ApplicationMetadata = z.infer; diff --git a/packages/processors/src/schemas/index.ts b/packages/processors/src/schemas/index.ts index d7789f5..f6e9268 100644 --- a/packages/processors/src/schemas/index.ts +++ b/packages/processors/src/schemas/index.ts @@ -1,2 +1,3 @@ export * from "./projectMetadata.js"; export * from "./roundMetadata.js"; +export * from "./applicationMetadata.js"; diff --git a/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.ts b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.ts index 8f22b36..b167224 100644 --- a/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.ts +++ b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.ts @@ -14,12 +14,13 @@ import DonationVotingMerkleDistributionDirectTransferStrategy from "../../abis/a import { calculateAmountInUsd, getDateFromTimestamp } from "../../helpers/index.js"; import { TokenPriceNotFoundError, UnsupportedEventException } from "../../internal.js"; import { BaseDistributedHandler, BaseStrategyHandler } from "../common/index.js"; -import { DVMDRegisteredHandler } from "./handlers/index.js"; +import { DVMDAllocatedHandler, DVMDRegisteredHandler } from "./handlers/index.js"; type Dependencies = Pick< ProcessorDependencies, | "projectRepository" | "roundRepository" + | "applicationRepository" | "metadataProvider" | "evmProvider" | "pricingProvider" @@ -63,6 +64,12 @@ export class DVMDDirectTransferStrategyHandler extends BaseStrategyHandler { this.chainId, this.dependencies, ).handle(); + case "AllocatedWithOrigin": + return new DVMDAllocatedHandler( + event as ProcessorEvent<"Strategy", "AllocatedWithOrigin">, + this.chainId, + this.dependencies, + ).handle(); default: throw new UnsupportedEventException("Strategy", event.eventName); } diff --git a/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/allocated.handler.ts b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/allocated.handler.ts new file mode 100644 index 0000000..888badf --- /dev/null +++ b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/allocated.handler.ts @@ -0,0 +1,183 @@ +import { Address, encodePacked, getAddress, keccak256 } from "viem"; + +import { Application, Changeset, Donation, Round } from "@grants-stack-indexer/repository"; +import { ChainId, getToken, ProcessorEvent, Token } from "@grants-stack-indexer/shared"; + +import { getTokenAmountInUsd, getUsdInTokenAmount } from "../../../helpers/index.js"; +import { + ApplicationNotFound, + IEventHandler, + MetadataParsingFailed, + ProcessorDependencies, + RoundNotFound, + UnknownToken, +} from "../../../internal.js"; +import { ApplicationMetadata, ApplicationMetadataSchema } from "../../../schemas/index.js"; + +type Dependencies = Pick< + ProcessorDependencies, + "roundRepository" | "applicationRepository" | "pricingProvider" +>; + +/** + * Handles the Allocated event for the Donation Voting Merkle Distribution Direct Transfer strategy. + * + * This handler performs the following core actions when a donation is allocated to a project: + * - Validates that both the round and application exist + * - Retrieves token price data to calculate USD amounts + * - Creates a new donation record with the allocated amount + * - Links the donation to both the application and round + */ +export class DVMDAllocatedHandler implements IEventHandler<"Strategy", "AllocatedWithOrigin"> { + constructor( + readonly event: ProcessorEvent<"Strategy", "AllocatedWithOrigin">, + private readonly chainId: ChainId, + private readonly dependencies: Dependencies, + ) {} + + /** + * Handles the AllocatedWithOrigin event for the Donation Voting Merkle Distribution Direct Transfer strategy. + * @returns {Changeset[]} The changeset containing an InsertDonation change + * @throws {OriginMissing} if the origin is missing + * @throws {RoundNotFound} if the round does not exist + * @throws {ApplicationNotFound} if the application does not exist + * @throws {UnknownToken} if the token does not exist + * @throws {TokenPriceNotFoundError} if the token price is not found + * @throws {MetadataParsingFailed} if the metadata is invalid + */ + async handle(): Promise { + const { srcAddress } = this.event; + const { recipientId: _recipientId, amount, token: _token } = this.event.params; + + const round = await this.getRoundOrThrow(srcAddress); + const application = await this.getApplicationOrThrow(round.id, _recipientId); + + const donationId = this.getDonationId(this.event.blockNumber, this.event.logIndex); + + const token = this.getTokenOrThrow(_token); + const matchToken = this.getTokenOrThrow(round.matchTokenAddress); + + const { amountInUsd, timestamp: priceTimestamp } = 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 parsedMetadata = this.parseMetadataOrThrow(application.metadata); + + const donation: Donation = { + id: donationId, + chainId: this.chainId, + roundId: round.id, + applicationId: application.id, + donorAddress: getAddress(this.event.params.origin), + recipientAddress: getAddress(parsedMetadata.application.recipient), + projectId: application.projectId, + transactionHash: this.event.transactionFields.hash, + blockNumber: BigInt(this.event.blockNumber), + tokenAddress: token.address, + amount: amount, + amountInUsd, + amountInRoundMatchToken, + timestamp: new Date(priceTimestamp), //TODO: ask Gitcoin if this is correct + }; + + return [ + { + type: "InsertDonation", + args: { donation }, + }, + ]; + } + + /** + * Retrieves a round by its strategy address. + * @param {Address} strategyAddress - The address of the strategy. + * @returns {Promise} The round found. + * @throws {RoundNotFound} if the round does not exist. + */ + private async getRoundOrThrow(strategyAddress: Address): Promise { + const normalizedStrategyAddress = getAddress(strategyAddress); + const round = await this.dependencies.roundRepository.getRoundByStrategyAddress( + this.chainId, + normalizedStrategyAddress, + ); + + if (!round) { + throw new RoundNotFound(this.chainId, normalizedStrategyAddress); + } + + return round; + } + + /** + * Retrieves an application by its round ID and recipient address. + * @param {string} roundId - The ID of the round. + * @param {Address} recipientId - The address of the recipient. + * @returns {Promise} The application found. + * @throws {ApplicationNotFound} if the application does not exist. + */ + private async getApplicationOrThrow( + roundId: string, + recipientId: Address, + ): Promise { + const normalizedRecipientId = getAddress(recipientId); + const application = + await this.dependencies.applicationRepository.getApplicationByAnchorAddress( + this.chainId, + roundId, + normalizedRecipientId, + ); + + if (!application) { + throw new ApplicationNotFound(this.chainId, roundId, normalizedRecipientId); + } + + return application; + } + + /** + * DONATION_ID = keccak256(abi.encodePacked(blockNumber, "-", logIndex)); + */ + private getDonationId(blockNumber: number, logIndex: number): string { + return keccak256(encodePacked(["string"], [`${blockNumber}-${logIndex}`])); + } + + /** + * Retrieves a token by its address and chain ID. + * @param {Address} tokenAddress - The address of the token. + * @returns {Token} The token found. + * @throws {UnknownToken} if the token does not exist. + */ + private getTokenOrThrow(tokenAddress: Address): Token { + const token = getToken(this.chainId, getAddress(tokenAddress)); + if (!token) throw new UnknownToken(tokenAddress, this.chainId); + return token; + } + + /** + * Parses the application metadata. + * @param {unknown} metadata - The metadata to parse. + * @returns {ApplicationMetadata} The parsed metadata. + * @throws {MetadataParsingFailed} if the metadata is invalid. + */ + private parseMetadataOrThrow(metadata: unknown): ApplicationMetadata { + const parsedMetadata = ApplicationMetadataSchema.safeParse(metadata); + if (!parsedMetadata.success) throw new MetadataParsingFailed(parsedMetadata.error.message); + + return parsedMetadata.data; + } +} diff --git a/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/index.ts b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/index.ts index 00c2f86..3072f31 100644 --- a/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/index.ts +++ b/packages/processors/src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/index.ts @@ -1 +1,2 @@ +export * from "./allocated.handler.js"; export * from "./registered.handler.js"; diff --git a/packages/processors/src/types/processor.types.ts b/packages/processors/src/types/processor.types.ts index 0ef5c58..a1deeee 100644 --- a/packages/processors/src/types/processor.types.ts +++ b/packages/processors/src/types/processor.types.ts @@ -2,6 +2,7 @@ import type { EvmProvider } from "@grants-stack-indexer/chain-providers"; import type { IMetadataProvider } from "@grants-stack-indexer/metadata"; import type { IPricingProvider } from "@grants-stack-indexer/pricing"; import type { + IApplicationReadRepository, IProjectReadRepository, IRoundReadRepository, } from "@grants-stack-indexer/repository"; @@ -13,5 +14,6 @@ export type ProcessorDependencies = { metadataProvider: IMetadataProvider; roundRepository: IRoundReadRepository; projectRepository: IProjectReadRepository; + applicationRepository: IApplicationReadRepository; logger: ILogger; }; diff --git a/packages/processors/test/allo/allo.processor.spec.ts b/packages/processors/test/allo/allo.processor.spec.ts index 8deac5a..e1a3617 100644 --- a/packages/processors/test/allo/allo.processor.spec.ts +++ b/packages/processors/test/allo/allo.processor.spec.ts @@ -4,6 +4,7 @@ import type { EvmProvider } from "@grants-stack-indexer/chain-providers"; import type { IMetadataProvider } from "@grants-stack-indexer/metadata"; import type { IPricingProvider } from "@grants-stack-indexer/pricing"; import type { + IApplicationReadRepository, IProjectReadRepository, IRoundReadRepository, } from "@grants-stack-indexer/repository"; @@ -48,6 +49,7 @@ describe("AlloProcessor", () => { metadataProvider: mockMetadataProvider, roundRepository: mockRoundRepository, projectRepository: {} as IProjectReadRepository, + applicationRepository: {} as IApplicationReadRepository, logger, }); diff --git a/packages/processors/test/helpers/tokenMath.spec.ts b/packages/processors/test/helpers/tokenMath.spec.ts index 3aee76c..33a8736 100644 --- a/packages/processors/test/helpers/tokenMath.spec.ts +++ b/packages/processors/test/helpers/tokenMath.spec.ts @@ -1,7 +1,7 @@ import { parseGwei } from "viem"; import { describe, expect, it, test } from "vitest"; -import { calculateAmountInUsd } from "../../src/helpers/tokenMath.js"; +import { calculateAmountInToken, calculateAmountInUsd } from "../../src/helpers/tokenMath.js"; import { InvalidArgument } from "../../src/internal.js"; describe("calculateAmountInUsd", () => { @@ -121,3 +121,54 @@ describe("calculateAmountInUsd", () => { expect(calculateAmountInUsd(3400000000000000000n, 2, 18, 8)).toBe("6.8"); }); }); + +describe("calculateAmountInToken", () => { + it("correctly calculate the amount in token", () => { + expect(calculateAmountInToken("1", "1", 18)).toBe(1000000000000000000n); + expect(calculateAmountInToken("1", "1", 8)).toBe(100000000n); + }); + + test("migrated cases", () => { + expect(calculateAmountInToken("3.4", "1", 18)).toBe(3400000000000000000n); + + expect(calculateAmountInToken("3.4", "2", 18)).toBe(1700000000000000000n); + + expect(calculateAmountInToken("3.4", "0.5", 18)).toBe(6800000000000000000n); + }); + + it("return zero for zero USD amount", () => { + expect(calculateAmountInToken("0", "1", 18)).toBe(0n); + }); + + it("handle very large USD amounts", () => { + expect(calculateAmountInToken("1000000000000000000", "1", 18)).toBe( + 1000000000000000000000000000000000000n, + ); + }); + + it("handle small USD amounts", () => { + expect(calculateAmountInToken("0.01", "0.001", 18)).toBe(10000000000000000000n); + }); + + it("handle very large token price", () => { + expect(calculateAmountInToken("2", "1000000000000000000", 18)).toBe(2n); + }); + + it("throw an error for zero token price", () => { + expect(() => calculateAmountInToken("1", "0", 18)).toThrow(); + }); + + it("handle scientific notation for USD amount", () => { + expect(calculateAmountInToken("1e3", "1", 18)).toBe(1000000000000000000000n); + }); + + it("truncates decimals to floor", () => { + // For 6 decimal token + expect(calculateAmountInToken("1.123456", "1", 6)).toBe(1123456n); + expect(calculateAmountInToken("1", "0.123456", 6)).toBe(8100051n); // 8100051.840331778 before truncation + + // For 8 decimal token + expect(calculateAmountInToken("1.12345678", "1", 8)).toBe(112345678n); + expect(calculateAmountInToken("1", "0.12345678", 8)).toBe(810000066n); // 810000066.4200054 before truncation + }); +}); diff --git a/packages/processors/test/registry/handlers/profileCreated.handler.spec.ts b/packages/processors/test/registry/handlers/profileCreated.handler.spec.ts index f6cb01f..0ec6c3d 100644 --- a/packages/processors/test/registry/handlers/profileCreated.handler.spec.ts +++ b/packages/processors/test/registry/handlers/profileCreated.handler.spec.ts @@ -4,7 +4,11 @@ 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 { + IApplicationReadRepository, + IProjectReadRepository, + IRoundReadRepository, +} from "@grants-stack-indexer/repository"; import { Bytes32String, ChainId, ILogger, ProcessorEvent } from "@grants-stack-indexer/shared"; import { ProcessorDependencies } from "../../../src/internal.js"; @@ -61,6 +65,7 @@ describe("ProfileCreatedHandler", () => { getMetadata: vi.fn(), } as unknown as IMetadataProvider, roundRepository: {} as unknown as IRoundReadRepository, + applicationRepository: {} as unknown as IApplicationReadRepository, logger, }; }); diff --git a/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.spec.ts b/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.spec.ts index 81fb0d0..41fdd83 100644 --- a/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.spec.ts +++ b/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.spec.ts @@ -3,6 +3,7 @@ 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"; @@ -20,16 +21,23 @@ import { import { TokenPriceNotFoundError, UnsupportedEventException } from "../../../src/internal.js"; import { BaseDistributedHandler } from "../../../src/strategy/common/index.js"; import { DVMDDirectTransferStrategyHandler } from "../../../src/strategy/donationVotingMerkleDistributionDirectTransfer/dvmdDirectTransfer.handler.js"; -import { DVMDRegisteredHandler } from "../../../src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/index.js"; +import { + DVMDAllocatedHandler, + DVMDRegisteredHandler, +} from "../../../src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/index.js"; vi.mock( "../../../src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/index.js", () => { const DVMDRegisteredHandler = vi.fn(); + const DVMDAllocatedHandler = vi.fn(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access DVMDRegisteredHandler.prototype.handle = vi.fn(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + DVMDAllocatedHandler.prototype.handle = vi.fn(); return { DVMDRegisteredHandler, + DVMDAllocatedHandler, }; }, ); @@ -50,6 +58,8 @@ describe("DVMDDirectTransferHandler", () => { let mockProjectRepository: IProjectReadRepository; let mockEVMProvider: EvmProvider; let mockPricingProvider: IPricingProvider; + let mockApplicationRepository: IApplicationReadRepository; + const logger: ILogger = { debug: vi.fn(), error: vi.fn(), @@ -68,13 +78,14 @@ describe("DVMDDirectTransferHandler", () => { mockPricingProvider = { getTokenPrice: vi.fn(), } as IPricingProvider; - + mockApplicationRepository = {} as IApplicationReadRepository; handler = new DVMDDirectTransferStrategyHandler(mockChainId, { metadataProvider: mockMetadataProvider, roundRepository: mockRoundRepository, projectRepository: mockProjectRepository, evmProvider: mockEVMProvider, pricingProvider: mockPricingProvider, + applicationRepository: mockApplicationRepository, logger, }); }); @@ -102,6 +113,7 @@ describe("DVMDDirectTransferHandler", () => { projectRepository: mockProjectRepository, evmProvider: mockEVMProvider, pricingProvider: mockPricingProvider, + applicationRepository: mockApplicationRepository, logger, }); expect(DVMDRegisteredHandler.prototype.handle).toHaveBeenCalled(); @@ -122,11 +134,33 @@ describe("DVMDDirectTransferHandler", () => { projectRepository: mockProjectRepository, evmProvider: mockEVMProvider, pricingProvider: mockPricingProvider, + applicationRepository: mockApplicationRepository, logger, }); expect(BaseDistributedHandler.prototype.handle).toHaveBeenCalled(); }); + it("calls AllocatedHandler for AllocatedWithOrigin event", async () => { + const mockEvent = { + eventName: "AllocatedWithOrigin", + } as ProcessorEvent<"Strategy", "AllocatedWithOrigin">; + + vi.spyOn(DVMDAllocatedHandler.prototype, "handle").mockResolvedValue([]); + + await handler.handle(mockEvent); + + expect(DVMDAllocatedHandler).toHaveBeenCalledWith(mockEvent, mockChainId, { + metadataProvider: mockMetadataProvider, + roundRepository: mockRoundRepository, + projectRepository: mockProjectRepository, + evmProvider: mockEVMProvider, + pricingProvider: mockPricingProvider, + applicationRepository: mockApplicationRepository, + logger, + }); + expect(DVMDAllocatedHandler.prototype.handle).toHaveBeenCalled(); + }); + describe("fetchMatchAmount", () => { it("fetches the correct match amount and USD value", async () => { const matchingFundsAvailable = 1000; @@ -228,7 +262,6 @@ describe("DVMDDirectTransferHandler", () => { }); }); - it.skip("calls AllocatedHandler for Allocated event"); it.skip("calls TimestampsUpdatedHandler for TimestampsUpdated event"); it.skip("calls RecipientStatusUpdatedHandler for RecipientStatusUpdated event"); it.skip("calls DistributionUpdatedHandler for DistributionUpdated event"); diff --git a/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/allocated.handler.spec.ts b/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/allocated.handler.spec.ts new file mode 100644 index 0000000..3523787 --- /dev/null +++ b/packages/processors/test/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/allocated.handler.spec.ts @@ -0,0 +1,353 @@ +import { getAddress, pad, parseEther } from "viem"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { IPricingProvider } from "@grants-stack-indexer/pricing"; +import { + Application, + IApplicationRepository, + IRoundRepository, + Round, +} from "@grants-stack-indexer/repository"; +import { ChainId, DeepPartial, mergeDeep, ProcessorEvent } from "@grants-stack-indexer/shared"; + +import { + ApplicationNotFound, + MetadataParsingFailed, + RoundNotFound, + TokenPriceNotFoundError, + UnknownToken, +} from "../../../../src/exceptions/index.js"; +import { DVMDAllocatedHandler } from "../../../../src/strategy/donationVotingMerkleDistributionDirectTransfer/handlers/allocated.handler.js"; + +function createMockEvent( + overrides: DeepPartial> = {}, +): ProcessorEvent<"Strategy", "AllocatedWithOrigin"> { + const defaultEvent: ProcessorEvent<"Strategy", "AllocatedWithOrigin"> = { + params: { + recipientId: "0x1234567890123456789012345678901234567890", + amount: 10n, + token: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", + origin: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + sender: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + }, + eventName: "AllocatedWithOrigin", + 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("DVMDAllocatedHandler", () => { + let handler: DVMDAllocatedHandler; + let mockRoundRepository: IRoundRepository; + let mockApplicationRepository: IApplicationRepository; + let mockPricingProvider: IPricingProvider; + let mockEvent: ProcessorEvent<"Strategy", "AllocatedWithOrigin">; + const chainId = 10 as ChainId; + const expectedDonationId = "0x60077b059a7ca75483cf0651e209a0d5c14ad2afb1fd363c728f13680d24c546"; + + beforeEach(() => { + mockRoundRepository = { + getRoundByStrategyAddress: vi.fn(), + } as unknown as IRoundRepository; + mockApplicationRepository = { + getApplicationByAnchorAddress: vi.fn(), + } as unknown as IApplicationRepository; + mockPricingProvider = { + getTokenPrice: vi.fn(), + } as IPricingProvider; + }); + + it("handle a valid allocated event", async () => { + const amount = parseEther("10"); + mockEvent = createMockEvent({ params: { amount } }); + const mockRound = { + id: "round1", + matchTokenAddress: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", + } as unknown as Round; + const mockApplication = { + id: "app1", + metadata: { + application: { + round: "round1", + recipient: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + }, + }, + projectId: "project1", + } as unknown as Application; + + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddress").mockResolvedValue(mockRound); + vi.spyOn(mockApplicationRepository, "getApplicationByAnchorAddress").mockResolvedValue( + mockApplication, + ); + vi.spyOn(mockPricingProvider, "getTokenPrice").mockResolvedValue({ + timestampMs: 1000000000, + priceUsd: 2000, + }); + + handler = new DVMDAllocatedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + pricingProvider: mockPricingProvider, + }); + + const result = await handler.handle(); + + expect(result).toEqual([ + { + type: "InsertDonation", + args: { + donation: { + id: expectedDonationId, + chainId, + roundId: "round1", + applicationId: "app1", + donorAddress: getAddress("0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5"), + recipientAddress: getAddress("0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5"), + projectId: "project1", + transactionHash: mockEvent.transactionFields.hash, + blockNumber: BigInt(mockEvent.blockNumber), + tokenAddress: getAddress("0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"), + amount: amount, + amountInUsd: "20000", + amountInRoundMatchToken: amount, + timestamp: new Date(1000000000), + }, + }, + }, + ]); + }); + + it("match token is different from event token", async () => { + const amount = parseEther("1500"); + mockEvent = createMockEvent({ params: { amount } }); + const mockRound = { + id: "round1", + matchTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + } as unknown as Round; + const mockApplication = { + id: "app1", + metadata: { + application: { + round: "round1", + recipient: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + }, + }, + projectId: "project1", + } as unknown as Application; + + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddress").mockResolvedValue(mockRound); + vi.spyOn(mockApplicationRepository, "getApplicationByAnchorAddress").mockResolvedValue( + mockApplication, + ); + vi.spyOn(mockPricingProvider, "getTokenPrice") + .mockResolvedValueOnce({ + timestampMs: 1000000000, + priceUsd: 1, + }) + .mockResolvedValueOnce({ + timestampMs: 1000000000, + priceUsd: 2000, + }); + + handler = new DVMDAllocatedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + pricingProvider: mockPricingProvider, + }); + + const result = await handler.handle(); + + expect(result).toEqual([ + { + type: "InsertDonation", + args: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + donation: expect.objectContaining({ + tokenAddress: getAddress("0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"), + amount: amount, + amountInUsd: "1500", + amountInRoundMatchToken: parseEther("0.75"), + timestamp: new Date(1000000000), + }), + }, + }, + ]); + }); + + it("throws RoundNotFound if round is not found", async () => { + mockEvent = createMockEvent(); + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddress").mockResolvedValue(undefined); + + handler = new DVMDAllocatedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + pricingProvider: mockPricingProvider, + }); + + await expect(handler.handle()).rejects.toThrow(RoundNotFound); + }); + + it("throws ApplicationNotFound if application is not found", async () => { + mockEvent = createMockEvent(); + const mockRound = { + id: "round1", + matchTokenAddress: "0x0987654321098765432109876543210987654321", + } as unknown as Round; + + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddress").mockResolvedValue(mockRound); + vi.spyOn(mockApplicationRepository, "getApplicationByAnchorAddress").mockResolvedValue( + undefined, + ); + + handler = new DVMDAllocatedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + pricingProvider: mockPricingProvider, + }); + + await expect(handler.handle()).rejects.toThrow(ApplicationNotFound); + }); + + it("throws UnknownToken if params token is not found", async () => { + mockEvent = createMockEvent({ + params: { token: pad("0x1", { size: 20 }) }, + }); + const mockRound = { + id: "round1", + matchTokenAddress: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", + } as unknown as Round; + const mockApplication = { + id: "app1", + metadata: { + application: { + round: "round1", + recipient: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + }, + }, + projectId: "project1", + } as unknown as Application; + + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddress").mockResolvedValue(mockRound); + vi.spyOn(mockApplicationRepository, "getApplicationByAnchorAddress").mockResolvedValue( + mockApplication, + ); + + handler = new DVMDAllocatedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + pricingProvider: mockPricingProvider, + }); + + await expect(handler.handle()).rejects.toThrow(UnknownToken); + }); + + it("throws UnknownToken if match token is not found", async () => { + mockEvent = createMockEvent(); + const mockRound = { + id: "round1", + matchTokenAddress: pad("0x1", { size: 20 }), + } as unknown as Round; + const mockApplication = { + id: "app1", + metadata: { + application: { + round: "round1", + recipient: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + }, + }, + projectId: "project1", + } as unknown as Application; + + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddress").mockResolvedValue(mockRound); + vi.spyOn(mockApplicationRepository, "getApplicationByAnchorAddress").mockResolvedValue( + mockApplication, + ); + + handler = new DVMDAllocatedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + pricingProvider: mockPricingProvider, + }); + + await expect(handler.handle()).rejects.toThrow(UnknownToken); + }); + + it("throws TokenPriceNotFound if token price is not found", async () => { + mockEvent = createMockEvent(); + const mockRound = { + id: "round1", + matchTokenAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", + } as unknown as Round; + const mockApplication = { + id: "app1", + metadata: { + application: { + round: "round1", + recipient: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + }, + }, + projectId: "project1", + } as unknown as Application; + + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddress").mockResolvedValue(mockRound); + vi.spyOn(mockApplicationRepository, "getApplicationByAnchorAddress").mockResolvedValue( + mockApplication, + ); + vi.spyOn(mockPricingProvider, "getTokenPrice").mockResolvedValue(undefined); + + handler = new DVMDAllocatedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + pricingProvider: mockPricingProvider, + }); + + await expect(handler.handle()).rejects.toThrow(TokenPriceNotFoundError); + }); + + it("throws MetadataParsingFailed if metadata is invalid", async () => { + mockEvent = createMockEvent(); + + const mockRound = { + id: "round1", + matchTokenAddress: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", + } as unknown as Round; + const mockApplication = { + id: "app1", + metadata: { + application: { + recipient: 10n, // recipient is not a string + }, + }, + projectId: "project1", + } as unknown as Application; + + vi.spyOn(mockRoundRepository, "getRoundByStrategyAddress").mockResolvedValue(mockRound); + vi.spyOn(mockApplicationRepository, "getApplicationByAnchorAddress").mockResolvedValue( + mockApplication, + ); + vi.spyOn(mockPricingProvider, "getTokenPrice").mockResolvedValue({ + timestampMs: 1000000000, + priceUsd: 2000, + }); + + handler = new DVMDAllocatedHandler(mockEvent, chainId, { + roundRepository: mockRoundRepository, + applicationRepository: mockApplicationRepository, + pricingProvider: mockPricingProvider, + }); + + await expect(handler.handle()).rejects.toThrow(MetadataParsingFailed); + }); +}); diff --git a/packages/processors/test/strategy/strategy.processor.spec.ts b/packages/processors/test/strategy/strategy.processor.spec.ts index 4d2e73a..3675952 100644 --- a/packages/processors/test/strategy/strategy.processor.spec.ts +++ b/packages/processors/test/strategy/strategy.processor.spec.ts @@ -4,6 +4,7 @@ import type { EvmProvider } from "@grants-stack-indexer/chain-providers"; import type { IMetadataProvider } from "@grants-stack-indexer/metadata"; import type { IPricingProvider } from "@grants-stack-indexer/pricing"; import type { + IApplicationReadRepository, IProjectReadRepository, IRoundReadRepository, } from "@grants-stack-indexer/repository"; @@ -36,6 +37,7 @@ describe("StrategyProcessor", () => { metadataProvider: mockMetadataProvider, roundRepository: mockRoundRepository, projectRepository: {} as IProjectReadRepository, + applicationRepository: {} as IApplicationReadRepository, logger, }); diff --git a/packages/processors/test/strategy/strategyHandler.factory.spec.ts b/packages/processors/test/strategy/strategyHandler.factory.spec.ts index ffbe3eb..6f77033 100644 --- a/packages/processors/test/strategy/strategyHandler.factory.spec.ts +++ b/packages/processors/test/strategy/strategyHandler.factory.spec.ts @@ -4,7 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EvmProvider } from "@grants-stack-indexer/chain-providers"; import { IMetadataProvider } from "@grants-stack-indexer/metadata"; import { IPricingProvider } from "@grants-stack-indexer/pricing"; -import { IProjectReadRepository, IRoundReadRepository } from "@grants-stack-indexer/repository"; +import { + IApplicationReadRepository, + IProjectReadRepository, + IRoundReadRepository, +} from "@grants-stack-indexer/repository"; import { ChainId, ILogger } from "@grants-stack-indexer/shared"; import { ProcessorDependencies, StrategyHandlerFactory } from "../../src/internal.js"; @@ -18,6 +22,8 @@ describe("StrategyHandlerFactory", () => { let mockRoundRepository: IRoundReadRepository; let mockProjectRepository: IProjectReadRepository; let mockProcessorDependencies: ProcessorDependencies; + let mockApplicationRepository: IApplicationReadRepository; + const logger: ILogger = { debug: vi.fn(), error: vi.fn(), @@ -30,12 +36,14 @@ describe("StrategyHandlerFactory", () => { mockMetadataProvider = {} as IMetadataProvider; mockRoundRepository = {} as IRoundReadRepository; mockProjectRepository = {} as IProjectReadRepository; + mockApplicationRepository = {} as IApplicationReadRepository; mockProcessorDependencies = { evmProvider: mockEvmProvider, pricingProvider: mockPricingProvider, metadataProvider: mockMetadataProvider, roundRepository: mockRoundRepository, projectRepository: mockProjectRepository, + applicationRepository: mockApplicationRepository, logger, }; }); diff --git a/packages/repository/src/db/connection.ts b/packages/repository/src/db/connection.ts index 6c9e0a6..2da393a 100644 --- a/packages/repository/src/db/connection.ts +++ b/packages/repository/src/db/connection.ts @@ -3,6 +3,7 @@ import pg from "pg"; import { Application, + Donation as DonationTable, PendingProjectRole as PendingProjectRoleTable, PendingRoundRole as PendingRoundRoleTable, ProjectRole as ProjectRoleTable, @@ -35,6 +36,7 @@ export interface Database { pendingProjectRoles: PendingProjectRoleTable; projectRoles: ProjectRoleTable; applications: ApplicationTable; + donations: DonationTable; } /** diff --git a/packages/repository/src/external.ts b/packages/repository/src/external.ts index a7d4553..3568aaa 100644 --- a/packages/repository/src/external.ts +++ b/packages/repository/src/external.ts @@ -6,6 +6,7 @@ export type { IProjectReadRepository, IApplicationRepository, IApplicationReadRepository, + IDonationRepository, DatabaseConfig, } from "./internal.js"; @@ -17,15 +18,9 @@ export type { PartialProject, ProjectRole, PendingProjectRole, -} from "./types/project.types.js"; +} from "./types/index.js"; -export type { - Round, - NewRound, - PartialRound, - RoundRole, - PendingRoundRole, -} from "./types/round.types.js"; +export type { Round, NewRound, PartialRound, RoundRole, PendingRoundRole } from "./types/index.js"; export type { ApplicationStatus, @@ -33,19 +28,23 @@ export type { Application, NewApplication, PartialApplication, -} from "./types/application.types.js"; +} from "./types/index.js"; + +export type { Donation, NewDonation } from "./types/index.js"; export type { Changeset, ProjectChangeset, RoundChangeset, ApplicationChangeset, + DonationChangeset, } from "./types/index.js"; export { KyselyRoundRepository, KyselyProjectRepository, KyselyApplicationRepository, + KyselyDonationRepository, } from "./repositories/kysely/index.js"; export { createKyselyPostgresDb as createKyselyDatabase } from "./internal.js"; diff --git a/packages/repository/src/interfaces/donationRepository.interface.ts b/packages/repository/src/interfaces/donationRepository.interface.ts new file mode 100644 index 0000000..4ddccc5 --- /dev/null +++ b/packages/repository/src/interfaces/donationRepository.interface.ts @@ -0,0 +1,17 @@ +import { NewDonation } from "../internal.js"; + +export interface IDonationRepository { + /** + * Insert a single donation + * @param donation The donation to insert + * @returns A promise that resolves when the donation is inserted + */ + insertDonation(donation: NewDonation): Promise; + + /** + * Insert many donations + * @param donations The donations to insert + * @returns A promise that resolves when the donations are inserted + */ + insertManyDonations(donations: NewDonation[]): Promise; +} diff --git a/packages/repository/src/interfaces/index.ts b/packages/repository/src/interfaces/index.ts index a40ec10..73950fd 100644 --- a/packages/repository/src/interfaces/index.ts +++ b/packages/repository/src/interfaces/index.ts @@ -1,3 +1,4 @@ export * from "./projectRepository.interface.js"; export * from "./roundRepository.interface.js"; export * from "./applicationRepository.interface.js"; +export * from "./donationRepository.interface.js"; diff --git a/packages/repository/src/repositories/kysely/donation.repository.ts b/packages/repository/src/repositories/kysely/donation.repository.ts new file mode 100644 index 0000000..85a762d --- /dev/null +++ b/packages/repository/src/repositories/kysely/donation.repository.ts @@ -0,0 +1,35 @@ +import { Kysely } from "kysely"; + +import { IDonationRepository } from "../../interfaces/donationRepository.interface.js"; +import { Database, NewDonation } from "../../internal.js"; + +export class KyselyDonationRepository implements IDonationRepository { + constructor( + private readonly db: Kysely, + private readonly schemaName: string, + ) {} + + /** @inheritdoc */ + async insertDonation(donation: NewDonation): Promise { + await this.db + .withSchema(this.schemaName) + .insertInto("donations") + .values(donation) + .onConflict((c) => { + return c.column("id").doNothing(); + }) + .execute(); + } + + /** @inheritdoc */ + async insertManyDonations(donations: NewDonation[]): Promise { + await this.db + .withSchema(this.schemaName) + .insertInto("donations") + .values(donations) + .onConflict((c) => { + return c.column("id").doNothing(); + }) + .execute(); + } +} diff --git a/packages/repository/src/repositories/kysely/index.ts b/packages/repository/src/repositories/kysely/index.ts index 75d6410..b94e71f 100644 --- a/packages/repository/src/repositories/kysely/index.ts +++ b/packages/repository/src/repositories/kysely/index.ts @@ -1,3 +1,4 @@ export * from "./project.repository.js"; export * from "./round.repository.js"; export * from "./application.repository.js"; +export * from "./donation.repository.js"; diff --git a/packages/repository/src/types/changeset.types.ts b/packages/repository/src/types/changeset.types.ts index 648d111..0ccce97 100644 --- a/packages/repository/src/types/changeset.types.ts +++ b/packages/repository/src/types/changeset.types.ts @@ -1,6 +1,7 @@ import type { Address, ChainId } from "@grants-stack-indexer/shared"; import { NewApplication, PartialApplication } from "./application.types.js"; +import { NewDonation } from "./donation.types.js"; import { NewPendingProjectRole, NewProject, @@ -144,6 +145,24 @@ export type ApplicationChangeset = }; }; +export type DonationChangeset = + | { + type: "InsertDonation"; + args: { + donation: NewDonation; + }; + } + | { + type: "InsertManyDonations"; + args: { + donations: NewDonation[]; + }; + }; + //TODO: add changeset for Donation and Payout tables -export type Changeset = ProjectChangeset | RoundChangeset | ApplicationChangeset; +export type Changeset = + | ProjectChangeset + | RoundChangeset + | ApplicationChangeset + | DonationChangeset; diff --git a/packages/repository/src/types/donation.types.ts b/packages/repository/src/types/donation.types.ts new file mode 100644 index 0000000..ea66514 --- /dev/null +++ b/packages/repository/src/types/donation.types.ts @@ -0,0 +1,20 @@ +import { Address, ChainId, Hex } from "@grants-stack-indexer/shared"; + +export type Donation = { + id: string; + chainId: ChainId; + roundId: Address | string; + applicationId: string; + donorAddress: Address; + recipientAddress: Address; + projectId: string; + transactionHash: Hex; + blockNumber: bigint; + tokenAddress: Address; + amount: bigint; + amountInUsd: string; + amountInRoundMatchToken: bigint; + timestamp: Date; +}; + +export type NewDonation = Donation; diff --git a/packages/repository/src/types/index.ts b/packages/repository/src/types/index.ts index 5eaee5f..afb0277 100644 --- a/packages/repository/src/types/index.ts +++ b/packages/repository/src/types/index.ts @@ -2,3 +2,4 @@ export * from "./project.types.js"; export * from "./round.types.js"; export * from "./application.types.js"; export * from "./changeset.types.js"; +export * from "./donation.types.js"; diff --git a/packages/shared/src/types/events/strategy.ts b/packages/shared/src/types/events/strategy.ts index 73a87a4..d9ac6c1 100644 --- a/packages/shared/src/types/events/strategy.ts +++ b/packages/shared/src/types/events/strategy.ts @@ -12,7 +12,11 @@ const StrategyEventArray = [ "DistributedWithData", "DistributedWithFlowRate", "TimestampsUpdated", + "AllocatedWithOrigin", "AllocatedWithToken", + "AllocatedWithData", + "AllocatedWithVotes", + "AllocatedWithStatus", ] as const; /** @@ -37,7 +41,11 @@ export type StrategyEventParams = T extends "Registered ? TimestampsUpdatedParams : T extends "AllocatedWithToken" ? AllocatedWithTokenParams - : never; + : T extends "AllocatedWithOrigin" + ? AllocatedWithOriginParams + : T extends "AllocatedWithVotes" + ? AllocatedWithVotesParams + : never; // ============================================================================= // =============================== Event Parameters ============================ @@ -82,9 +90,30 @@ export type TimestampsUpdatedParams = { // ======================= Allocated ======================= export type AllocatedWithTokenParams = { - contractAddress: Address; - tokenAddress: Address; - amount: number; + recipientId: Address; + amount: bigint; + token: Address; + sender: Address; +}; + +export type AllocatedWithOriginParams = { + recipientId: Address; + amount: bigint; + token: Address; + sender: Address; + origin: Address; +}; + +export type AllocatedWithVotesParams = { + recipientId: Address; + votes: bigint; + allocator: Address; +}; + +export type AllocatedWithStatusParams = { + recipientId: Address; + status: number; + sender: Address; }; /**