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.