diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml
new file mode 100644
index 0000000000..7459f0b921
--- /dev/null
+++ b/.github/workflows/common.yml
@@ -0,0 +1,35 @@
+name: Common - Test
+on:
+ push:
+ branches:
+ - main
+ - release
+ pull_request:
+ branches:
+ - "**"
+jobs:
+ lint-test-typecheck:
+ concurrency: ci-round-manager-${{ github.head_ref || github.run_id }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+ with:
+ fetch-depth: 1
+
+ - uses: pnpm/action-setup@v2
+ with:
+ version: 8
+
+ - uses: actions/setup-node@v3
+ with:
+ node-version: "18"
+ cache: "pnpm"
+
+ - name: Install Dependencies
+ run: |
+ pnpm install
+
+ - name: Test Common
+ run: |
+ pnpm c-test
diff --git a/.github/workflows/grant-explorer.yml b/.github/workflows/grant-explorer.yml
index 99be37ff10..c5d5160e72 100644
--- a/.github/workflows/grant-explorer.yml
+++ b/.github/workflows/grant-explorer.yml
@@ -32,12 +32,12 @@ jobs:
- name: Lint Explorer
run: |
- pnpm re-lint
+ pnpm ge-lint
- name: Test Explorer
run: |
- pnpm re-test
+ pnpm ge-test
- name: Typecheck Explorer
run: |
- pnpm re-typecheck
+ pnpm ge-typecheck
diff --git a/.github/workflows/verify-env.yml b/.github/workflows/verify-env.yml
new file mode 100644
index 0000000000..ee0fc26a74
--- /dev/null
+++ b/.github/workflows/verify-env.yml
@@ -0,0 +1,35 @@
+name: Verify-Env - Test
+on:
+ push:
+ branches:
+ - main
+ - release
+ pull_request:
+ branches:
+ - "**"
+jobs:
+ lint-test-typecheck:
+ concurrency: ci-round-manager-${{ github.head_ref || github.run_id }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+ with:
+ fetch-depth: 1
+
+ - uses: pnpm/action-setup@v2
+ with:
+ version: 8
+
+ - uses: actions/setup-node@v3
+ with:
+ node-version: "18"
+ cache: "pnpm"
+
+ - name: Install Dependencies
+ run: |
+ pnpm install
+
+ - name: Test Verify-Env
+ run: |
+ pnpm ve-test
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000000..757fd64caa
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,3 @@
+{
+ "trailingComma": "es5"
+}
diff --git a/package.json b/package.json
index f4a5878b7c..0211e79af7 100644
--- a/package.json
+++ b/package.json
@@ -20,16 +20,18 @@
"rm-lint": "turbo run lint:ci --filter=round-manager",
"rm-typecheck": "turbo run typecheck --filter=round-manager",
"// grant explorer script": "====== packages/grant-explorer specific ======",
- "re-build": "turbo run build --filter=grant-explorer",
- "re-test": "turbo run test --filter=grant-explorer",
- "re-start": "pnpm --filter grant-explorer run start",
- "re-typecheck": "turbo run typecheck --filter=grant-explorer",
- "re-lint": "turbo run lint:ci --filter=grant-explorer",
+ "ge-build": "turbo run build --filter=grant-explorer",
+ "ge-test": "turbo run test --filter=grant-explorer",
+ "ge-start": "pnpm --filter grant-explorer run start",
+ "ge-typecheck": "turbo run typecheck --filter=grant-explorer",
+ "ge-lint": "turbo run lint:ci --filter=grant-explorer",
"// builder script": "====== packages/builder specific ======",
"b-start": "pnpm --filter builder run start",
"b-lint": "turbo run lint:ci --filter=builder",
"b-test": "pnpm test --filter=builder",
- "b-typecheck": "turbo run typecheck --filter=builder"
+ "b-typecheck": "turbo run typecheck --filter=builder",
+ "c-test": "turbo run test --filter=common",
+ "ve-test": "turbo run test --filter=verify-env"
},
"devDependencies": {
"@commitlint/cli": "^17.6.3",
@@ -37,7 +39,7 @@
},
"dependencies": {
"prettier": "^3.0.3",
- "turbo": "^1.10.9"
+ "turbo": "^1.10.15"
},
"pnpm": {
"overrides": {
diff --git a/packages/common/package.json b/packages/common/package.json
index 8b8ff5cbcc..9b1f27a623 100644
--- a/packages/common/package.json
+++ b/packages/common/package.json
@@ -7,6 +7,9 @@
"author": "Josef Vacek",
"license": "MIT",
"private": false,
+ "scripts": {
+ "test": "vitest --run"
+ },
"dependencies": {
"@ethersproject/providers": "^5.7.2",
"@rainbow-me/rainbowkit": "^0.12.16",
@@ -28,6 +31,7 @@
"@types/markdown-it": "^12.2.3",
"@types/node": "^18.15.5",
"@types/react": "^18.0.31",
- "@types/react-dom": "^18.0.11"
+ "@types/react-dom": "^18.0.11",
+ "vitest": "^0.34.6"
}
}
diff --git a/packages/common/src/chains.test.ts b/packages/common/src/chains.test.ts
new file mode 100644
index 0000000000..7e0e94f486
--- /dev/null
+++ b/packages/common/src/chains.test.ts
@@ -0,0 +1,39 @@
+import { test, expect } from "vitest";
+import { parseChainId, ChainId } from "./chains"; // Replace 'your-module' with the actual module path
+
+test("Valid input: number", () => {
+ const input = 137;
+ const result = parseChainId(input);
+ expect(result).toBe(ChainId.POLYGON);
+});
+
+test("Valid input: string (number as string)", () => {
+ const input = "80001";
+ const result = parseChainId(input);
+ expect(result).toBe(ChainId.POLYGON_MUMBAI);
+});
+
+test("Invalid input: string (non-existent enum name)", () => {
+ const input = "NON_EXISTENT_CHAIN";
+ expect(() => parseChainId(input)).toThrow("Invalid chainId " + input);
+});
+
+test("Invalid input: string (non-numeric string)", () => {
+ const input = "invalid";
+ expect(() => parseChainId(input)).toThrow("Invalid chainId " + input);
+});
+
+test("Invalid input: number (non-existent enum value)", () => {
+ const input = 999;
+ expect(() => parseChainId(input)).toThrow("Invalid chainId " + input);
+});
+
+test("Invalid input: null", () => {
+ const input = null;
+ expect(() => parseChainId(input as any)).toThrow("Invalid chainId null");
+});
+
+test("Invalid input: undefined", () => {
+ const input = undefined;
+ expect(() => parseChainId(input as any)).toThrow("Invalid chainId undefined");
+});
diff --git a/packages/common/src/chains.ts b/packages/common/src/chains.ts
index 5d0d2d6d7b..4ea6ea7234 100644
--- a/packages/common/src/chains.ts
+++ b/packages/common/src/chains.ts
@@ -69,3 +69,24 @@ export const pgn: Chain = {
},
},
};
+
+export function parseChainId(input: string | number): ChainId {
+ if (typeof input === "string") {
+ // If the input is a string, try to parse it as a number
+ const parsedInput = parseInt(input, 10);
+ if (!isNaN(parsedInput)) {
+ // If parsing is successful, check if it's a valid enum value
+ if (Object.values(ChainId).includes(parsedInput)) {
+ return parsedInput as ChainId;
+ }
+ }
+ } else if (typeof input === "number") {
+ // If the input is a number, check if it's a valid enum value
+ if (Object.values(ChainId).includes(input)) {
+ return input as ChainId;
+ }
+ }
+
+ // If the input is not a valid enum value, return undefined
+ throw "Invalid chainId " + input;
+}
diff --git a/packages/grant-explorer/src/features/api/types.ts b/packages/grant-explorer/src/features/api/types.ts
index 876d123204..5dd53ed5bf 100644
--- a/packages/grant-explorer/src/features/api/types.ts
+++ b/packages/grant-explorer/src/features/api/types.ts
@@ -212,7 +212,7 @@ export type VotingToken = {
decimal: number;
logo?: string;
default?: boolean;
- redstoneTokenId?: string;
+ redstoneTokenId: string;
permitVersion?: string;
//TODO: remove if the previous default was intended to be used as defaultForVoting
defaultForVoting: boolean;
diff --git a/packages/grant-explorer/src/features/common/MatchingEstimateTooltip.tsx b/packages/grant-explorer/src/features/common/MatchingEstimateTooltip.tsx
new file mode 100644
index 0000000000..4d756ac094
--- /dev/null
+++ b/packages/grant-explorer/src/features/common/MatchingEstimateTooltip.tsx
@@ -0,0 +1,57 @@
+import { InformationCircleIcon } from "@heroicons/react/24/solid";
+import React from "react";
+import { Tooltip } from "@chakra-ui/react";
+
+export function MatchingEstimateTooltip(props: { isEligible: boolean }) {
+ return (
+
+
+ {props.isEligible ? (
+ <>
+ Due to the nature of quadratic funding, this estimated match is
+ subject to change as the round progresses. Your match may start
+ at $0, but can change as the project receives more donations.
+ Read more about how quadratic funding works{" "}
+
+ here
+
+ .
+ >
+ ) : (
+ <>
+ Keep in mind that this is a potential match. By connecting to
+ Gitcoin Passport, you can update your score before or after
+ submitting your donation.{" "}
+
+ Click here
+ {" "}
+ to configure your score.
+ >
+ )}
+
+ }
+ id="matching-estimate-tooltip"
+ className={"max-w-sm bg-gray-500 text-gray-50"}
+ >
+
+
+
+ );
+}
diff --git a/packages/grant-explorer/src/features/common/Passport.tsx b/packages/grant-explorer/src/features/common/Passport.tsx
new file mode 100644
index 0000000000..4de2dffdb6
--- /dev/null
+++ b/packages/grant-explorer/src/features/common/Passport.tsx
@@ -0,0 +1,49 @@
+import { PassportResponse } from "common";
+import { useMemo } from "react";
+
+export type PassportColor = "gray" | "orange" | "yellow" | "green";
+
+export type PassportDisplay = {
+ score: number;
+ color: PassportColor;
+};
+
+export function passportColorTextClass(color: PassportColor): string {
+ switch (color) {
+ case "gray":
+ return "text-gray-400";
+ case "orange":
+ return "text-orange-400";
+ case "yellow":
+ return "text-yellow-400";
+ case "green":
+ return "text-green-400";
+ }
+}
+
+function passportDisplayColorFromScore(score: number | null): PassportColor {
+ if (score === null) {
+ return "gray";
+ } else if (score < 15) {
+ return "orange";
+ } else if (score < 25) {
+ return "yellow";
+ }
+
+ return "green";
+}
+
+export function usePassportScore(score?: PassportResponse) {
+ const passportScore = useMemo(() => {
+ if (score?.evidence?.rawScore === undefined) {
+ return null;
+ }
+
+ return Number(score.evidence.rawScore);
+ }, [score]);
+
+ return {
+ score: passportScore,
+ color: passportDisplayColorFromScore(passportScore),
+ };
+}
diff --git a/packages/grant-explorer/src/features/contributors/__tests__/ViewContributionHistory.test.tsx b/packages/grant-explorer/src/features/contributors/__tests__/ViewContributionHistory.test.tsx
index 113e6926d8..dcf32103cd 100644
--- a/packages/grant-explorer/src/features/contributors/__tests__/ViewContributionHistory.test.tsx
+++ b/packages/grant-explorer/src/features/contributors/__tests__/ViewContributionHistory.test.tsx
@@ -30,6 +30,7 @@ const mockTokens: Record = {
decimal: 18,
defaultForVoting: true,
canVote: true,
+ redstoneTokenId: "DAI",
},
};
@@ -98,9 +99,10 @@ vi.mock("@rainbow-me/rainbowkit", () => ({
}));
vi.mock("react-router-dom", async () => {
- const actual = await vi.importActual(
- "react-router-dom"
- );
+ const actual =
+ await vi.importActual(
+ "react-router-dom",
+ );
return {
...actual,
@@ -136,7 +138,7 @@ describe("", () => {
addressLogo="mockedAddressLogo"
breadCrumbs={breadCrumbs}
/>
-
+ ,
);
expect(screen.getByText("Donation Impact")).toBeInTheDocument();
@@ -144,20 +146,20 @@ describe("", () => {
expect(screen.getByText("Active Rounds")).toBeInTheDocument();
expect(screen.getByText("Past Rounds")).toBeInTheDocument();
expect(
- screen.getByText(mockAddress.slice(0, 6) + "..." + mockAddress.slice(-6))
+ screen.getByText(mockAddress.slice(0, 6) + "..." + mockAddress.slice(-6)),
).toBeInTheDocument();
expect(screen.getByText("Share Profile")).toBeInTheDocument();
for (const contribution of mockContributions) {
for (const chainContribution of contribution.data) {
expect(
- screen.getByText(chainContribution.roundName)
+ screen.getByText(chainContribution.roundName),
).toBeInTheDocument();
expect(
- screen.getByText(chainContribution.projectTitle)
+ screen.getByText(chainContribution.projectTitle),
).toBeInTheDocument();
expect(screen.getAllByText("View transaction").length).toBeGreaterThan(
- 0
+ 0,
);
}
}
@@ -177,14 +179,14 @@ describe("", () => {
addressLogo="mockedAddressLogo"
breadCrumbs={breadCrumbs}
/>
-
+ ,
);
await waitFor(() => {
expect(screen.getByText("Donation History")).toBeInTheDocument();
});
expect(
- screen.getByText(mockAddress.slice(0, 6) + "..." + mockAddress.slice(-6))
+ screen.getByText(mockAddress.slice(0, 6) + "..." + mockAddress.slice(-6)),
).toBeInTheDocument();
expect(screen.getByText("Share Profile")).toBeInTheDocument();
});
diff --git a/packages/grant-explorer/src/features/round/ViewCartPage/ProjectInCart.tsx b/packages/grant-explorer/src/features/round/ViewCartPage/ProjectInCart.tsx
index fe9b276452..1320c89d96 100644
--- a/packages/grant-explorer/src/features/round/ViewCartPage/ProjectInCart.tsx
+++ b/packages/grant-explorer/src/features/round/ViewCartPage/ProjectInCart.tsx
@@ -18,6 +18,7 @@ export function ProjectInCart(
selectedPayoutToken: VotingToken;
payoutTokenPrice: number;
removeProjectFromCart: (grantApplicationId: string) => void;
+ matchingEstimateUSD: number | undefined;
}
) {
const { project, roundRoutePath } = props;
diff --git a/packages/grant-explorer/src/features/round/ViewCartPage/RoundInCart.tsx b/packages/grant-explorer/src/features/round/ViewCartPage/RoundInCart.tsx
index 9e4e714ef5..b36989c465 100644
--- a/packages/grant-explorer/src/features/round/ViewCartPage/RoundInCart.tsx
+++ b/packages/grant-explorer/src/features/round/ViewCartPage/RoundInCart.tsx
@@ -2,6 +2,20 @@ import React from "react";
import { CartProject, VotingToken } from "../../api/types";
import { useRoundById } from "../../../context/RoundContext";
import { ProjectInCart } from "./ProjectInCart";
+import {
+ matchingEstimatesToText,
+ useMatchingEstimates,
+} from "../../../hooks/matchingEstimate";
+import { getAddress, parseUnits, zeroAddress } from "viem";
+import { useAccount } from "wagmi";
+import { useCartStorage } from "../../../store";
+import { Skeleton } from "@chakra-ui/react";
+import { BoltIcon } from "@heroicons/react/24/outline";
+import { usePassport } from "../../api/passport";
+import {
+ passportColorTextClass,
+ usePassportScore,
+} from "../../common/Passport";
export function RoundInCart(
props: React.ComponentProps<"div"> & {
@@ -15,38 +29,113 @@ export function RoundInCart(
String(props.roundCart[0].chainId),
props.roundCart[0].roundId
).round;
+
const minDonationThresholdAmount =
round?.roundMetadata?.quadraticFundingConfig?.minDonationThresholdAmount ??
1;
+
+ const { address } = useAccount();
+ const votingTokenForChain = useCartStorage((state) =>
+ state.getVotingTokenForChain(props.roundCart[0]?.chainId)
+ );
+
+ const {
+ data: matchingEstimates,
+ error: matchingEstimateError,
+ isLoading: matchingEstimateLoading,
+ } = useMatchingEstimates([
+ {
+ roundId: getAddress(round?.id ?? zeroAddress),
+ chainId: props.roundCart[0].chainId,
+ potentialVotes: props.roundCart.map((proj) => ({
+ roundId: getAddress(round?.id ?? zeroAddress),
+ projectId: proj.projectRegistryId,
+ amount: parseUnits(
+ proj.amount ?? "0",
+ votingTokenForChain.decimal ?? 18
+ ),
+ grantAddress: proj.recipient,
+ voter: address ?? zeroAddress,
+ token: votingTokenForChain.address.toLowerCase(),
+ applicationId: proj.grantApplicationId,
+ })),
+ },
+ ]);
+
+ const estimateText = matchingEstimatesToText(matchingEstimates);
+
+ const { passport } = usePassport({
+ address: address ?? "",
+ });
+
+ const passportScore = usePassportScore(passport);
+
return (
-
-
{round?.roundMetadata?.name}
-
({props.roundCart.length})
-
- {minDonationThresholdAmount && (
-
-
- Your donation to each project must be valued at{" "}
- {minDonationThresholdAmount} USD or more to be eligible for
- matching.
-
+
+
+
+
+ {round?.roundMetadata?.name}
+
+
+ ({props.roundCart.length})
+
+
+ {minDonationThresholdAmount && (
+
+
+ Your donation to each project must be valued at{" "}
+ {minDonationThresholdAmount} USD or more to be eligible for
+ matching.
+
+
+ )}
- )}
- {props.roundCart.map((project, key) => (
-
-
+
+
+ {matchingEstimateError === undefined &&
+ matchingEstimates !== undefined && (
+
+
+
+
+ ~$
+ {estimateText}
+
+
+
+ )}
- ))}
+
+ {props.roundCart.map((project, key) => {
+ const matchingEstimateUSD = matchingEstimates
+ ?.flat()
+ .find(
+ (est) =>
+ getAddress(est.recipient ?? zeroAddress) ===
+ getAddress(project.recipient ?? zeroAddress)
+ )?.differenceInUSD;
+ return (
+
+ );
+ })}
);
}
diff --git a/packages/grant-explorer/src/features/round/ViewCartPage/SummaryContainer.tsx b/packages/grant-explorer/src/features/round/ViewCartPage/SummaryContainer.tsx
index 3fe00282ba..1d7dd1414a 100644
--- a/packages/grant-explorer/src/features/round/ViewCartPage/SummaryContainer.tsx
+++ b/packages/grant-explorer/src/features/round/ViewCartPage/SummaryContainer.tsx
@@ -1,4 +1,4 @@
-import { ChainId, PassportState, getTokenPrice } from "common";
+import { ChainId, getTokenPrice, PassportState } from "common";
import { useCartStorage } from "../../../store";
import React, { useEffect, useMemo, useState } from "react";
import { Summary } from "./Summary";
@@ -8,18 +8,31 @@ import { ChainConfirmationModalBody } from "./ChainConfirmationModalBody";
import { ProgressStatus } from "../../api/types";
import { modalDelayMs } from "../../../constants";
import { useNavigate } from "react-router-dom";
-import { useAccount, useWalletClient, usePublicClient } from "wagmi";
+import { useAccount, usePublicClient, useWalletClient } from "wagmi";
import { Button } from "common/src/styles";
import { InformationCircleIcon } from "@heroicons/react/24/solid";
+import { BoltIcon } from "@heroicons/react/24/outline";
+
import { usePassport } from "../../api/passport";
import useSWR from "swr";
-import { round, groupBy, uniqBy } from "lodash-es";
+import { groupBy, uniqBy } from "lodash-es";
import { getRoundById } from "../../api/round";
import MRCProgressModal from "../../common/MRCProgressModal";
import { MRCProgressModalBody } from "./MRCProgressModalBody";
import { useCheckoutStore } from "../../../checkoutStore";
-import { formatUnits, parseUnits } from "viem";
+import { formatUnits, getAddress, parseUnits, zeroAddress } from "viem";
import { useConnectModal } from "@rainbow-me/rainbowkit";
+import {
+ matchingEstimatesToText,
+ useMatchingEstimates,
+} from "../../../hooks/matchingEstimate";
+import { Skeleton } from "@chakra-ui/react";
+import { MatchingEstimateTooltip } from "../../common/MatchingEstimateTooltip";
+import { parseChainId } from "common/src/chains";
+import {
+ passportColorTextClass,
+ usePassportScore,
+} from "../../common/Passport";
export function SummaryContainer() {
const { projects, getVotingTokenForChain, chainToVotingToken } =
@@ -76,7 +89,7 @@ export function SummaryContainer() {
const totalDonationsPerChain = useMemo(() => {
return Object.fromEntries(
Object.entries(projectsByChain).map(([key, value]) => [
- Number(key) as ChainId,
+ parseChainId(key),
value
.map((project) => project.amount)
.reduce(
@@ -84,7 +97,7 @@ export function SummaryContainer() {
acc +
parseUnits(
amount ? amount : "0",
- getVotingTokenForChain(Number(key) as ChainId).decimal
+ getVotingTokenForChain(parseChainId(key)).decimal
),
0n
),
@@ -214,7 +227,7 @@ export function SummaryContainer() {
async function handleSubmitDonation() {
try {
- if (!round || !walletClient) {
+ if (!walletClient) {
return;
}
@@ -236,10 +249,12 @@ export function SummaryContainer() {
}
}
- const { passportState } = usePassport({
+ const { passportState, passport } = usePassport({
address: address ?? "",
});
+ const passportScore = usePassportScore(passport);
+
const [totalDonationAcrossChainsInUSD, setTotalDonationAcrossChainsInUSD] =
useState
();
@@ -249,14 +264,13 @@ export function SummaryContainer() {
return Promise.all(
Object.keys(totalDonationsPerChain).map((chainId) =>
getTokenPrice(
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- getVotingTokenForChain(Number(chainId) as ChainId).redstoneTokenId!
+ getVotingTokenForChain(parseChainId(chainId)).redstoneTokenId
).then((price) => {
return (
Number(
formatUnits(
totalDonationsPerChain[chainId],
- getVotingTokenForChain(Number(chainId) as ChainId).decimal
+ getVotingTokenForChain(parseChainId(chainId)).decimal
)
) * Number(price)
);
@@ -275,6 +289,42 @@ export function SummaryContainer() {
}
}, [totalDonationAcrossChainsInUSDData]);
+ /* Matching estimates are calculated per-round */
+ const matchingEstimateParamsPerRound =
+ rounds?.map((round) => {
+ const projectFromRound = projects.find(
+ (project) => project.roundId === round.id
+ );
+ return {
+ roundId: getAddress(round.id ?? zeroAddress),
+ chainId: projectFromRound?.chainId ?? ChainId.MAINNET,
+ potentialVotes: projects
+ .filter((proj) => proj.roundId === round.id)
+ .map((proj) => ({
+ amount: parseUnits(
+ proj.amount ?? "0",
+ getVotingTokenForChain(parseChainId(proj.chainId)).decimal ?? 18
+ ),
+ grantAddress: proj.recipient,
+ voter: address ?? zeroAddress,
+ token: getVotingTokenForChain(
+ parseChainId(proj.chainId)
+ ).address.toLowerCase(),
+ projectId: proj.projectRegistryId,
+ applicationId: proj.grantApplicationId,
+ roundId: getAddress(round.id ?? zeroAddress),
+ })),
+ };
+ }) ?? [];
+
+ const {
+ data: matchingEstimates,
+ error: matchingEstimateError,
+ isLoading: matchingEstimateLoading,
+ } = useMatchingEstimates(matchingEstimateParamsPerRound);
+
+ const estimateText = matchingEstimatesToText(matchingEstimates);
+
if (projects.length === 0) {
return null;
}
@@ -282,14 +332,40 @@ export function SummaryContainer() {
return (
Summary
+
+ {matchingEstimateError === undefined &&
+ matchingEstimates !== undefined && (
+ <>
+
+
Estimated match
+
= 15
+ }
+ />
+
+
+
+
+
+ ~$
+ {estimateText}
+
+
+
+ >
+ )}
+
{Object.keys(projectsByChain).map((chainId) => (
))}
diff --git a/packages/grant-explorer/src/hooks/matchingEstimate.ts b/packages/grant-explorer/src/hooks/matchingEstimate.ts
new file mode 100644
index 0000000000..9e466c60c3
--- /dev/null
+++ b/packages/grant-explorer/src/hooks/matchingEstimate.ts
@@ -0,0 +1,87 @@
+import useSWR from "swr";
+import { Address, zeroAddress } from "viem";
+import { ChainId } from "common";
+
+/* TODO: Rename some of the types to hungarian-style notation once we have shared types between indexer and frontends */
+export type MatchingEstimateResult = {
+ totalReceived: string;
+ contributionsCount: string;
+ sumOfSqrt: string;
+ capOverflow: string;
+ matchedWithoutCap: string;
+ matched: string;
+ difference: bigint;
+ differenceInUSD: number;
+ roundId: string;
+ chainId: number;
+ recipient: string;
+};
+
+type UseMatchingEstimatesParams = {
+ roundId: Address;
+ chainId: ChainId;
+ potentialVotes: {
+ projectId: string;
+ roundId: string;
+ applicationId: string;
+ token: string;
+ voter: string;
+ grantAddress: string;
+ amount: bigint;
+ }[];
+};
+
+type JSONValue = string | number | boolean | bigint | JSONObject | JSONValue[];
+
+interface JSONObject {
+ [x: string]: JSONValue;
+}
+
+function getMatchingEstimates(
+ params: UseMatchingEstimatesParams
+): Promise {
+ const replacer = (_key: string, value: JSONValue) =>
+ typeof value === "bigint" ? value.toString() : value;
+
+ /* The indexer wants just the application id number, not the whole application */
+ const fixedApplicationId = params.potentialVotes.map((vote) => ({
+ ...vote,
+ applicationId: vote.applicationId.includes("-")
+ ? vote.applicationId.split("-")[1]
+ : vote.applicationId,
+ }));
+
+ return fetch(
+ `${process.env.REACT_APP_ALLO_API_URL}/api/v1/chains/${params.chainId}/rounds/${params.roundId}/estimate`,
+ {
+ headers: {
+ Accept: "application/json",
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({ potentialVotes: fixedApplicationId }, replacer),
+ method: "POST",
+ }
+ ).then((r) => r.json());
+}
+
+/**
+ * Fetches matching estimates for the given rounds, given potential votes, as an array
+ * For a single round, pass in an array with a single element
+ */
+export function useMatchingEstimates(params: UseMatchingEstimatesParams[]) {
+ const shouldFetch = params.every((param) => param.roundId !== zeroAddress);
+ return useSWR(shouldFetch ? params : null, (params) =>
+ Promise.all(params.map((params) => getMatchingEstimates(params)))
+ );
+}
+
+export function matchingEstimatesToText(
+ matchingEstimates?: MatchingEstimateResult[][]
+) {
+ return matchingEstimates
+ ?.flat()
+ .map((est) => est.differenceInUSD ?? 0)
+ .filter((diff) => diff > 0)
+ .reduce((acc, b) => acc + b, 0)
+ .toFixed(2);
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0bf650cf6a..a4652b6dfa 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -15,8 +15,8 @@ importers:
specifier: ^3.0.3
version: 3.0.3
turbo:
- specifier: ^1.10.9
- version: 1.10.14
+ specifier: ^1.10.15
+ version: 1.10.15
devDependencies:
'@commitlint/cli':
specifier: ^17.6.3
@@ -383,6 +383,9 @@ importers:
'@types/react-dom':
specifier: ^18.0.11
version: 18.2.7
+ vitest:
+ specifier: ^0.34.6
+ version: 0.34.6
packages/eslint-config-gitcoin:
dependencies:
@@ -7997,6 +8000,14 @@ packages:
chai: 4.3.8
dev: true
+ /@vitest/expect@0.34.6:
+ resolution: {integrity: sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==}
+ dependencies:
+ '@vitest/spy': 0.34.6
+ '@vitest/utils': 0.34.6
+ chai: 4.3.10
+ dev: true
+
/@vitest/runner@0.34.3:
resolution: {integrity: sha512-lYNq7N3vR57VMKMPLVvmJoiN4bqwzZ1euTW+XXYH5kzr3W/+xQG3b41xJn9ChJ3AhYOSoweu974S1V3qDcFESA==}
dependencies:
@@ -8005,6 +8016,14 @@ packages:
pathe: 1.1.1
dev: true
+ /@vitest/runner@0.34.6:
+ resolution: {integrity: sha512-1CUQgtJSLF47NnhN+F9X2ycxUP0kLHQ/JWvNHbeBfwW8CzEGgeskzNnHDyv1ieKTltuR6sdIHV+nmR6kPxQqzQ==}
+ dependencies:
+ '@vitest/utils': 0.34.6
+ p-limit: 4.0.0
+ pathe: 1.1.1
+ dev: true
+
/@vitest/snapshot@0.34.3:
resolution: {integrity: sha512-QyPaE15DQwbnIBp/yNJ8lbvXTZxS00kRly0kfFgAD5EYmCbYcA+1EEyRalc93M0gosL/xHeg3lKAClIXYpmUiQ==}
dependencies:
@@ -8013,12 +8032,26 @@ packages:
pretty-format: 29.6.3
dev: true
+ /@vitest/snapshot@0.34.6:
+ resolution: {integrity: sha512-B3OZqYn6k4VaN011D+ve+AA4whM4QkcwcrwaKwAbyyvS/NB1hCWjFIBQxAQQSQir9/RtyAAGuq+4RJmbn2dH4w==}
+ dependencies:
+ magic-string: 0.30.3
+ pathe: 1.1.1
+ pretty-format: 29.6.3
+ dev: true
+
/@vitest/spy@0.34.3:
resolution: {integrity: sha512-N1V0RFQ6AI7CPgzBq9kzjRdPIgThC340DGjdKdPSE8r86aUSmeliTUgkTqLSgtEwWWsGfBQ+UetZWhK0BgJmkQ==}
dependencies:
tinyspy: 2.1.1
dev: true
+ /@vitest/spy@0.34.6:
+ resolution: {integrity: sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ==}
+ dependencies:
+ tinyspy: 2.1.1
+ dev: true
+
/@vitest/utils@0.34.3:
resolution: {integrity: sha512-kiSnzLG6m/tiT0XEl4U2H8JDBjFtwVlaE8I3QfGiMFR0QvnRDfYfdP3YvTBWM/6iJDAyaPY6yVQiCTUc7ZzTHA==}
dependencies:
@@ -8027,6 +8060,14 @@ packages:
pretty-format: 29.6.3
dev: true
+ /@vitest/utils@0.34.6:
+ resolution: {integrity: sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A==}
+ dependencies:
+ diff-sequences: 29.6.3
+ loupe: 2.3.6
+ pretty-format: 29.6.3
+ dev: true
+
/@wagmi/chains@0.2.22(typescript@4.9.5):
resolution: {integrity: sha512-TdiOzJT6TO1JrztRNjTA5Quz+UmQlbvWFG8N41u9tta0boHA1JCAzGGvU6KuIcOmJfRJkKOUIt67wlbopCpVHg==}
peerDependencies:
@@ -10454,6 +10495,19 @@ packages:
hasBin: true
dev: false
+ /chai@4.3.10:
+ resolution: {integrity: sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==}
+ engines: {node: '>=4'}
+ dependencies:
+ assertion-error: 1.1.0
+ check-error: 1.0.3
+ deep-eql: 4.1.3
+ get-func-name: 2.0.2
+ loupe: 2.3.6
+ pathval: 1.1.1
+ type-detect: 4.0.8
+ dev: true
+
/chai@4.3.8:
resolution: {integrity: sha512-vX4YvVVtxlfSZ2VecZgFUTU5qPCYsobVI2O9FmwEXBhDigYGQA6jRXCycIs1yJnnWbZ6/+a2zNIF5DfVCcJBFQ==}
engines: {node: '>=4'}
@@ -10514,6 +10568,12 @@ packages:
resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==}
dev: true
+ /check-error@1.0.3:
+ resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==}
+ dependencies:
+ get-func-name: 2.0.2
+ dev: true
+
/check-types@11.2.3:
resolution: {integrity: sha512-+67P1GkJRaxQD6PKK0Et9DhwQB+vGg3PM5+aavopCpZT1lj9jeqfvpgTLAWErNj8qApkkmXlu/Ug74kmhagkXg==}
@@ -13840,6 +13900,10 @@ packages:
resolution: {integrity: sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==}
dev: true
+ /get-func-name@2.0.2:
+ resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==}
+ dev: true
+
/get-intrinsic@1.2.1:
resolution: {integrity: sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==}
dependencies:
@@ -17274,7 +17338,7 @@ packages:
/loupe@2.3.6:
resolution: {integrity: sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==}
dependencies:
- get-func-name: 2.0.0
+ get-func-name: 2.0.2
dev: true
/lower-case@2.0.2:
@@ -22348,64 +22412,64 @@ packages:
safe-buffer: 5.2.1
dev: false
- /turbo-darwin-64@1.10.14:
- resolution: {integrity: sha512-I8RtFk1b9UILAExPdG/XRgGQz95nmXPE7OiGb6ytjtNIR5/UZBS/xVX/7HYpCdmfriKdVwBKhalCoV4oDvAGEg==}
+ /turbo-darwin-64@1.10.15:
+ resolution: {integrity: sha512-Sik5uogjkRTe1XVP9TC2GryEMOJCaKE2pM/O9uLn4koQDnWKGcLQv+mDU+H+9DXvKLnJnKCD18OVRkwK5tdpoA==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
- /turbo-darwin-arm64@1.10.14:
- resolution: {integrity: sha512-KAdUWryJi/XX7OD0alOuOa0aJ5TLyd4DNIYkHPHYcM6/d7YAovYvxRNwmx9iv6Vx6IkzTnLeTiUB8zy69QkG9Q==}
+ /turbo-darwin-arm64@1.10.15:
+ resolution: {integrity: sha512-xwqyFDYUcl2xwXyGPmHkmgnNm4Cy0oNzMpMOBGRr5x64SErS7QQLR4VHb0ubiR+VAb8M+ECPklU6vD1Gm+wekg==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
- /turbo-linux-64@1.10.14:
- resolution: {integrity: sha512-BOBzoREC2u4Vgpap/WDxM6wETVqVMRcM8OZw4hWzqCj2bqbQ6L0wxs1LCLWVrghQf93JBQtIGAdFFLyCSBXjWQ==}
+ /turbo-linux-64@1.10.15:
+ resolution: {integrity: sha512-dM07SiO3RMAJ09Z+uB2LNUSkPp3I1IMF8goH5eLj+d8Kkwoxd/+qbUZOj9RvInyxU/IhlnO9w3PGd3Hp14m/nA==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
- /turbo-linux-arm64@1.10.14:
- resolution: {integrity: sha512-D8T6XxoTdN5D4V5qE2VZG+/lbZX/89BkAEHzXcsSUTRjrwfMepT3d2z8aT6hxv4yu8EDdooZq/2Bn/vjMI32xw==}
+ /turbo-linux-arm64@1.10.15:
+ resolution: {integrity: sha512-MkzKLkKYKyrz4lwfjNXH8aTny5+Hmiu4SFBZbx+5C0vOlyp6fV5jZANDBvLXWiDDL4DSEAuCEK/2cmN6FVH1ow==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: false
optional: true
- /turbo-windows-64@1.10.14:
- resolution: {integrity: sha512-zKNS3c1w4i6432N0cexZ20r/aIhV62g69opUn82FLVs/zk3Ie0GVkSB6h0rqIvMalCp7enIR87LkPSDGz9K4UA==}
+ /turbo-windows-64@1.10.15:
+ resolution: {integrity: sha512-3TdVU+WEH9ThvQGwV3ieX/XHebtYNHv9HARHauPwmVj3kakoALkpGxLclkHFBLdLKkqDvmHmXtcsfs6cXXRHJg==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: false
optional: true
- /turbo-windows-arm64@1.10.14:
- resolution: {integrity: sha512-rkBwrTPTxNSOUF7of8eVvvM+BkfkhA2OvpHM94if8tVsU+khrjglilp8MTVPHlyS9byfemPAmFN90oRIPB05BA==}
+ /turbo-windows-arm64@1.10.15:
+ resolution: {integrity: sha512-l+7UOBCbfadvPMYsX08hyLD+UIoAkg6ojfH+E8aud3gcA1padpjCJTh9gMpm3QdMbKwZteT5uUM+wyi6Rbbyww==}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: false
optional: true
- /turbo@1.10.14:
- resolution: {integrity: sha512-hr9wDNYcsee+vLkCDIm8qTtwhJ6+UAMJc3nIY6+PNgUTtXcQgHxCq8BGoL7gbABvNWv76CNbK5qL4Lp9G3ZYRA==}
+ /turbo@1.10.15:
+ resolution: {integrity: sha512-mKKkqsuDAQy1wCCIjCdG+jOCwUflhckDMSRoeBPcIL/CnCl7c5yRDFe7SyaXloUUkt4tUR0rvNIhVCcT7YeQpg==}
hasBin: true
optionalDependencies:
- turbo-darwin-64: 1.10.14
- turbo-darwin-arm64: 1.10.14
- turbo-linux-64: 1.10.14
- turbo-linux-arm64: 1.10.14
- turbo-windows-64: 1.10.14
- turbo-windows-arm64: 1.10.14
+ turbo-darwin-64: 1.10.15
+ turbo-darwin-arm64: 1.10.15
+ turbo-linux-64: 1.10.15
+ turbo-linux-arm64: 1.10.15
+ turbo-windows-64: 1.10.15
+ turbo-windows-arm64: 1.10.15
dev: false
/tweetnacl-util@0.15.1:
@@ -22919,6 +22983,28 @@ packages:
- terser
dev: true
+ /vite-node@0.34.6(@types/node@18.17.14):
+ resolution: {integrity: sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==}
+ engines: {node: '>=v14.18.0'}
+ hasBin: true
+ dependencies:
+ cac: 6.7.14
+ debug: 4.3.4
+ mlly: 1.4.2
+ pathe: 1.1.1
+ picocolors: 1.0.0
+ vite: 4.4.9(@types/node@18.17.14)
+ transitivePeerDependencies:
+ - '@types/node'
+ - less
+ - lightningcss
+ - sass
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+ dev: true
+
/vite@4.4.9(@types/node@17.0.45):
resolution: {integrity: sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -23068,6 +23154,71 @@ packages:
- terser
dev: true
+ /vitest@0.34.6:
+ resolution: {integrity: sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==}
+ engines: {node: '>=v14.18.0'}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@vitest/browser': '*'
+ '@vitest/ui': '*'
+ happy-dom: '*'
+ jsdom: '*'
+ playwright: '*'
+ safaridriver: '*'
+ webdriverio: '*'
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@vitest/browser':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+ playwright:
+ optional: true
+ safaridriver:
+ optional: true
+ webdriverio:
+ optional: true
+ dependencies:
+ '@types/chai': 4.3.6
+ '@types/chai-subset': 1.3.3
+ '@types/node': 18.17.14
+ '@vitest/expect': 0.34.6
+ '@vitest/runner': 0.34.6
+ '@vitest/snapshot': 0.34.6
+ '@vitest/spy': 0.34.6
+ '@vitest/utils': 0.34.6
+ acorn: 8.10.0
+ acorn-walk: 8.2.0
+ cac: 6.7.14
+ chai: 4.3.10
+ debug: 4.3.4
+ local-pkg: 0.4.3
+ magic-string: 0.30.3
+ pathe: 1.1.1
+ picocolors: 1.0.0
+ std-env: 3.4.3
+ strip-literal: 1.3.0
+ tinybench: 2.5.0
+ tinypool: 0.7.0
+ vite: 4.4.9(@types/node@18.17.14)
+ vite-node: 0.34.6(@types/node@18.17.14)
+ why-is-node-running: 2.2.2
+ transitivePeerDependencies:
+ - less
+ - lightningcss
+ - sass
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+ dev: true
+
/w3c-hr-time@1.0.2:
resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==}
deprecated: Use your platform's native performance.now() and performance.timeOrigin.