diff --git a/app/context/ceramicContext.tsx b/app/context/ceramicContext.tsx index dd6f7e6be2..e3798964cf 100644 --- a/app/context/ceramicContext.tsx +++ b/app/context/ceramicContext.tsx @@ -44,6 +44,7 @@ const { Idena, Civic, CyberConnect, + GrantsStack, } = stampPlatforms; import { PlatformProps } from "../components/GenericPlatform"; @@ -229,6 +230,11 @@ if (process.env.NEXT_PUBLIC_FF_CYBERCONNECT_STAMPS === "on") { }); } +platforms.set("GrantsStack", { + platform: new GrantsStack.GrantsStackPlatform(), + platFormGroupSpec: GrantsStack.ProviderConfig, +}); + export enum IsLoadingPassportState { Idle, Loading, diff --git a/app/public/assets/grantsStackLogo.svg b/app/public/assets/grantsStackLogo.svg new file mode 100644 index 0000000000..c41c9cb318 --- /dev/null +++ b/app/public/assets/grantsStackLogo.svg @@ -0,0 +1,3 @@ + + + diff --git a/platforms/src/GrantsStack/App-Bindings.ts b/platforms/src/GrantsStack/App-Bindings.ts new file mode 100644 index 0000000000..e89004b79a --- /dev/null +++ b/platforms/src/GrantsStack/App-Bindings.ts @@ -0,0 +1,23 @@ +import { AppContext, ProviderPayload } from "../types"; +import { Platform } from "../utils/platform"; + +export class GrantsStackPlatform extends Platform { + platformId = "GrantsStack"; + path = "GrantsStack"; + clientId: string = null; + redirectUri: string = null; + + banner = { + heading: + "Note: Only Alpha and Beta rounds run by Gitcoin are included. For the Alpha program, only donations larger than $1 are counted. For the Beta program, only matching-eligible contributions are counted.", + }; + + async getProviderPayload(appContext: AppContext): Promise { + const result = await Promise.resolve({}); + return result; + } + + getOAuthUrl(state: string): Promise { + throw new Error("Method not implemented."); + } +} diff --git a/platforms/src/GrantsStack/Providers-config.ts b/platforms/src/GrantsStack/Providers-config.ts new file mode 100644 index 0000000000..4876988c98 --- /dev/null +++ b/platforms/src/GrantsStack/Providers-config.ts @@ -0,0 +1,62 @@ +import { PlatformSpec, PlatformGroupSpec, Provider } from "../types"; +import { GrantsStackProvider } from "./Providers/GrantsStack"; + +export const PlatformDetails: PlatformSpec = { + icon: "./assets/grantsStackLogo.svg", + platform: "GrantsStack", + name: "GrantsStack", + description: "Connect your existing GrantsStack Account to verify", + connectMessage: "Connect Account", +}; + +export const ProviderConfig: PlatformGroupSpec[] = [ + { + platformGroup: "Projects Contributed To:", + providers: [ + { title: "Supported 3+ unique projects", name: "GrantsStack3Projects" }, + { title: "Supported 5+ unique projects", name: "GrantsStack5Projects" }, + { title: "Supported 7+ unique projects", name: "GrantsStack7Projects" }, + ], + }, + { + platformGroup: "Matching Fund Programs Participation:", + providers: [ + { title: "Contributed to 2+ unique programs.", name: "GrantsStack2Programs" }, + { title: "Contributed to 4+ unique programs.", name: "GrantsStack4Programs" }, + { title: "Contributed to 6+ unique programs.", name: "GrantsStack6Programs" }, + ], + }, +]; + +export const providers: Provider[] = [ + new GrantsStackProvider({ + type: "GrantsStack3Projects", + dataKey: "projectCount", + threshold: 3, + }), + new GrantsStackProvider({ + type: "GrantsStack5Projects", + dataKey: "projectCount", + threshold: 5, + }), + new GrantsStackProvider({ + type: "GrantsStack7Projects", + dataKey: "projectCount", + threshold: 7, + }), + new GrantsStackProvider({ + type: "GrantsStack2Programs", + dataKey: "programCount", + threshold: 2, + }), + new GrantsStackProvider({ + type: "GrantsStack4Programs", + dataKey: "programCount", + threshold: 4, + }), + new GrantsStackProvider({ + type: "GrantsStack6Programs", + dataKey: "programCount", + threshold: 6, + }), +]; diff --git a/platforms/src/GrantsStack/Providers/GrantsStack.ts b/platforms/src/GrantsStack/Providers/GrantsStack.ts new file mode 100644 index 0000000000..647d158f78 --- /dev/null +++ b/platforms/src/GrantsStack/Providers/GrantsStack.ts @@ -0,0 +1,83 @@ +import type { Provider, ProviderOptions } from "../../types"; +import { ProviderContext, PROVIDER_ID, RequestPayload, VerifiedPayload } from "@gitcoin/passport-types"; +import axios from "axios"; + +export type GrantsStackProviderOptions = ProviderOptions & { + type: PROVIDER_ID; + dataKey: keyof GrantsStackCounts; + threshold: number; +}; + +type GrantsStackCounts = { + projectCount?: number; + programCount?: number; +}; + +export type GrantsStackContext = ProviderContext & { + grantsStack?: GrantsStackCounts; +}; + +type StatisticResponse = { + num_grants_contribute_to: number; + num_rounds_contribute_to: number; + total_valid_contribution_amount: number; + num_gr14_contributions: number; +}; + +export const getGrantsStackData = async ( + payload: RequestPayload, + context: GrantsStackContext +): Promise => { + try { + if (!context?.grantsStack?.projectCount || !context?.grantsStack?.programCount) { + const grantStatisticsRequest: { + data: StatisticResponse; + } = await axios.get(`${process.env.CGRANTS_API_URL}/allo/contributor_statistics`, { + headers: { Authorization: process.env.CGRANTS_API_TOKEN }, + params: { address: payload.address }, + }); + + if (!context.grantsStack) context.grantsStack = {}; + + context.grantsStack.projectCount = grantStatisticsRequest.data.num_grants_contribute_to; + context.grantsStack.programCount = grantStatisticsRequest.data.num_rounds_contribute_to; + + return context.grantsStack; + } + return context.grantsStack; + } catch (e) { + throw new Error("Error getting GrantsStack data"); + } +}; + +export class GrantsStackProvider implements Provider { + type: PROVIDER_ID; + threshold: number; + dataKey: keyof GrantsStackCounts; + + constructor(options: GrantsStackProviderOptions) { + this.type = options.type; + this.threshold = options.threshold; + this.dataKey = options.dataKey; + } + + async verify(payload: RequestPayload, context: ProviderContext): Promise { + try { + const grantsStackData = await getGrantsStackData(payload, context); + const count = grantsStackData[this.dataKey]; + const valid = count >= this.threshold; + const errors = !valid ? [`${this.dataKey}: ${count} is less than ${this.threshold}`] : []; + const contributionStatistic = `${this.type}-${this.threshold}-contribution-statistic`; + return { + valid, + errors, + record: { + address: payload.address, + contributionStatistic, + }, + }; + } catch (e) { + throw new Error("Error verifying GrantsStack data"); + } + } +} diff --git a/platforms/src/GrantsStack/Providers/__tests__/GrantsStack.test.ts b/platforms/src/GrantsStack/Providers/__tests__/GrantsStack.test.ts new file mode 100644 index 0000000000..32f2930ed0 --- /dev/null +++ b/platforms/src/GrantsStack/Providers/__tests__/GrantsStack.test.ts @@ -0,0 +1,69 @@ +import axios from "axios"; +import { GrantsStackProvider, getGrantsStackData } from "../GrantsStack"; +import { RequestPayload, PROVIDER_ID } from "@gitcoin/passport-types"; + +// Mocking axios +jest.mock("axios"); + +// Common setup +const userAddress = "0x123"; +const requestPayload = { address: userAddress } as RequestPayload; + +describe("GrantsStackProvider", () => { + // Testing getGrantsStackData function + describe("getGrantsStackData", () => { + it("should fetch GrantsStack data successfully", async () => { + // Mock the axios response + (axios.get as jest.Mock).mockResolvedValue({ + data: { num_grants_contribute_to: 10, num_rounds_contribute_to: 5 }, + }); + const context = {}; + const result = await getGrantsStackData(requestPayload, context); + expect(result).toEqual({ projectCount: 10, programCount: 5 }); + }); + + it("should throw error when fetching GrantsStack data fails", async () => { + // Mock an axios error + (axios.get as jest.Mock).mockRejectedValue(new Error("Network Error")); + const context = {}; + await expect(() => getGrantsStackData(requestPayload, context)).rejects.toThrow("Error getting GrantsStack data"); + }); + }); + + // Testing GrantsStackProvider class + describe("verify method", () => { + it("should verify GrantsStack data and return valid true if threshold is met", async () => { + const providerId = "GrantsStack5Projects"; + const threshold = 5; + const provider = new GrantsStackProvider({ type: providerId, threshold, dataKey: "projectCount" }); + // Using the previously tested getGrantsStackData, we'll assume it works as expected + const verifiedPayload = await provider.verify(requestPayload, { + grantsStack: { projectCount: 10, programCount: 1 }, + }); + expect(verifiedPayload).toMatchObject({ + valid: true, + record: { + address: userAddress, + contributionStatistic: `${providerId}-${threshold}-contribution-statistic`, + }, + }); + }); + + it("should verify GrantsStack data and return valid false if threshold is not met", async () => { + const providerId = "GrantsStack5Projects"; + const provider = new GrantsStackProvider({ type: providerId, threshold: 15, dataKey: "projectCount" }); + const verifiedPayload = await provider.verify(requestPayload, { + grantsStack: { projectCount: 10, programCount: 1 }, + }); + expect(verifiedPayload).toMatchObject({ valid: false }); + }); + + it("should throw an error if verification fails", async () => { + const providerId = "GrantsStack5Projects"; + // Mock the axios response to throw an error in getGrantsStackData + (axios.get as jest.Mock).mockRejectedValue(new Error("Network Error")); + const provider = new GrantsStackProvider({ type: providerId, threshold: 5, dataKey: "projectCount" }); + await expect(() => provider.verify(requestPayload, {})).rejects.toThrow("Error verifying GrantsStack data"); + }); + }); +}); diff --git a/platforms/src/GrantsStack/index.ts b/platforms/src/GrantsStack/index.ts new file mode 100644 index 0000000000..1ff4029485 --- /dev/null +++ b/platforms/src/GrantsStack/index.ts @@ -0,0 +1,3 @@ +export { GrantsStackProvider } from "./Providers/GrantsStack"; +export { GrantsStackPlatform } from "./App-Bindings"; +export { ProviderConfig, PlatformDetails, providers } from "./Providers-config"; diff --git a/platforms/src/platforms.ts b/platforms/src/platforms.ts index bae489ae5b..213a9bb951 100644 --- a/platforms/src/platforms.ts +++ b/platforms/src/platforms.ts @@ -24,6 +24,7 @@ import * as Holonym from "./Holonym"; import * as Idena from "./Idena"; import * as Civic from "./Civic"; import * as CyberConnect from "./CyberProfile"; +import * as GrantsStack from "./GrantsStack"; import { PlatformSpec, PlatformGroupSpec, Provider } from "./types"; type PlatformConfig = { @@ -60,6 +61,7 @@ const platforms: Record = { Idena, Civic, CyberConnect, + GrantsStack, }; if (process.env.NEXT_PUBLIC_FF_NEW_POAP_STAMPS === "on") { diff --git a/platforms/testSetup.js b/platforms/testSetup.js index 5e3063f90b..755f2ed8ae 100644 --- a/platforms/testSetup.js +++ b/platforms/testSetup.js @@ -1 +1 @@ -process.env.ZKSYNC_ERA_MAINNET_ENDPOINT = "https://zksync-era-api-endpoint.io"; \ No newline at end of file +process.env.ZKSYNC_ERA_MAINNET_ENDPOINT = "https://zksync-era-api-endpoint.io"; diff --git a/types/src/index.d.ts b/types/src/index.d.ts index 67e6ffcf6a..3ce7f4c84e 100644 --- a/types/src/index.d.ts +++ b/types/src/index.d.ts @@ -227,7 +227,8 @@ export type PLATFORM_ID = | "Holonym" | "Idena" | "Civic" - | "CyberConnect"; + | "CyberConnect" + | "GrantsStack"; export type PROVIDER_ID = | "Signer" @@ -322,7 +323,13 @@ export type PROVIDER_ID = | "twitterAccountAgeGte#730" | "twitterTweetDaysGte#30" | "twitterTweetDaysGte#60" - | "twitterTweetDaysGte#120"; + | "twitterTweetDaysGte#120" + | "GrantsStack3Projects" + | "GrantsStack5Projects" + | "GrantsStack7Projects" + | "GrantsStack2Programs" + | "GrantsStack4Programs" + | "GrantsStack6Programs"; export type StampBit = { bit: number;