diff --git a/packages/processors/src/allo/allo.processor.ts b/packages/processors/src/allo/allo.processor.ts index 692e386..3735c46 100644 --- a/packages/processors/src/allo/allo.processor.ts +++ b/packages/processors/src/allo/allo.processor.ts @@ -18,7 +18,7 @@ export class AlloProcessor implements IProcessor<"Allo", AlloEvent> { private readonly roundRepository: IRoundReadRepository, ) {} - process(event: ProtocolEvent<"Allo", AlloEvent>): Promise { + async process(event: ProtocolEvent<"Allo", AlloEvent>): Promise { switch (event.eventName) { case "PoolCreated": return new PoolCreatedHandler(event, this.chainId, { @@ -28,7 +28,7 @@ export class AlloProcessor implements IProcessor<"Allo", AlloEvent> { roundRepository: this.roundRepository, }).handle(); default: - throw new Error("Unknown event name"); + throw new Error(`Unknown event name: ${event.eventName}`); } } } diff --git a/packages/processors/src/allo/handlers/poolCreated.handler.ts b/packages/processors/src/allo/handlers/poolCreated.handler.ts index a71834e..fb9317d 100644 --- a/packages/processors/src/allo/handlers/poolCreated.handler.ts +++ b/packages/processors/src/allo/handlers/poolCreated.handler.ts @@ -11,7 +11,12 @@ import { import type { IMetadataProvider } from "@grants-stack-indexer/metadata"; import type { IPricingProvider } from "@grants-stack-indexer/pricing"; -import type { Changeset, IRoundReadRepository, NewRound } from "@grants-stack-indexer/repository"; +import type { + Changeset, + IRoundReadRepository, + NewRound, + PendingRoundRole, +} from "@grants-stack-indexer/repository"; import type { ChainId, ProtocolEvent } from "@grants-stack-indexer/shared"; import { isAlloNativeToken } from "@grants-stack-indexer/shared/"; @@ -222,6 +227,7 @@ export class PoolCreatedHandler implements IEventHandler<"Allo", "PoolCreated"> */ private async handlePendingRoles(chainId: ChainId, roundId: string): Promise { const changes: Changeset[] = []; + const allPendingRoles: PendingRoundRole[] = []; for (const roleName of ["admin", "manager"] as const) { const pendingRoles = await this.roundRepository.getPendingRoundRoles(chainId, roleName); @@ -239,13 +245,15 @@ export class PoolCreatedHandler implements IEventHandler<"Allo", "PoolCreated"> }, }); } + allPendingRoles.push(...pendingRoles); + } - if (pendingRoles.length > 0) { - changes.push({ - type: "DeletePendingRoundRoles", - args: { ids: pendingRoles.map((r) => r.id!) }, - }); - } + const pendingRoleIds = [...new Set(allPendingRoles.map((r) => r.id!))]; + if (pendingRoleIds.length > 0) { + changes.push({ + type: "DeletePendingRoundRoles", + args: { ids: pendingRoleIds }, + }); } return changes; @@ -267,6 +275,6 @@ export class PoolCreatedHandler implements IEventHandler<"Allo", "PoolCreated"> throw new Error("Token price not found"); } - return calculateAmountInUsd(amount, tokenPrice, token.decimals); + return calculateAmountInUsd(amount, tokenPrice.priceUsd, token.decimals); } } diff --git a/packages/processors/src/helpers/tokenMath.ts b/packages/processors/src/helpers/tokenMath.ts index c0fed42..4d32849 100644 --- a/packages/processors/src/helpers/tokenMath.ts +++ b/packages/processors/src/helpers/tokenMath.ts @@ -1,7 +1,5 @@ import { formatUnits, parseUnits } from "viem"; -import { TokenPrice } from "@grants-stack-indexer/pricing"; - /** * Calculates the amount in USD * @param amount - The amount to convert to USD @@ -12,13 +10,13 @@ import { TokenPrice } from "@grants-stack-indexer/pricing"; */ export const calculateAmountInUsd = ( amount: bigint, - tokenPrice: TokenPrice, + tokenPriceInUsd: number, tokenDecimals: number, truncateDecimals?: number, ): number => { const amountInUsd = Number( formatUnits( - amount * parseUnits(tokenPrice.priceUsd.toString(), tokenDecimals), + amount * parseUnits(tokenPriceInUsd.toString(), tokenDecimals), tokenDecimals * 2, ), ); diff --git a/packages/processors/test/allo/allo.processor.spec.ts b/packages/processors/test/allo/allo.processor.spec.ts new file mode 100644 index 0000000..8e7119b --- /dev/null +++ b/packages/processors/test/allo/allo.processor.spec.ts @@ -0,0 +1,76 @@ +import { Chain, PublicClient, Transport } from "viem"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { IMetadataProvider } from "@grants-stack-indexer/metadata"; +import type { IPricingProvider } from "@grants-stack-indexer/pricing"; +import type { IRoundReadRepository } from "@grants-stack-indexer/repository"; +import type { AlloEvent, ChainId, ProtocolEvent } from "@grants-stack-indexer/shared"; + +import { AlloProcessor } from "../../src/allo/allo.processor.js"; +import { PoolCreatedHandler } from "../../src/allo/handlers/poolCreated.handler.js"; + +// Mock the handlers +vi.mock("../../src/allo/handlers/poolCreated.handler.js", () => { + const PoolCreatedHandler = vi.fn(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + PoolCreatedHandler.prototype.handle = vi.fn(); + return { + PoolCreatedHandler, + }; +}); + +describe("AlloProcessor", () => { + const mockChainId = 10 as ChainId; + let processor: AlloProcessor; + let mockViemClient: PublicClient; + let mockPricingProvider: IPricingProvider; + let mockMetadataProvider: IMetadataProvider; + let mockRoundRepository: IRoundReadRepository; + + beforeEach(() => { + mockViemClient = {} as PublicClient; + mockPricingProvider = {} as IPricingProvider; + mockMetadataProvider = {} as IMetadataProvider; + mockRoundRepository = {} as IRoundReadRepository; + + processor = new AlloProcessor( + mockChainId, + mockViemClient, + mockPricingProvider, + mockMetadataProvider, + mockRoundRepository, + ); + + // Reset mocks before each test + vi.clearAllMocks(); + }); + + it("call PoolCreatedHandler for PoolCreated event", async () => { + const mockEvent: ProtocolEvent<"Allo", "PoolCreated"> = { + eventName: "PoolCreated", + // Add other necessary event properties here + } as ProtocolEvent<"Allo", "PoolCreated">; + + vi.spyOn(PoolCreatedHandler.prototype, "handle").mockResolvedValue([]); + + await processor.process(mockEvent); + + expect(PoolCreatedHandler).toHaveBeenCalledWith(mockEvent, mockChainId, { + viemClient: mockViemClient, + pricingProvider: mockPricingProvider, + metadataProvider: mockMetadataProvider, + roundRepository: mockRoundRepository, + }); + expect(PoolCreatedHandler.prototype.handle).toHaveBeenCalled(); + }); + + it("throw an error for unknown event names", async () => { + const mockEvent = { + eventName: "UnknownEvent", + } as unknown as ProtocolEvent<"Allo", AlloEvent>; + + await expect(() => processor.process(mockEvent)).rejects.toThrow( + "Unknown event name: UnknownEvent", + ); + }); +}); diff --git a/packages/processors/test/allo/handlers/poolCreated.handler.spec.ts b/packages/processors/test/allo/handlers/poolCreated.handler.spec.ts new file mode 100644 index 0000000..a20e2cf --- /dev/null +++ b/packages/processors/test/allo/handlers/poolCreated.handler.spec.ts @@ -0,0 +1,430 @@ +import { Chain, GetTransactionReturnType, parseUnits, PublicClient, Transport } from "viem"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ChainId, DeepPartial, ProtocolEvent } from "@grants-stack-indexer/shared"; +import { IMetadataProvider } from "@grants-stack-indexer/metadata"; +import { IPricingProvider } from "@grants-stack-indexer/pricing"; +import { IRoundReadRepository, Round } from "@grants-stack-indexer/repository"; +import { mergeDeep } from "@grants-stack-indexer/shared"; + +import { PoolCreatedHandler } from "../../../src/allo/handlers/poolCreated.handler.js"; +import * as strategy from "../../../src/helpers/strategy.js"; + +vi.mock("../../../src/helpers/strategy.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDonationVotingMerkleDistributionDirectTransferStrategyTimings: vi.fn(), + getDirectGrantsStrategyTimings: vi.fn(), + }; +}); + +// Function to create a mock event with optional overrides +function createMockEvent( + overrides: DeepPartial> = {}, +): ProtocolEvent<"Allo", "PoolCreated"> { + const defaultEvent: ProtocolEvent<"Allo", "PoolCreated"> = { + blockNumber: 116385567, + blockTimestamp: 1708369911, + chainId: 10 as ChainId, + contractName: "Allo", + eventName: "PoolCreated", + logIndex: 221, + srcAddress: "0x1133eA7Af70876e64665ecD07C0A0476d09465a1", + params: { + contractAddress: "0xD545fbA3f43EcA447CC7FBF41D4A8F0f575F2491", + poolId: 10n, + profileId: "0xcc3509068dfb6604965939f100e57dde21e9d764d8ce4b34284bbe9364b1f5ed", + strategyId: "0x9fa6890423649187b1f0e8bf4265f0305ce99523c3d11aa36b35a54617bb0ec0", + amount: 0n, + token: "0x4200000000000000000000000000000000000042", + metadata: { + pointer: "bafkreihrjyu5tney6wia2hmkertc74nzfpsgxw2epvnxm72bxj6ifnd4ku", + protocol: 1n, + }, + }, + transactionFields: { + hash: "0xd2352acdcd59e312370831ea927d51a1917654697a72434cd905a60897a5bb8b", + transactionIndex: 6, + from: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + }, + }; + + return mergeDeep(defaultEvent, overrides) as ProtocolEvent<"Allo", "PoolCreated">; +} + +describe("PoolCreatedHandler", () => { + let mockViemClient: PublicClient; + let mockPricingProvider: IPricingProvider; + let mockMetadataProvider: IMetadataProvider; + let mockRoundRepository: IRoundReadRepository; + + beforeEach(() => { + mockViemClient = { + readContract: vi.fn(), + getTransaction: vi.fn(), + } as unknown as PublicClient; + mockPricingProvider = { + getTokenPrice: vi.fn(), + }; + mockMetadataProvider = { + getMetadata: vi.fn(), + }; + mockRoundRepository = { + getPendingRoundRoles: vi.fn(), + } as unknown as IRoundReadRepository; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("process an event with initial funds", async () => { + const fundedAmount = parseUnits("10", 18); + const mockEvent = createMockEvent({ + params: { amount: fundedAmount, strategyId: "0xunknown" }, + }); + + vi.spyOn(mockPricingProvider, "getTokenPrice").mockResolvedValue({ + priceUsd: 100, + timestampMs: 1708369911, + }); + vi.spyOn(mockRoundRepository, "getPendingRoundRoles").mockResolvedValue([]); + + const handler = new PoolCreatedHandler(mockEvent, 10 as ChainId, { + viemClient: mockViemClient, + pricingProvider: mockPricingProvider, + metadataProvider: mockMetadataProvider, + roundRepository: mockRoundRepository, + }); + + const result = await handler.handle(); + + const changeset = result[0] as { type: "InsertRound"; args: { round: Round } }; + expect(changeset.type).toBe("InsertRound"); + expect(changeset.args.round).toMatchObject({ + fundedAmount: fundedAmount, + fundedAmountInUsd: 1000, + }); + expect(mockPricingProvider.getTokenPrice).toHaveBeenCalled(); + expect(mockMetadataProvider.getMetadata).toHaveBeenCalled(); + }); + + it("process an unknown strategyId", async () => { + const mockEvent = createMockEvent({ + params: { strategyId: "0xunknown" }, + }); + + vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue(undefined); + vi.spyOn(mockRoundRepository, "getPendingRoundRoles").mockResolvedValue([]); + + const handler = new PoolCreatedHandler(mockEvent, 10 as ChainId, { + viemClient: mockViemClient, + pricingProvider: mockPricingProvider, + metadataProvider: mockMetadataProvider, + roundRepository: mockRoundRepository, + }); + + const result = await handler.handle(); + + const changeset = result[0] as { type: "InsertRound"; args: { round: Round } }; + expect(changeset.type).toBe("InsertRound"); + expect(changeset.args.round).toMatchObject({ + chainId: 10, + id: "10", + tags: ["allo-v2"], + strategyAddress: mockEvent.params.contractAddress, + strategyId: "0xunknown", + strategyName: "", + createdByAddress: mockEvent.transactionFields.from, + }); + expect(mockPricingProvider.getTokenPrice).not.toHaveBeenCalled(); + expect(strategy.getDirectGrantsStrategyTimings).not.toHaveBeenCalled(); + expect( + strategy.getDonationVotingMerkleDistributionDirectTransferStrategyTimings, + ).not.toHaveBeenCalled(); + }); + + it("process a DonationVotingMerkleDistributionDirectTransferStrategy", async () => { + const mockEvent = createMockEvent(); + + vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue({ + round: { + name: "Test Round", + roundType: "private", + quadraticFundingConfig: { + matchingFundsAvailable: 1, + }, + }, + application: { + version: "1.0.0", + }, + }); + + vi.spyOn(mockPricingProvider, "getTokenPrice").mockResolvedValue({ + priceUsd: 100, + timestampMs: 1708369911, + }); + vi.spyOn( + strategy, + "getDonationVotingMerkleDistributionDirectTransferStrategyTimings", + ).mockResolvedValue({ + applicationsStartTime: new Date(), + applicationsEndTime: new Date(), + donationsStartTime: new Date(), + donationsEndTime: new Date(), + }); + + vi.spyOn(mockRoundRepository, "getPendingRoundRoles") + .mockResolvedValueOnce([ + { + chainId: 10 as ChainId, + role: "admin", + address: "0x1133eA7Af70876e64665ecD07C0A0476d09465a1", + createdAtBlock: 116385567n, + }, + ]) + .mockResolvedValue([]); + + const handler = new PoolCreatedHandler(mockEvent, 10 as ChainId, { + viemClient: mockViemClient, + pricingProvider: mockPricingProvider, + metadataProvider: mockMetadataProvider, + roundRepository: mockRoundRepository, + }); + + const result = await handler.handle(); + + expect(result).toHaveLength(3); + + const changeset = result[0] as { type: "InsertRound"; args: { round: Round } }; + expect(changeset.type).toBe("InsertRound"); + expect(changeset.args.round).toMatchObject({ + chainId: 10, + id: "10", + tags: ["allo-v2", "grants-stack"], + totalDonationsCount: 0, + totalAmountDonatedInUsd: 0, + uniqueDonorsCount: 0, + matchTokenAddress: mockEvent.params.token, + matchAmount: parseUnits("1", 18), + matchAmountInUsd: 100, + fundedAmount: 0n, + fundedAmountInUsd: 0, + applicationMetadataCid: "bafkreihrjyu5tney6wia2hmkertc74nzfpsgxw2epvnxm72bxj6ifnd4ku", + applicationMetadata: { + version: "1.0.0", + }, + roundMetadataCid: "bafkreihrjyu5tney6wia2hmkertc74nzfpsgxw2epvnxm72bxj6ifnd4ku", + roundMetadata: { + name: "Test Round", + roundType: "private", + quadraticFundingConfig: { + matchingFundsAvailable: 1, + }, + }, + strategyAddress: mockEvent.params.contractAddress, + strategyId: "0x9fa6890423649187b1f0e8bf4265f0305ce99523c3d11aa36b35a54617bb0ec0", + strategyName: "allov2.DonationVotingMerkleDistributionDirectTransferStrategy", + createdByAddress: mockEvent.transactionFields.from, + createdAtBlock: BigInt(mockEvent.blockNumber), + updatedAtBlock: BigInt(mockEvent.blockNumber), + projectId: mockEvent.params.profileId, + totalDistributed: 0n, + readyForPayoutTransaction: null, + matchingDistribution: null, + }); + expect(mockPricingProvider.getTokenPrice).toHaveBeenCalled(); + expect(mockMetadataProvider.getMetadata).toHaveBeenCalled(); + }); + + it("fetches transaction sender if not present in event", async () => { + const mockEvent = createMockEvent({ + params: { strategyId: "0xunknown" }, + transactionFields: { + hash: "0xd2352acdcd59e312370831ea927d51a1917654697a72434cd905a60897a5bb8b", + from: undefined, + }, + }); + + vi.spyOn(mockPricingProvider, "getTokenPrice").mockResolvedValue({ + priceUsd: 100, + timestampMs: 1708369911, + }); + vi.spyOn(mockRoundRepository, "getPendingRoundRoles").mockResolvedValue([]); + vi.spyOn(mockViemClient, "getTransaction").mockResolvedValue({ + from: "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + } as unknown as GetTransactionReturnType); + + const handler = new PoolCreatedHandler(mockEvent, 10 as ChainId, { + viemClient: mockViemClient, + pricingProvider: mockPricingProvider, + metadataProvider: mockMetadataProvider, + roundRepository: mockRoundRepository, + }); + + const result = await handler.handle(); + + const changeset = result[0] as { type: "InsertRound"; args: { round: Round } }; + expect(changeset.args.round.createdByAddress).toBe( + "0xcBf407C33d68a55CB594Ffc8f4fD1416Bba39DA5", + ); + expect(mockViemClient.getTransaction).toHaveBeenCalledWith({ + hash: "0xd2352acdcd59e312370831ea927d51a1917654697a72434cd905a60897a5bb8b", + }); + }); + + it("handles an undefined metadata", async () => { + const mockEvent = createMockEvent(); + + vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue(undefined); + vi.spyOn( + strategy, + "getDonationVotingMerkleDistributionDirectTransferStrategyTimings", + ).mockResolvedValue({ + applicationsStartTime: new Date(), + applicationsEndTime: new Date(), + donationsStartTime: new Date(), + donationsEndTime: new Date(), + }); + vi.spyOn(mockRoundRepository, "getPendingRoundRoles").mockResolvedValue([]); + + const handler = new PoolCreatedHandler(mockEvent, 10 as ChainId, { + viemClient: mockViemClient, + pricingProvider: mockPricingProvider, + metadataProvider: mockMetadataProvider, + roundRepository: mockRoundRepository, + }); + + const result = await handler.handle(); + + const changeset = result[0] as { type: "InsertRound"; args: { round: Round } }; + expect(changeset.type).toBe("InsertRound"); + expect(changeset.args.round).toMatchObject({ + chainId: 10, + id: "10", + tags: ["allo-v2"], + matchAmount: 0n, + matchAmountInUsd: 0, + fundedAmount: 0n, + fundedAmountInUsd: 0, + applicationMetadataCid: "bafkreihrjyu5tney6wia2hmkertc74nzfpsgxw2epvnxm72bxj6ifnd4ku", + applicationMetadata: {}, + roundMetadataCid: "bafkreihrjyu5tney6wia2hmkertc74nzfpsgxw2epvnxm72bxj6ifnd4ku", + roundMetadata: null, + readyForPayoutTransaction: null, + matchingDistribution: null, + }); + + expect(mockPricingProvider.getTokenPrice).not.toHaveBeenCalled(); + expect(mockMetadataProvider.getMetadata).toHaveBeenCalled(); + }); + + it("returns empty changeset if token price fetch fails", async () => { + const mockEvent = createMockEvent({ params: { amount: 1n } }); + + vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue(undefined); + + vi.spyOn(mockPricingProvider, "getTokenPrice").mockResolvedValue(undefined); + + const handler = new PoolCreatedHandler(mockEvent, 10 as ChainId, { + viemClient: mockViemClient, + pricingProvider: mockPricingProvider, + metadataProvider: mockMetadataProvider, + roundRepository: mockRoundRepository, + }); + + const result = await handler.handle(); + expect(result).toHaveLength(0); + }); + + it("handles pending round roles", async () => { + const mockEvent = createMockEvent(); + + vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue(undefined); + vi.spyOn(mockPricingProvider, "getTokenPrice").mockResolvedValue({ + priceUsd: 100, + timestampMs: 1708369911, + }); + vi.spyOn( + strategy, + "getDonationVotingMerkleDistributionDirectTransferStrategyTimings", + ).mockResolvedValue({ + applicationsStartTime: new Date(), + applicationsEndTime: new Date(), + donationsStartTime: new Date(), + donationsEndTime: new Date(), + }); + + vi.spyOn(mockRoundRepository, "getPendingRoundRoles") + .mockResolvedValueOnce([ + { + id: 1, + chainId: 10 as ChainId, + role: "admin", + address: "0x1133eA7Af70876e64665ecD07C0A0476d09465a1", + createdAtBlock: 116385565n, + }, + ]) + .mockResolvedValueOnce([ + { + id: 2, + chainId: 10 as ChainId, + role: "manager", + address: "0x1234567890123456789012345678901234567890", + createdAtBlock: 116385565n, + }, + { + id: 3, + chainId: 10 as ChainId, + role: "manager", + address: "0xAaBBccdDeEFf0000000000000000000000000000", + createdAtBlock: 116385565n, + }, + ]); + + const handler = new PoolCreatedHandler(mockEvent, 10 as ChainId, { + viemClient: mockViemClient, + pricingProvider: mockPricingProvider, + metadataProvider: mockMetadataProvider, + roundRepository: mockRoundRepository, + }); + + const result = await handler.handle(); + + expect(result).toHaveLength(5); + + const changeset = result[0] as { type: "InsertRound"; args: { round: Round } }; + expect(changeset.type).toBe("InsertRound"); + + expect(result.filter((c) => c.type === "InsertRoundRole")).toHaveLength(3); + expect(result.filter((c) => c.type === "InsertRoundRole")[0].args.roundRole).toMatchObject({ + chainId: 10 as ChainId, + roundId: "10", + role: "admin", + address: "0x1133eA7Af70876e64665ecD07C0A0476d09465a1", + createdAtBlock: 116385565n, + }); + expect(result.filter((c) => c.type === "InsertRoundRole")[1].args.roundRole).toMatchObject({ + chainId: 10 as ChainId, + roundId: "10", + role: "manager", + address: "0x1234567890123456789012345678901234567890", + createdAtBlock: 116385565n, + }); + expect(result.filter((c) => c.type === "InsertRoundRole")[2].args.roundRole).toMatchObject({ + chainId: 10 as ChainId, + roundId: "10", + role: "manager", + address: "0xAaBBccdDeEFf0000000000000000000000000000", + createdAtBlock: 116385565n, + }); + expect(result.filter((c) => c.type === "DeletePendingRoundRoles")).toHaveLength(1); + expect(result.filter((c) => c.type === "DeletePendingRoundRoles")[0].args.ids).toContain(1); + expect(result.filter((c) => c.type === "DeletePendingRoundRoles")[0].args.ids).toContain(2); + expect(result.filter((c) => c.type === "DeletePendingRoundRoles")[0].args.ids).toContain(3); + }); + + it.skip("handles a native token"); + it.skip("handles an unknown token"); +}); diff --git a/packages/processors/test/helpers/tokenMath.spec.ts b/packages/processors/test/helpers/tokenMath.spec.ts new file mode 100644 index 0000000..5702e52 --- /dev/null +++ b/packages/processors/test/helpers/tokenMath.spec.ts @@ -0,0 +1,98 @@ +import { parseGwei } from "viem"; +import { describe, expect, it, test } from "vitest"; + +import { calculateAmountInUsd } from "../../src/helpers/tokenMath.js"; + +describe("calculateAmountInUsd", () => { + it("calculate USD amount for 18 decimal token with integer price", () => { + const amount = 1000000000000000000n; // 1 token + const tokenPriceInUsd = 100; // $100 per token + const tokenDecimals = 18; + + const result = calculateAmountInUsd(amount, tokenPriceInUsd, tokenDecimals); + expect(result).toBe(100); + }); + + it("calculate USD amount for 18 decimal token with float price", () => { + const amount = 1500000000000000000n; // 1.5 tokens + const tokenPriceInUsd = 27.35; // $27.35 per token + const tokenDecimals = 18; + + const result = calculateAmountInUsd(amount, tokenPriceInUsd, tokenDecimals); + expect(result).toBeCloseTo(41.025, 5); + }); + + it("calculate USD amount for 8 decimal token with integer price", () => { + const amount = 100000000n; // 1 token + const tokenPriceInUsd = 50; // $50 per token + const tokenDecimals = 8; + + const result = calculateAmountInUsd(amount, tokenPriceInUsd, tokenDecimals); + expect(result).toBe(50); + }); + + // Test case for 8 decimal token with float price + it("should correctly calculate USD amount for 8 decimal token with float price", () => { + const amount = 150000000n; // 1.5 tokens + const tokenPriceInUsd = 12.75; // $12.75 per token + const tokenDecimals = 8; + + const result = calculateAmountInUsd(amount, tokenPriceInUsd, tokenDecimals); + expect(result).toBeCloseTo(19.125, 5); + }); + + it("correctly calculate USD amount for 1gwei token amount", () => { + const amount = parseGwei("1"); // 1 gwei in wei + const tokenPriceInUsd = 1000; // $1000 per token + const tokenDecimals = 18; + + const result = calculateAmountInUsd(amount, tokenPriceInUsd, tokenDecimals); + expect(result).toBe(0.000001); + }); + + it("correctly truncate decimals when specified", () => { + const amount = 1234567890123456789n; // 1.234567890123456789 tokens + const tokenPriceInUsd = 1.23; // $1.23 per token + const tokenDecimals = 18; + + const result = calculateAmountInUsd(amount, tokenPriceInUsd, tokenDecimals, 4); + expect(result).toBe(1.5185); + }); + + it("return zero for zero token amount", () => { + const amount = 0n; + const tokenPriceInUsd = 100; + const tokenDecimals = 18; + + const result = calculateAmountInUsd(amount, tokenPriceInUsd, tokenDecimals); + expect(result).toBe(0); + }); + + it("should return zero for zero token price", () => { + const amount = 1000000000000000000n; // 1 token + const tokenPriceInUsd = 0; + const tokenDecimals = 18; + + const result = calculateAmountInUsd(amount, tokenPriceInUsd, tokenDecimals); + expect(result).toBe(0); + }); + + it("throw an error for invalid truncate decimals", () => { + const amount = 1000000000000000000n; // 1 token + const tokenPriceInUsd = 100; + const tokenDecimals = 18; + + expect(() => calculateAmountInUsd(amount, tokenPriceInUsd, tokenDecimals, -1)).toThrow(); + expect(() => calculateAmountInUsd(amount, tokenPriceInUsd, tokenDecimals, 19)).toThrow(); + }); + + test("migrated cases", () => { + expect(calculateAmountInUsd(3400000000000000000n, 1, 18, 8)).toBe(3.4); + + expect(calculateAmountInUsd(50000000000n, 1, 18, 8)).toBe(0.00000005); + + expect(calculateAmountInUsd(3400000000000000000n, 0.5, 18, 8)).toBe(1.7); + + expect(calculateAmountInUsd(3400000000000000000n, 2, 18, 8)).toBe(6.8); + }); +}); diff --git a/packages/processors/test/helpers/utils.spec.ts b/packages/processors/test/helpers/utils.spec.ts new file mode 100644 index 0000000..cdc570f --- /dev/null +++ b/packages/processors/test/helpers/utils.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { getDateFromTimestamp } from "../../src/helpers/utils.ts"; + +describe("utils", () => { + describe("getDateFromTimestamp", () => { + it("should convert a valid timestamp to a Date object", () => { + const timestamp = 1609459200n; // 2021-01-01 00:00:00 UTC + const result = getDateFromTimestamp(timestamp); + expect(result).toBeInstanceOf(Date); + expect(result?.toISOString()).toBe("2021-01-01T00:00:00.000Z"); + }); + + it("should handle the minimum valid timestamp (0)", () => { + const timestamp = 0n; + const result = getDateFromTimestamp(timestamp); + expect(result).toBeInstanceOf(Date); + expect(result?.toISOString()).toBe("1970-01-01T00:00:00.000Z"); + }); + + it("should handle the maximum valid timestamp", () => { + const maxTimestamp = 18446744073709551615n - 1n; + const result = getDateFromTimestamp(maxTimestamp); + expect(result).toBeInstanceOf(Date); + }); + + it("should return null for timestamps equal to or greater than UINT64_MAX", () => { + const maxTimestamp = 18446744073709551615n; + expect(getDateFromTimestamp(maxTimestamp)).toBeNull(); + expect(getDateFromTimestamp(maxTimestamp + 1n)).toBeNull(); + }); + + it("should return null for negative timestamps", () => { + expect(getDateFromTimestamp(-1n)).toBeNull(); + expect(getDateFromTimestamp(-1000000n)).toBeNull(); + }); + }); +}); diff --git a/packages/processors/vitest.config.ts b/packages/processors/vitest.config.ts index 36aeafb..402555e 100644 --- a/packages/processors/vitest.config.ts +++ b/packages/processors/vitest.config.ts @@ -10,7 +10,15 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "json", "html"], - exclude: ["node_modules", "dist", "src/index.ts", ...configDefaults.exclude], + exclude: [ + "node_modules", + "dist", + "src/index.ts", + "src/internal.ts", + "src/external.ts", + "**/abis/**", + ...configDefaults.exclude, + ], }, }, resolve: { diff --git a/packages/shared/src/external.ts b/packages/shared/src/external.ts index 80fefd6..d3be43d 100644 --- a/packages/shared/src/external.ts +++ b/packages/shared/src/external.ts @@ -6,3 +6,6 @@ export { ALLO_NATIVE_TOKEN, isAlloNativeToken, } from "./constants/index.js"; + +export type { DeepPartial } from "./utils/testing.js"; +export { mergeDeep } from "./utils/testing.js"; diff --git a/packages/shared/src/internal.ts b/packages/shared/src/internal.ts index 7dc65a3..e48d62e 100644 --- a/packages/shared/src/internal.ts +++ b/packages/shared/src/internal.ts @@ -1,3 +1,4 @@ export type { Address } from "viem"; export * from "./types/index.js"; export * from "./constants/index.js"; +export * from "./utils/testing.js"; diff --git a/packages/shared/src/utils/testing.ts b/packages/shared/src/utils/testing.ts new file mode 100644 index 0000000..ca9e678 --- /dev/null +++ b/packages/shared/src/utils/testing.ts @@ -0,0 +1,43 @@ +// Define a type for objects +type ObjectType = Record; + +// Helper type to create a deep partial type +export type DeepPartial = T extends ObjectType ? { [P in keyof T]?: DeepPartial } : T; + +/** + * Deeply merges a partial source object into a target object. + * @param target The target object to merge into + * @param source The source object to merge from (can be partial) + * @returns A new object with the merged properties + */ +export function mergeDeep(target: T, source: DeepPartial): T { + const output = { ...target }; + + if (isObject(target) && isObject(source)) { + Object.keys(source).forEach((key) => { + if (isObject(source[key])) { + if (!(key in target)) { + Object.assign(output, { [key]: source[key] }); + } else { + output[key as keyof T] = mergeDeep( + target[key as keyof T] as ObjectType, + source[key] as DeepPartial, + ) as T[keyof T]; + } + } else { + Object.assign(output, { [key]: source[key] }); + } + }); + } + + return output; +} + +/** + * Type guard to check if a value is an object + * @param item The value to check + * @returns True if the value is an object, false otherwise + */ +function isObject(item: unknown): item is ObjectType { + return item !== null && typeof item === "object" && !Array.isArray(item); +}