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;