Skip to content

Commit ee8b218

Browse files
committed
feat: add handler for RF distribution updated
1 parent 1f4de53 commit ee8b218

File tree

7 files changed

+242
-7
lines changed

7 files changed

+242
-7
lines changed

packages/processors/src/processors/strategy/easyRetroFunding/easyRetroFunding.handler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@ import {
2323
UnsupportedEventException,
2424
} from "../../../internal.js";
2525
import {
26-
BaseDistributionUpdatedHandler,
2726
BaseFundsDistributedHandler,
2827
BaseRecipientStatusUpdatedHandler,
2928
BaseStrategyHandler,
3029
} from "../common/index.js";
3130
import {
31+
ERFDistributionUpdatedHandler,
3232
ERFRegisteredHandler,
3333
ERFTimestampsUpdatedHandler,
3434
ERFUpdatedRegistrationHandler,
@@ -86,7 +86,7 @@ export class EasyRetroFundingStrategyHandler extends BaseStrategyHandler {
8686
this.dependencies,
8787
).handle();
8888
case "DistributionUpdated":
89-
return new BaseDistributionUpdatedHandler(
89+
return new ERFDistributionUpdatedHandler(
9090
event as ProcessorEvent<"Strategy", "DistributionUpdated">,
9191
this.chainId,
9292
this.dependencies,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { getAddress } from "viem";
2+
3+
import { Changeset } from "@grants-stack-indexer/repository";
4+
import { ChainId, ProcessorEvent } from "@grants-stack-indexer/shared";
5+
6+
import {
7+
IEventHandler,
8+
MetadataNotFound,
9+
MetadataParsingFailed,
10+
ProcessorDependencies,
11+
} from "../../../../internal.js";
12+
import {
13+
SimpleMatchingDistribution,
14+
SimpleMatchingDistributionSchema,
15+
} from "../../../../schemas/index.js";
16+
17+
type Dependencies = Pick<ProcessorDependencies, "metadataProvider" | "logger">;
18+
19+
/**
20+
* ERFDistributionUpdatedHandler: Processes 'DistributionUpdated' events
21+
*
22+
* - Decodes the updated distribution metadata
23+
* - Creates a changeset to update the round with the new distribution
24+
*
25+
* @dev:
26+
* - Strategy handlers that want to handle the DistributionUpdated event should create an instance of this class corresponding to the event.
27+
*
28+
*/
29+
30+
export class ERFDistributionUpdatedHandler
31+
implements IEventHandler<"Strategy", "DistributionUpdated">
32+
{
33+
constructor(
34+
readonly event: ProcessorEvent<"Strategy", "DistributionUpdated">,
35+
private readonly chainId: ChainId,
36+
private readonly dependencies: Dependencies,
37+
) {}
38+
39+
/* @inheritdoc */
40+
async handle(): Promise<Changeset[]> {
41+
const { logger, metadataProvider } = this.dependencies;
42+
const [_, pointer] = this.event.params.metadata;
43+
44+
const strategyAddress = getAddress(this.event.srcAddress);
45+
const rawDistribution = await metadataProvider.getMetadata<
46+
SimpleMatchingDistribution | undefined
47+
>(pointer);
48+
49+
if (!rawDistribution) {
50+
logger.warn(`No matching distribution found for pointer: ${pointer}`);
51+
52+
throw new MetadataNotFound(`No matching distribution found for pointer: ${pointer}`);
53+
}
54+
55+
const distribution = SimpleMatchingDistributionSchema.safeParse(rawDistribution);
56+
57+
if (!distribution.success) {
58+
logger.warn(`Failed to parse matching distribution: ${distribution.error.message}`);
59+
60+
throw new MetadataParsingFailed(
61+
`Failed to parse matching distribution: ${distribution.error.message}`,
62+
);
63+
}
64+
65+
return [
66+
{
67+
type: "UpdateRoundByStrategyAddress",
68+
args: {
69+
chainId: this.chainId,
70+
strategyAddress,
71+
round: {
72+
readyForPayoutTransaction: this.event.transactionFields.hash,
73+
matchingDistribution: distribution.data,
74+
},
75+
},
76+
},
77+
];
78+
}
79+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from "./registered.handler.js";
22
export * from "./timestampsUpdated.handler.js";
33
export * from "./updatedRegistration.handler.js";
4+
export * from "./distributionUpdated.handler.js";

packages/processors/src/schemas/matchingDistribution.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from "zod";
22

33
export type MatchingDistribution = z.infer<typeof MatchingDistributionSchema>;
4+
export type SimpleMatchingDistribution = z.infer<typeof SimpleMatchingDistributionSchema>;
45

56
const BigIntSchema = z.string().or(
67
z.object({ type: z.literal("BigNumber"), hex: z.string() }).transform((val) => {
@@ -22,3 +23,12 @@ export const MatchingDistributionSchema = z.object({
2223
}),
2324
),
2425
});
26+
27+
export const SimpleMatchingDistributionSchema = z.array(
28+
z.object({
29+
anchorAddress: z.string(),
30+
payoutAddress: z.string(),
31+
amount: BigIntSchema.default("0"),
32+
index: z.coerce.number(),
33+
}),
34+
);
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { getAddress } from "viem";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
import { IMetadataProvider } from "@grants-stack-indexer/metadata";
5+
import { PartialRound } from "@grants-stack-indexer/repository";
6+
import { Bytes32String, ChainId, Logger, ProcessorEvent } from "@grants-stack-indexer/shared";
7+
8+
import { MetadataNotFound, MetadataParsingFailed } from "../../../../src/internal.js";
9+
import { ERFDistributionUpdatedHandler } from "../../../../src/processors/strategy/easyRetroFunding/index.js";
10+
import { createMockEvent } from "../../../mocks/index.js";
11+
12+
describe("ERFDistributionUpdatedHandler", () => {
13+
let handler: ERFDistributionUpdatedHandler;
14+
let mockMetadataProvider: IMetadataProvider;
15+
let mockLogger: Logger;
16+
let mockEvent: ProcessorEvent<"Strategy", "DistributionUpdated">;
17+
const chainId = 10 as ChainId;
18+
const eventName = "DistributionUpdated";
19+
const defaultParams = {
20+
metadata: ["1", "ipfs://QmTestHash"] as [string, string],
21+
merkleRoot: "0xroot" as Bytes32String,
22+
};
23+
const defaultStrategyId = "0x9fa6890423649187b1f0e8bf4265f0305ce99523c3d11aa36b35a54617bb0ec0";
24+
25+
beforeEach(() => {
26+
mockMetadataProvider = {
27+
getMetadata: vi.fn(),
28+
} as unknown as IMetadataProvider;
29+
mockLogger = {
30+
warn: vi.fn(),
31+
error: vi.fn(),
32+
info: vi.fn(),
33+
debug: vi.fn(),
34+
} as unknown as Logger;
35+
});
36+
37+
it("handles a valid distribution update event", async () => {
38+
mockEvent = createMockEvent(eventName, defaultParams, defaultStrategyId);
39+
const mockDistribution = [
40+
{
41+
anchorAddress: "anchorAddress",
42+
payoutAddress: "payoutAddress",
43+
amount: "10",
44+
index: 0,
45+
},
46+
];
47+
48+
vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue(mockDistribution);
49+
50+
handler = new ERFDistributionUpdatedHandler(mockEvent, chainId, {
51+
metadataProvider: mockMetadataProvider,
52+
logger: mockLogger,
53+
});
54+
55+
const result = await handler.handle();
56+
57+
expect(result).toEqual([
58+
{
59+
type: "UpdateRoundByStrategyAddress",
60+
args: {
61+
chainId,
62+
strategyAddress: getAddress(mockEvent.srcAddress),
63+
round: {
64+
readyForPayoutTransaction: mockEvent.transactionFields.hash,
65+
matchingDistribution: mockDistribution,
66+
},
67+
},
68+
},
69+
]);
70+
});
71+
72+
it("throws MetadataNotFound if distribution metadata is not found", async () => {
73+
mockEvent = createMockEvent(eventName, defaultParams, defaultStrategyId);
74+
vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue(undefined);
75+
76+
handler = new ERFDistributionUpdatedHandler(mockEvent, chainId, {
77+
metadataProvider: mockMetadataProvider,
78+
logger: mockLogger,
79+
});
80+
81+
await expect(handler.handle()).rejects.toThrow(MetadataNotFound);
82+
expect(mockLogger.warn).toHaveBeenCalledWith(
83+
expect.stringContaining("No matching distribution found for pointer:"),
84+
);
85+
});
86+
87+
it("throw MatchingDistributionParsingError if distribution format is invalid", async () => {
88+
mockEvent = createMockEvent(eventName, defaultParams, defaultStrategyId);
89+
const invalidDistribution = {
90+
matchingDistribution: [
91+
{
92+
amount: "not_a_number", // Invalid amount format
93+
applicationId: "app1",
94+
recipientAddress: "0x1234567890123456789012345678901234567890",
95+
},
96+
],
97+
};
98+
99+
vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue(invalidDistribution);
100+
101+
handler = new ERFDistributionUpdatedHandler(mockEvent, chainId, {
102+
metadataProvider: mockMetadataProvider,
103+
logger: mockLogger,
104+
});
105+
106+
await expect(handler.handle()).rejects.toThrow(MetadataParsingFailed);
107+
expect(mockLogger.warn).toHaveBeenCalledWith(
108+
expect.stringContaining("Failed to parse matching distribution:"),
109+
);
110+
});
111+
112+
it("handles empty matching distribution array", async () => {
113+
mockEvent = createMockEvent(eventName, defaultParams, defaultStrategyId);
114+
const emptyDistribution = {
115+
matchingDistribution: [],
116+
};
117+
118+
vi.spyOn(mockMetadataProvider, "getMetadata").mockResolvedValue(emptyDistribution);
119+
120+
handler = new ERFDistributionUpdatedHandler(mockEvent, chainId, {
121+
metadataProvider: mockMetadataProvider,
122+
logger: mockLogger,
123+
});
124+
125+
const result = await handler.handle();
126+
expect(result).toHaveLength(1);
127+
128+
const changeset = result[0] as {
129+
type: "UpdateRoundByStrategyAddress";
130+
args: {
131+
round: PartialRound;
132+
};
133+
};
134+
expect(changeset.args.round.matchingDistribution).toEqual([]);
135+
});
136+
});

packages/repository/src/db/connection.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ import {
1515
ApplicationPayout,
1616
Attestation as AttestationTable,
1717
AttestationTxn as AttestationTxnTable,
18+
Distribution,
1819
Donation as DonationTable,
1920
ProcessedEvent as EventRegistryTable,
2021
LegacyProject as LegacyProjectTable,
21-
MatchingDistribution,
2222
Metadata as MetadataCacheTable,
2323
PendingProjectRole as PendingProjectRoleTable,
2424
PendingRoundRole as PendingRoundRoleTable,
@@ -55,9 +55,9 @@ type ApplicationTable = Omit<Application, "statusSnapshots"> & {
5555

5656
type RoundTable = Omit<Round, "matchingDistribution"> & {
5757
matchingDistribution: ColumnType<
58-
MatchingDistribution[] | null,
59-
MatchingDistribution[] | string | null,
60-
MatchingDistribution[] | string | null
58+
Distribution[] | null,
59+
Distribution[] | string | null,
60+
Distribution[] | string | null
6161
>;
6262
};
6363

packages/repository/src/types/round.types.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Address, ChainId } from "@grants-stack-indexer/shared";
22

3+
export type Distribution = SimpleMatchingDistribution | MatchingDistribution;
4+
35
export type MatchingDistribution = {
46
applicationId: string;
57
projectPayoutAddress: string;
@@ -11,6 +13,13 @@ export type MatchingDistribution = {
1113
matchAmountInToken: string;
1214
};
1315

16+
export type SimpleMatchingDistribution = {
17+
anchorAddress: string;
18+
payoutAddress: string;
19+
amount: string;
20+
index: number;
21+
};
22+
1423
export type Round = {
1524
id: Address | string;
1625
chainId: ChainId;
@@ -40,7 +49,7 @@ export type Round = {
4049
strategyId: string;
4150
strategyName: string;
4251
readyForPayoutTransaction: string | null;
43-
matchingDistribution: MatchingDistribution[] | null;
52+
matchingDistribution: Distribution[] | null;
4453
projectId: string;
4554
tags: string[];
4655
timestamp: Date;

0 commit comments

Comments
 (0)