diff --git a/lefthook.yml b/lefthook.yml index ca6ff16476..eb2c8351d5 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -41,5 +41,3 @@ pre-push: run: pnpm turbo run typecheck build: run: pnpm turbo run build - test: - run: pnpm turbo run test --concurrency=50% diff --git a/packages/common/src/allo/backends/allo-v2.ts b/packages/common/src/allo/backends/allo-v2.ts index 10c14dab72..3d2ed6195d 100644 --- a/packages/common/src/allo/backends/allo-v2.ts +++ b/packages/common/src/allo/backends/allo-v2.ts @@ -23,9 +23,21 @@ import { RoundApplicationAnswers, RoundCategory, } from "data-layer"; -import { Abi, Address, Hex, getAddress, zeroAddress } from "viem"; +import { + Abi, + Address, + Hex, + encodeAbiParameters, + getAddress, + parseAbiParameters, + zeroAddress, +} from "viem"; import { AnyJson } from "../.."; -import { UpdateRoundParams, MatchingStatsData } from "../../types"; +import { + UpdateRoundParams, + MatchingStatsData, + DirectAllocation, +} from "../../types"; import { Allo, AlloError, AlloOperation, CreateRoundArguments } from "../allo"; import { Result, @@ -121,7 +133,8 @@ export class AlloV2 implements Allo { sig: PermitSignature; deadline: number; nonce: bigint; - } + }, + directAllocation?: DirectAllocation ) { let tx: Result; const mrcAddress = getChainById(chainId).contracts.multiRoundCheckout; @@ -132,7 +145,22 @@ export class AlloV2 implements Allo { }); const data = Object.values(groupedVotes).flat(); + const amounts = Object.values(groupedAmounts); + if (directAllocation) { + poolIds.push(directAllocation.poolId); + amounts.push(directAllocation?.amount ?? BigInt(0)); + const encoded: `0x${string}` = encodeAbiParameters( + parseAbiParameters("address,uint256,address,uint256"), + [ + directAllocation.recipient, + directAllocation.amount, + directAllocation.tokenAddress, + directAllocation.nonce, + ] + ); + data.push(encoded); + } /* decide which function to use based on whether token is native, permit-compatible or DAI */ if (token.address === zeroAddress || token.address === NATIVE) { tx = await sendTransaction( @@ -141,7 +169,7 @@ export class AlloV2 implements Allo { address: mrcAddress, abi: MRC_ABI, functionName: "allocate", - args: [poolIds, Object.values(groupedAmounts), data], + args: [poolIds, amounts, data], value: nativeTokenAmount, }, this.chainId @@ -157,8 +185,8 @@ export class AlloV2 implements Allo { args: [ data, poolIds, - Object.values(groupedAmounts), - Object.values(groupedAmounts).reduce((acc, b) => acc + b), + amounts, + amounts.reduce((acc, b) => acc + b), token.address as Hex, BigInt(permit.deadline ?? Number.MAX_SAFE_INTEGER), permit.nonce, @@ -179,8 +207,8 @@ export class AlloV2 implements Allo { args: [ data, poolIds, - Object.values(groupedAmounts), - Object.values(groupedAmounts).reduce((acc, b) => acc + b), + amounts, + amounts.reduce((acc, b) => acc + b), token.address as Hex, BigInt(permit.deadline ?? Number.MAX_SAFE_INTEGER), permit.sig.v, diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index ab157fb3a5..f352560820 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -14,6 +14,7 @@ export * from "./markdown"; export * from "./allo/common"; export * from "./allo/application"; export * from "./payoutTokens"; +export * from "./types"; export * from "./services/passport/passportCredentials"; export { PassportVerifierWithExpiration } from "./services/passport/credentialVerifier"; diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index fe7a3afad5..1f879489ee 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -1,6 +1,7 @@ import { Round } from "data-layer"; import { AnyJson } from "."; import { BigNumber } from "ethers"; +import { Hex } from "viem"; export type CreateRoundData = { roundMetadataWithProgramContractAddress: Round["roundMetadata"]; @@ -107,3 +108,12 @@ export type PriceSource = { chainId: number; address: `0x${string}`; }; + +export interface DirectAllocation { + chainId: number; + tokenAddress: Hex; + poolId: string; + amount: bigint; + recipient: Hex; + nonce: bigint; +} diff --git a/packages/grant-explorer/src/checkoutStore.ts b/packages/grant-explorer/src/checkoutStore.ts index 1b3116e04f..299187e259 100644 --- a/packages/grant-explorer/src/checkoutStore.ts +++ b/packages/grant-explorer/src/checkoutStore.ts @@ -4,6 +4,7 @@ import { devtools } from "zustand/middleware"; import { CartProject, ProgressStatus } from "./features/api/types"; import { AlloV2, + DirectAllocation, createEthersTransactionSender, createPinataIpfsUploader, createWaitForIndexerSyncTo, @@ -32,13 +33,13 @@ import { groupBy, uniq } from "lodash-es"; import { getEnabledChains } from "./app/chainConfig"; import { getPermitType } from "common/dist/allo/voting"; import { getConfig } from "common/src/config"; -import { DataLayer } from "data-layer"; import { getEthersProvider, getEthersSigner } from "./app/wagmi"; import { Connector } from "wagmi"; type ChainMap = Record; const isV2 = getConfig().allo.version === "allo-v2"; + interface CheckoutState { permitStatus: ChainMap; setPermitStatusForChain: ( @@ -62,7 +63,7 @@ interface CheckoutState { chainsToCheckout: { chainId: number; permitDeadline: number }[], walletClient: WalletClient, connector: Connector, - dataLayer: DataLayer + directAllocation?: DirectAllocation ) => Promise; getCheckedOutProjects: () => CartProject[]; checkedOutProjects: CartProject[]; @@ -109,10 +110,12 @@ export const useCheckoutStore = create()( checkout: async ( chainsToCheckout: { chainId: number; permitDeadline: number }[], walletClient: WalletClient, - connector: Connector + connector: Connector, + directAllocation?: DirectAllocation ) => { const userAddress = walletClient.account?.address; const chainIdsToCheckOut = chainsToCheckout.map((chain) => chain.chainId); + const hasDirectAllocation = !!directAllocation; get().setChainsToCheckout( uniq([...get().chainsToCheckout, ...chainIdsToCheckOut]) ); @@ -151,6 +154,8 @@ export const useCheckoutStore = create()( const chainId = currentChain.chainId; const deadline = currentChain.permitDeadline; const donations = projectsByChain[chainId]; + const isDirectAllocation = + hasDirectAllocation && directAllocation.chainId === chainId; set({ currentChainBeingCheckedOut: chainId, @@ -205,7 +210,9 @@ export const useCheckoutStore = create()( tokenName = "cUSD"; sig = await signPermit2612({ walletClient: walletClient, - value: totalDonationPerChain[chainId], + value: isDirectAllocation + ? totalDonationPerChain[chainId] + directAllocation.amount + : totalDonationPerChain[chainId], spenderAddress: chain.contracts.multiRoundCheckout, nonce, chainId, @@ -302,7 +309,8 @@ export const useCheckoutStore = create()( deadline, nonce: nonce!, } - : undefined + : undefined, + isDirectAllocation ? directAllocation : undefined ); if (receipt.status === "reverted") { diff --git a/packages/grant-explorer/src/features/common/ConfirmationModal.tsx b/packages/grant-explorer/src/features/common/ConfirmationModal.tsx index ef4054de3e..099969c00a 100644 --- a/packages/grant-explorer/src/features/common/ConfirmationModal.tsx +++ b/packages/grant-explorer/src/features/common/ConfirmationModal.tsx @@ -13,6 +13,7 @@ interface ModalProps { children?: ReactNode; modalStyle?: "wide" | "normal"; disabled?: boolean; + totalDonationAcrossChainsInUSD?: number; } export default function ConfirmationModal({ diff --git a/packages/grant-explorer/src/features/round/DonateToGitcoin/DonateToGitcoin.tsx b/packages/grant-explorer/src/features/round/DonateToGitcoin/DonateToGitcoin.tsx new file mode 100644 index 0000000000..c53cda4231 --- /dev/null +++ b/packages/grant-explorer/src/features/round/DonateToGitcoin/DonateToGitcoin.tsx @@ -0,0 +1,64 @@ +import { useCallback } from "react"; +import { Checkbox } from "@chakra-ui/react"; +import { useDonateToGitcoin } from "../DonateToGitcoinContext"; +import React from "react"; +import { DonateToGitcoinContent } from "./components/DonateToGitcoinContent"; + +export type DonationDetails = { + chainId: number; + tokenAddress: string; + amount: string; +}; + +type DonateToGitcoinProps = { + totalAmount: string; + totalDonationsByChain: { + [chainId: number]: number; + }; +}; + +export const DonateToGitcoin = React.memo( + ({ totalAmount, totalDonationsByChain }: DonateToGitcoinProps) => { + const { isEnabled, setIsEnabled } = useDonateToGitcoin(); + + const handleCheckboxChange = useCallback( + (value: React.ChangeEvent) => { + setIsEnabled(value.target.checked); + }, + [setIsEnabled] + ); + + return ( +
+
+

+ + Gitcoin + + Donate to Gitcoin + +

+
+ + +
+ ); + }, + (prevProps, nextProps) => prevProps.totalAmount === nextProps.totalAmount +); diff --git a/packages/grant-explorer/src/features/round/DonateToGitcoin/components/DonateToGitcoinContent.tsx b/packages/grant-explorer/src/features/round/DonateToGitcoin/components/DonateToGitcoinContent.tsx new file mode 100644 index 0000000000..033cb02223 --- /dev/null +++ b/packages/grant-explorer/src/features/round/DonateToGitcoin/components/DonateToGitcoinContent.tsx @@ -0,0 +1,301 @@ +import { + getChainById, + getTokenPrice, + stringToBlobUrl, + TChain, + TToken, +} from "common"; +import { useState, useRef, useEffect, useMemo } from "react"; + +import { + useDonateToGitcoin, + GITCOIN_RECIPIENT_CONFIG, +} from "../../DonateToGitcoinContext"; +import { DonationInput } from "./DonationInput"; + +import React from "react"; +import { ChevronDownIcon } from "@heroicons/react/20/solid"; +import { useCartStorage } from "../../../../store"; +import { parseUnits } from "viem"; + +type DonateToGitcoinContentProps = { + totalAmount: string; + totalDonationsByChain: { + [chainId: number]: number; + }; +}; + +export const DonateToGitcoinContent = React.memo( + ({ totalAmount, totalDonationsByChain }: DonateToGitcoinContentProps) => { + const { + isEnabled, + selectedChainId, + amount, + selectedChain, + chains, + tokenBalances, + tokenFilters, + setSelectedChainId, + setSelectedToken, + setAmountInWei, + } = useDonateToGitcoin(); + + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const [isInitialized, setIsInitialized] = useState(false); + + const { votingTokens, tokenAmountInfo } = useMemo(() => { + if (!tokenFilters) { + return { votingTokens: {}, tokenAmountInfo: null }; + } + + const votingTokens = tokenFilters.reduce( + (acc, { chainId }) => { + const votingToken = useCartStorage + .getState() + .getVotingTokenForChain(chainId); + const token = getChainById(chainId)?.tokens.find( + (t) => t.address.toLowerCase() === votingToken.address.toLowerCase() + ); + + return { + ...acc, + [chainId]: { + address: votingToken.address, + token, + }, + }; + }, + {} as { + [chainId: number]: { address: string; token: TToken | undefined }; + } + ); + + const tokenAmountInfo = selectedChainId + ? { + token: votingTokens[selectedChainId].token, + } + : null; + + return { votingTokens, tokenAmountInfo }; + }, [tokenFilters, selectedChainId]); + + const [tokenAmount, setTokenAmount] = useState(0); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // Filter chains to only show Gitcoin supported ones + const supportedChains = useMemo( + () => chains.filter((chain) => chain.id in GITCOIN_RECIPIENT_CONFIG), + [chains] + ); + + // Handle initial setup + useEffect(() => { + if (supportedChains.length === 1 && !selectedChainId) { + setSelectedChainId(supportedChains[0].id); + } + setIsInitialized(true); + }, [supportedChains, selectedChainId, setSelectedChainId]); + + useEffect(() => { + let isMounted = true; + + const updateTokenAmount = async () => { + try { + setIsLoading(true); + + if (!selectedChainId || !amount) { + setAmountInWei(0n); + setTokenAmount(0); + setError( + !selectedChainId + ? "Please select a chain" + : !amount + ? "Please enter an amount" + : amount === "0" + ? "Amount must be greater than 0" + : null + ); + return; + } + + const votingToken = votingTokens[selectedChainId]?.token; + if (!votingToken) { + setError("Selected token not found"); + setTokenAmount(0); + setAmountInWei(0n); + return; + } + + setSelectedToken(votingToken.address); + + const price = await getTokenPrice( + votingToken.redstoneTokenId, + votingToken.priceSource + ); + + if (!isMounted) return; + + if (!price || price <= 0) { + setError("Unable to fetch token price"); + setTokenAmount(0); + setAmountInWei(0n); + return; + } + + const calculatedAmount = + Number(amount) === 0 ? 0 : Number(amount) / Number(price); + const balance = Number( + tokenBalances[selectedChainId]?.[votingToken.address]?.toFixed(5) || + 0 + ); + const existingDonations = totalDonationsByChain[selectedChainId] || 0; + const totalRequiredAmount = calculatedAmount + existingDonations; + + if (calculatedAmount === 0) { + setError("Amount must be greater than 0"); + setTokenAmount(0); + setAmountInWei(0n); + return; + } + + if (totalRequiredAmount > balance) { + setError(`Insufficient balance for total donations on this chain`); + setTokenAmount(calculatedAmount); + setAmountInWei(0n); + return; + } + + if (isMounted) { + setError(null); + setTokenAmount(calculatedAmount); + setAmountInWei( + parseUnits(String(calculatedAmount), votingToken.decimals) + ); + } + } catch (err) { + console.error("Error in token amount calculation:", err); + if (isMounted) { + setError("Error calculating token amount"); + setTokenAmount(0); + setAmountInWei(0n); + } + } finally { + if (isMounted) { + setIsLoading(false); + } + } + }; + + updateTokenAmount(); + + return () => { + isMounted = false; + }; + }, [ + selectedChainId, + amount, + votingTokens, + setSelectedToken, + setAmountInWei, + tokenBalances, + totalDonationsByChain, + ]); + + if (!isEnabled || !isInitialized) return null; + + const renderChainOption = (chain: TChain) => { + const votingInfo = votingTokens[chain.id]; + const balance = + tokenBalances[chain.id]?.[votingInfo?.address || ""]?.toFixed(5); + + return ( +
+
+ {chain.prettyName} + {chain.prettyName} +
+ + Balance: {balance} {votingInfo?.token?.code} + +
+ ); + }; + + return ( +
+ + +
+ + Add to listed transaction + + +
+ {supportedChains.length === 1 ? ( +
+ {renderChainOption(supportedChains[0])} +
+ ) : ( + <> +
setIsOpen(!isOpen)} + className="w-full p-[9px] cursor-pointer rounded-[6px] border-[0.75px] border-[#D7D7D7] bg-white font-modern-era font-medium" + > + {selectedChain ? ( +
+ {renderChainOption(selectedChain)} + +
+ ) : ( + Select chain + )} +
+ + {isOpen && ( +
+ {supportedChains + .sort((a, b) => a.prettyName.localeCompare(b.prettyName)) + .map((chain) => ( +
{ + setSelectedChainId(chain.id); + setIsOpen(false); + }} + className="p-[9px] hover:bg-gray-50 cursor-pointer" + > + {renderChainOption(chain)} +
+ ))} +
+ )} + + )} +
+ + {selectedChain && tokenAmountInfo && ( +
+ Donation total + + {isLoading + ? "Calculating..." + : `${tokenAmount.toFixed(5)} ${tokenAmountInfo.token?.code}`} + +
+ )} +
+ + {isInitialized && error && ( +

{error}

+ )} +
+ ); + } +); diff --git a/packages/grant-explorer/src/features/round/DonateToGitcoin/components/DonationInput.tsx b/packages/grant-explorer/src/features/round/DonateToGitcoin/components/DonationInput.tsx new file mode 100644 index 0000000000..08fbd81283 --- /dev/null +++ b/packages/grant-explorer/src/features/round/DonateToGitcoin/components/DonationInput.tsx @@ -0,0 +1,94 @@ +import { useDonateToGitcoin } from "../../DonateToGitcoinContext"; +import { useState, useEffect } from "react"; + +type Props = { + totalAmount: string; +}; + +function formatAmount(num: number): string { + return (Math.floor(num * 100) / 100).toFixed(2); +} + +export function DonationInput({ totalAmount }: Props) { + const { amount, setAmount, selectedTokenBalance, isEnabled } = + useDonateToGitcoin(); + + const [selectedPercentage, setSelectedPercentage] = useState(0); + + const percentages = [10, 15, 20]; + + useEffect(() => { + if (isEnabled) { + const calculatedAmount = Number(totalAmount) * (10 / 100); + setAmount(formatAmount(calculatedAmount)); + setSelectedPercentage(10); + } else { + setAmount("0.00"); + } + }, [isEnabled, totalAmount, setAmount]); + + const handlePercentageClick = (percentage: number) => { + const calculatedAmount = Number(totalAmount) * (percentage / 100); + setAmount(formatAmount(calculatedAmount)); + setSelectedPercentage(percentage); + }; + + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === "" || /^\d*\.?\d*$/.test(value)) { + setAmount(value); + // Check if the new amount matches any percentage + const newPercentage = percentages.find( + (p) => formatAmount(Number(totalAmount) * (p / 100)) === value + ); + setSelectedPercentage(newPercentage || 0); + } + }; + + return ( +
+ {percentages.map((percentage) => ( + + ))} +
+
+ $ +
+ +
+
+ ); +} diff --git a/packages/grant-explorer/src/features/round/DonateToGitcoin/components/TotalAmountInclGitcoinDonation.tsx b/packages/grant-explorer/src/features/round/DonateToGitcoin/components/TotalAmountInclGitcoinDonation.tsx new file mode 100644 index 0000000000..97e7355113 --- /dev/null +++ b/packages/grant-explorer/src/features/round/DonateToGitcoin/components/TotalAmountInclGitcoinDonation.tsx @@ -0,0 +1,18 @@ +import { useDonateToGitcoin } from "../../DonateToGitcoinContext"; + +export function TotalAmountInclGitcoinDonation({ + totalDonationAcrossChainsInUSD, +}: { + totalDonationAcrossChainsInUSD: number; +}) { + const { amount } = useDonateToGitcoin(); + const totalAmount = totalDonationAcrossChainsInUSD + Number(amount); + + return ( +
+ + ~${totalAmount.toFixed(2)} + +
+ ); +} diff --git a/packages/grant-explorer/src/features/round/DonateToGitcoin/index.ts b/packages/grant-explorer/src/features/round/DonateToGitcoin/index.ts new file mode 100644 index 0000000000..55dfea3cfd --- /dev/null +++ b/packages/grant-explorer/src/features/round/DonateToGitcoin/index.ts @@ -0,0 +1 @@ +export { DonateToGitcoin } from "./DonateToGitcoin"; diff --git a/packages/grant-explorer/src/features/round/DonateToGitcoinContext.tsx b/packages/grant-explorer/src/features/round/DonateToGitcoinContext.tsx new file mode 100644 index 0000000000..f673e79c3a --- /dev/null +++ b/packages/grant-explorer/src/features/round/DonateToGitcoinContext.tsx @@ -0,0 +1,272 @@ +import React, { + createContext, + useContext, + useState, + useMemo, + useEffect, +} from "react"; +import { getBalance } from "@wagmi/core"; +import { config } from "../../app/wagmi"; +import { NATIVE, getChains, TChain, TToken, getChainById } from "common"; +import { useAccount } from "wagmi"; +import { Hex, zeroAddress } from "viem"; + +type TokenFilter = { + chainId: number; + addresses: string[]; +}; + +export type DonationDetails = { + chainId: number; + tokenAddress: string; + amount: string; +}; + +type DonateToGitcoinContextType = { + isEnabled: boolean; + selectedChainId: number | null; + selectedToken: string; + amount: string; + directAllocationPoolId: number | null; + tokenBalances: { + [chainId: number]: { + [address: string]: number; + }; + }; + selectedTokenBalance: number; + tokenFilters?: TokenFilter[]; + chains: TChain[]; + selectedChain: TChain | null; + filteredTokens?: TToken[]; + tokenDetails: TToken | undefined; + amountInWei: bigint; + setIsEnabled: (enabled: boolean) => void; + setSelectedChainId: (chainId: number | null) => void; + setSelectedToken: (token: string) => void; + setAmount: (amount: string) => void; + setTokenFilters: (filters: TokenFilter[]) => void; + setAmountInWei: (amount: bigint) => void; +}; + +export const GITCOIN_RECIPIENT_CONFIG: { + [chainId: number]: { + nonce: bigint; + recipient: Hex; + }; +} = { + 42220: { + // Celo + nonce: 10000n, + recipient: "0x6a02e9bdAd1C5B8cBbC3B200F0aaE67496FFd4d4", + }, + 42161: { + // Arbitrum One + nonce: 10000n, + recipient: "0x6a02e9bdAd1C5B8cBbC3B200F0aaE67496FFd4d4", + }, + 10: { + // Optimism + nonce: 10000n, + recipient: "0x6a02e9bdAd1C5B8cBbC3B200F0aaE67496FFd4d4", + }, + 8453: { + // Base + nonce: 10000n, + recipient: "0x6a02e9bdAd1C5B8cBbC3B200F0aaE67496FFd4d4", + }, +}; + +export const getGitcoinRecipientData = ( + chainId: number +): { + nonce: bigint; + recipient: Hex; +} => { + const config = GITCOIN_RECIPIENT_CONFIG[chainId]; + if (!config) { + throw new Error(`Unsupported chainId: ${chainId}`); + } + return config; +}; + +const DonateToGitcoinContext = createContext( + null +); + +export function DonateToGitcoinProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [isEnabled, setIsEnabled] = useState(false); + const [selectedChainId, setSelectedChainId] = useState(null); + const [selectedToken, setSelectedToken] = useState(""); + const [amount, setAmount] = useState("0.00"); + const [directAllocationPoolId, setDirectAllocationPoolId] = useState< + number | null + >(null); + const [tokenBalances, setTokenBalances] = useState<{ + [chainId: number]: { + [address: Hex]: number; + }; + }>({}); + const { address } = useAccount(); + + const [tokenFilters, setTokenFilters] = useState( + undefined + ); + + const [amountInWei, setAmountInWei] = useState(0n); + + const chains = useMemo(() => { + const allChains = getChains().filter((c) => c.type === "mainnet"); + if (!tokenFilters) return allChains; + return allChains.filter((chain) => + tokenFilters.some((filter) => filter.chainId === chain.id) + ); + }, [tokenFilters]); + + const selectedChain = selectedChainId + ? chains.find((c) => c.id === selectedChainId) || null + : null; + + const selectedTokenBalance = useMemo( + () => + selectedChainId && selectedToken + ? tokenBalances[selectedChainId]?.[selectedToken as Hex] || 0 + : 0, + [tokenBalances, selectedChainId, selectedToken] + ); + + useEffect(() => { + if (!address || !tokenFilters) return; + + const fetchBalances = async () => { + const balancesMap: { [chainId: number]: { [address: string]: number } } = + {}; + + // Process each chain's token filters + await Promise.all( + tokenFilters.map(async ({ chainId, addresses }) => { + const chain = getChainById(chainId); + if (!chain) return; + + balancesMap[chainId] = {}; + + // Fetch balances for filtered tokens + const tokenBalances = await Promise.all( + addresses.map(async (tokenAddress) => { + const token = chain.tokens.find( + (t) => t.address.toLowerCase() === tokenAddress.toLowerCase() + ); + if (!token) return null; + + const { value } = await getBalance(config, { + address, + token: + tokenAddress.toLowerCase() === NATIVE.toLowerCase() || + tokenAddress.toLowerCase() === zeroAddress.toLowerCase() + ? undefined + : (tokenAddress.toLowerCase() as Hex), + chainId, + }); + + return { + address: + tokenAddress.toLowerCase() === NATIVE.toLowerCase() || + tokenAddress.toLowerCase() === zeroAddress.toLowerCase() + ? zeroAddress + : tokenAddress, + balance: Number(value) / 10 ** (token.decimals || 18), + }; + }) + ); + + // Add valid balances to the map + tokenBalances.forEach((result) => { + if (result) { + balancesMap[chainId][result.address] = result.balance; + } + }); + }) + ); + + setTokenBalances(balancesMap); + }; + + fetchBalances(); + }, [address, tokenFilters]); + + useEffect(() => { + if (!isEnabled) { + setSelectedChainId(null); + setSelectedToken(""); + setAmount(""); + } + }, [isEnabled, setSelectedChainId, setSelectedToken, setAmount]); + + useEffect(() => { + if (!selectedChainId) return; + const fetchDirectAllocationPoolId = async () => { + const poolId = + getChainById(selectedChainId).contracts.directAllocationPoolId; + setDirectAllocationPoolId(poolId ?? null); + }; + fetchDirectAllocationPoolId(); + }, [selectedChainId]); + + const tokenDetails = selectedChain?.tokens.find( + (t) => t.address === selectedToken + ); + + const filteredTokens = useMemo(() => { + if (!selectedChain || !tokenFilters) return selectedChain?.tokens; + const chainFilter = tokenFilters.find( + (f) => f.chainId === selectedChain.id + ); + if (!chainFilter) return selectedChain.tokens; + return selectedChain.tokens.filter((token) => + chainFilter.addresses + .map((addr) => addr.toLowerCase()) + .includes(token.address.toLowerCase()) + ); + }, [selectedChain, tokenFilters]); + + const value = { + isEnabled, + selectedChainId, + selectedToken, + amount, + directAllocationPoolId, + tokenBalances, + selectedTokenBalance, + chains, + selectedChain, + tokenFilters, + filteredTokens, + tokenDetails, + amountInWei, + setTokenFilters, + setIsEnabled, + setSelectedChainId, + setSelectedToken, + setAmount, + setAmountInWei, + }; + + return ( + + {children} + + ); +} + +export function useDonateToGitcoin() { + const context = useContext(DonateToGitcoinContext); + if (!context) { + throw new Error( + "useDonateToGitcoin must be used within a DonateToGitcoinProvider" + ); + } + return context; +} diff --git a/packages/grant-explorer/src/features/round/ViewCartPage/ChainConfirmationModalBody.tsx b/packages/grant-explorer/src/features/round/ViewCartPage/ChainConfirmationModalBody.tsx index 92f64058ab..e66c8301d1 100644 --- a/packages/grant-explorer/src/features/round/ViewCartPage/ChainConfirmationModalBody.tsx +++ b/packages/grant-explorer/src/features/round/ViewCartPage/ChainConfirmationModalBody.tsx @@ -1,9 +1,16 @@ -import React from "react"; +import React, { useEffect, useMemo } from "react"; import { CartProject } from "../../api/types"; -import { TToken, getChainById, stringToBlobUrl } from "common"; +import { NATIVE, TToken, getChainById, stringToBlobUrl } from "common"; import { useCartStorage } from "../../../store"; import { parseChainId } from "common/src/chains"; import { Checkbox } from "@chakra-ui/react"; +import { DonateToGitcoin } from "../DonateToGitcoin"; +import { zeroAddress } from "viem"; +import { + useDonateToGitcoin, + GITCOIN_RECIPIENT_CONFIG, +} from "../DonateToGitcoinContext"; +import { TotalAmountInclGitcoinDonation } from "../DonateToGitcoin/components/TotalAmountInclGitcoinDonation"; type ChainConfirmationModalBodyProps = { projectsByChain: { [chain: number]: CartProject[] }; @@ -12,6 +19,7 @@ type ChainConfirmationModalBodyProps = { enoughBalanceByChainId: Record; setChainIdsBeingCheckedOut: React.Dispatch>; handleSwap: (chainId: number) => void; + totalDonationAcrossChainsInUSD: number; }; export function ChainConfirmationModalBody({ @@ -21,7 +29,9 @@ export function ChainConfirmationModalBody({ enoughBalanceByChainId, setChainIdsBeingCheckedOut, handleSwap, + totalDonationAcrossChainsInUSD, }: ChainConfirmationModalBodyProps) { + const { setTokenFilters } = useDonateToGitcoin(); const handleChainCheckboxChange = (chainId: number, checked: boolean) => { if (checked) { setChainIdsBeingCheckedOut((prevChainIds) => @@ -40,40 +50,113 @@ export function ChainConfirmationModalBody({ (state) => state.getVotingTokenForChain ); + const parsedChainIds = useMemo( + () => Object.keys(projectsByChain).map(parseChainId), + [projectsByChain] + ); + + const tokenFilters = useMemo( + () => + parsedChainIds.map((chainId) => ({ + chainId, + addresses: [ + getVotingTokenForChain(chainId).address === zeroAddress + ? NATIVE + : getVotingTokenForChain(chainId).address, + ], + })), + [parsedChainIds, getVotingTokenForChain] + ); + + useEffect(() => { + setTokenFilters(tokenFilters); + }, [tokenFilters, setTokenFilters]); + + const hasGitcoinSupportedChain = chainIdsBeingCheckedOut.some( + (chainId) => chainId in GITCOIN_RECIPIENT_CONFIG + ); + return ( - <> +

- {chainIdsBeingCheckedOut.length > 1 && ( + {chainIdsBeingCheckedOut.length > 1 && ( <> Checkout all your carts across different networks or select the cart you wish to checkout now. )}

-
- {Object.keys(projectsByChain) - .map(parseChainId) - .filter((chainId) => chainIdsBeingCheckedOut.includes(chainId)) - .map((chainId, index) => ( - - handleChainCheckboxChange(chainId, checked) - } - isLastItem={index === Object.keys(projectsByChain).length - 1} - notEnoughBalance={!enoughBalanceByChainId[chainId]} - handleSwap={() => handleSwap(chainId)} +
+
+ + Networks + + {parsedChainIds + .filter((chainId) => chainIdsBeingCheckedOut.includes(chainId)) + .map((chainId, index) => ( + + handleChainCheckboxChange(chainId, checked) + } + // isLastItem={index === Object.keys(projectsByChain).length - 1} + isLastItem={true} + notEnoughBalance={!enoughBalanceByChainId[chainId]} + handleSwap={() => handleSwap(chainId)} + /> + ))} +
+
+ {hasGitcoinSupportedChain && ( +
+ + Subtotal + + + ~${totalDonationAcrossChainsInUSD.toFixed(2)} + +
+ )} + {hasGitcoinSupportedChain ? ( + - ))} + ) : ( +
+ + Total + + +
+ )} + {hasGitcoinSupportedChain && ( +
+
+ + Total + + +
+
+ )} +
- +
); } @@ -104,16 +187,16 @@ export function ChainSummary({ return (
-
-

+

+
- - Checkout {chain.prettyName} cart - -

-

- + {chain.prettyName} +

+
+ {totalDonation} - - {selectedPayoutToken.code} to be contributed + + {selectedPayoutToken.code} -

+
{notEnoughBalance && ( -
+

There are insufficient funds in your wallet to complete your full donation. Please{" "} diff --git a/packages/grant-explorer/src/features/round/ViewCartPage/PayoutModals.tsx b/packages/grant-explorer/src/features/round/ViewCartPage/PayoutModals.tsx new file mode 100644 index 0000000000..8cf7328be4 --- /dev/null +++ b/packages/grant-explorer/src/features/round/ViewCartPage/PayoutModals.tsx @@ -0,0 +1,178 @@ +/* eslint-disable no-unexpected-multiline */ +import { useCartStorage } from "../../../store"; +import { useMemo, useState } from "react"; +import ChainConfirmationModal from "../../common/ConfirmationModal"; +import { ChainConfirmationModalBody } from "./ChainConfirmationModalBody"; +import { modalDelayMs } from "../../../constants"; +import { useAccount, useWalletClient } from "wagmi"; +import { groupBy } from "lodash-es"; +import MRCProgressModal from "../../common/MRCProgressModal"; +import { MRCProgressModalBody } from "./MRCProgressModalBody"; +import { useCheckoutStore } from "../../../checkoutStore"; +import { Round } from "data-layer"; +import { + getGitcoinRecipientData, + useDonateToGitcoin, +} from "../DonateToGitcoinContext"; +import { Hex } from "viem"; + +export function PayoutModals({ + openChainConfirmationModal, + setOpenChainConfirmationModal, + openMRCProgressModal, + setOpenMRCProgressModal, + rounds, + enoughBalanceByChainId, + totalAmountByChainId, + handleSwap, + totalDonationAcrossChainsInUSD, +}: { + openChainConfirmationModal: boolean; + setOpenChainConfirmationModal: React.Dispatch>; + openMRCProgressModal: boolean; + setOpenMRCProgressModal: React.Dispatch>; + rounds?: Round[]; + enoughBalanceByChainId: Record; + totalAmountByChainId: Record; + handleSwap: (chainId: number) => void; + totalDonationAcrossChainsInUSD: number; +}) { + const { + selectedChainId, + selectedToken, + directAllocationPoolId, + amountInWei, + } = useDonateToGitcoin(); + + const { data: walletClient } = useWalletClient(); + const { connector } = useAccount(); + const { checkout } = useCheckoutStore(); + const { projects } = useCartStorage(); + const projectsByChain = useMemo( + () => groupBy(projects, "chainId"), + [projects] + ); + /** The ids of the chains that will be checked out */ + const [chainIdsBeingCheckedOut, setChainIdsBeingCheckedOut] = useState< + number[] + >(Object.keys(projectsByChain).map(Number)); + + const cancelButtonAction = () => { + setOpenChainConfirmationModal(false); + setChainIdsBeingCheckedOut(Object.keys(projectsByChain).map(Number)); + }; + + /** We find the round that ends last, and take its end date as the permit deadline */ + const currentPermitDeadline = + rounds && rounds.length > 0 + ? [...rounds] + .sort((a, b) => a.roundEndTime.getTime() - b.roundEndTime.getTime()) + [rounds.length - 1].roundEndTime.getTime() + : 0; + + async function handleSubmitDonation() { + try { + if (!walletClient || !connector) { + console.error("Wallet client or Connector not available"); + return; + } + + setTimeout(() => { + setOpenMRCProgressModal(true); + setOpenChainConfirmationModal(false); + }, modalDelayMs); + + await checkout( + chainIdsBeingCheckedOut + .filter((chainId) => enoughBalanceByChainId[chainId] === true) + .map((chainId) => ({ + chainId, + permitDeadline: currentPermitDeadline, + })), + walletClient, + connector, + selectedChainId && + selectedToken && + directAllocationPoolId && + amountInWei + ? { + chainId: selectedChainId as number, + tokenAddress: selectedToken as Hex, + poolId: directAllocationPoolId.toString(), + amount: amountInWei, + recipient: getGitcoinRecipientData(selectedChainId as number) + .recipient, + nonce: getGitcoinRecipientData(selectedChainId as number).nonce, + } + : undefined + ); + } catch (error) { + console.error(error); + } + } + return ( + <> + + } + isOpen={openChainConfirmationModal} + setIsOpen={setOpenChainConfirmationModal} + disabled={chainIdsBeingCheckedOut.length === 0} + /> + enoughBalanceByChainId[chainId] === true + )} + tryAgainFn={handleSubmitDonation} + setIsOpen={setOpenMRCProgressModal} + /> + } + /> + {/*Passport not connected warning modal*/} + {/* { + setDonateWarningModalOpen(false); + handleConfirmation(); + }} + tryAgainText={"Go to Passport"} + doneText={"Donate without matching"} + onTryAgain={() => { + window.location.href = "https://passport.gitcoin.co"; + }} + heading={`Don't miss out on getting your donations matched!`} + subheading={ + <> +

+ Verify your identity with Gitcoin Passport to amplify your + donations. +

+

+ Note that donations made without Gitcoin Passport verification + will not be matched. +

+ + } + closeOnBackgroundClick={true} + /> */} + + ); +} diff --git a/packages/grant-explorer/src/features/round/ViewCartPage/SummaryContainer.tsx b/packages/grant-explorer/src/features/round/ViewCartPage/SummaryContainer.tsx index 4e5e85cc4e..0604895f94 100644 --- a/packages/grant-explorer/src/features/round/ViewCartPage/SummaryContainer.tsx +++ b/packages/grant-explorer/src/features/round/ViewCartPage/SummaryContainer.tsx @@ -3,20 +3,15 @@ import { getTokenPrice, submitPassportLite } from "common"; import { useCartStorage } from "../../../store"; import { useEffect, useMemo, useState } from "react"; import { Summary } from "./Summary"; -import ChainConfirmationModal from "../../common/ConfirmationModal"; -import { ChainConfirmationModalBody } from "./ChainConfirmationModalBody"; import { ProgressStatus } from "../../api/types"; -import { modalDelayMs } from "../../../constants"; import { useNavigate } from "react-router-dom"; -import { useAccount, useWalletClient } from "wagmi"; +import { useAccount } from "wagmi"; import { Button } from "common/src/styles"; import { InformationCircleIcon } from "@heroicons/react/24/solid"; import { BoltIcon } from "@heroicons/react/24/outline"; import { getClassForPassportColor } from "../../api/passport"; import useSWR from "swr"; import { groupBy, uniqBy } from "lodash-es"; -import MRCProgressModal from "../../common/MRCProgressModal"; -import { MRCProgressModalBody } from "./MRCProgressModalBody"; import { useCheckoutStore } from "../../../checkoutStore"; import { Address, parseUnits, zeroAddress } from "viem"; import { useConnectModal } from "@rainbow-me/rainbowkit"; @@ -31,23 +26,22 @@ import { useDataLayer } from "data-layer"; import { isPresent } from "ts-is-present"; import { getFormattedRoundId } from "../../common/utils/utils"; import { datadogLogs } from "@datadog/browser-logs"; +import { PayoutModals } from "./PayoutModals"; export function SummaryContainer(props: { enoughBalanceByChainId: Record; totalAmountByChainId: Record; handleSwap: (chainId: number) => void; }) { - const { data: walletClient } = useWalletClient(); const navigate = useNavigate(); - const { address, isConnected, connector } = useAccount(); + const { address, isConnected } = useAccount(); const { projects, getVotingTokenForChain, remove: removeProjectFromCart, } = useCartStorage(); - const { checkout, voteStatus, chainsToCheckout } = useCheckoutStore(); + const { voteStatus, chainsToCheckout } = useCheckoutStore(); const dataLayer = useDataLayer(); - const { openConnectModal } = useConnectModal(); const projectsByChain = useMemo( @@ -98,22 +92,9 @@ export function SummaryContainer(props: { // eslint-disable-next-line react-hooks/exhaustive-deps }, [projects, clickedSubmit]); - /** The ids of the chains that will be checked out */ - const [chainIdsBeingCheckedOut, setChainIdsBeingCheckedOut] = useState< - number[] - >(Object.keys(projectsByChain).map(Number)); - - /** We find the round that ends last, and take its end date as the permit deadline */ - const currentPermitDeadline = - rounds && rounds.length > 0 - ? [...rounds] - .sort((a, b) => a.roundEndTime.getTime() - b.roundEndTime.getTime()) - [rounds.length - 1].roundEndTime.getTime() - : 0; - const [emptyInput, setEmptyInput] = useState(false); const [openChainConfirmationModal, setOpenChainConfirmationModal] = - useState(false); + useState(true); const [openMRCProgressModal, setOpenMRCProgressModal] = useState(false); /* Donate without matching warning modal */ // const [donateWarningModalOpen, setDonateWarningModalOpen] = useState(false); @@ -170,100 +151,6 @@ export function SummaryContainer(props: { } } - function PayoutModals() { - return ( - <> - - } - isOpen={openChainConfirmationModal} - setIsOpen={setOpenChainConfirmationModal} - disabled={chainIdsBeingCheckedOut.length === 0} - /> - props.enoughBalanceByChainId[chainId] === true - )} - tryAgainFn={handleSubmitDonation} - setIsOpen={setOpenMRCProgressModal} - /> - } - /> - {/*Passport not connected warning modal*/} - {/* { - setDonateWarningModalOpen(false); - handleConfirmation(); - }} - tryAgainText={"Go to Passport"} - doneText={"Donate without matching"} - onTryAgain={() => { - window.location.href = "https://passport.gitcoin.co"; - }} - heading={`Don’t miss out on getting your donations matched!`} - subheading={ - <> -

- Verify your identity with Gitcoin Passport to amplify your - donations. -

-

- Note that donations made without Gitcoin Passport verification - will not be matched. -

- - } - closeOnBackgroundClick={true} - /> */} - - ); - } - - async function handleSubmitDonation() { - try { - if (!walletClient || !connector) { - console.log("Wallet client or Connector not available"); - return; - } - - setTimeout(() => { - setOpenMRCProgressModal(true); - setOpenChainConfirmationModal(false); - }, modalDelayMs); - - await checkout( - chainIdsBeingCheckedOut - .filter((chainId) => props.enoughBalanceByChainId[chainId] === true) - .map((chainId) => ({ - chainId, - permitDeadline: currentPermitDeadline, - })), - walletClient, - connector, - dataLayer - ); - } catch (error) { - console.error(error); - } - } - const passportTextClass = getClassForPassportColor("black"); const { data: totalDonationAcrossChainsInUSDData } = useSWR( @@ -423,7 +310,17 @@ export function SummaryContainer(props: { > {isConnected ? "Submit your donation!" : "Connect wallet to continue"} - +

Need to bridge funds ? Bridge funds{" "} ({}); - const [totalAmountByChainId, setTotalAmountByChainId] = useState< - Record - >({}); + const [{ balances, addressOfBalances }, setBalances] = useState<{ + balances: BalanceMap; + addressOfBalances?: `0x${string}`; + }>({ + balances: {}, + }); const [enoughBalanceByChainId, setEnoughBalanceByChainId] = useState< Record >({}); const dataLayer = useDataLayer(); - const groupedCartProjects = groupProjectsInCart(projects); - const chainIds = Object.keys(groupedCartProjects); + const [chainIds, groupedCartProjects] = useMemo(() => { + const groupedCartProjects = groupProjectsInCart(projects); + const chainIds = Object.keys(groupedCartProjects); + return [chainIds, groupedCartProjects]; + }, [projects]); const [openSwapModel, setOpenSwapModal] = useState(false); const [swapParams, setSwapParams] = useState({ @@ -90,27 +95,26 @@ export default function ViewCart() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useEffect(() => { - const totalAmountByChainId = Object.keys(groupedCartProjects).reduce( - (acc, chainId) => { - const amount = Object.values( - groupedCartProjects[Number(chainId)] - ).reduce( - (acc, curr) => - acc + - curr.reduce((acc, curr) => acc + (Number(curr.amount) || 0), 0), - 0 - ); - return { ...acc, [Number(chainId)]: amount }; - }, - {} - ); - setTotalAmountByChainId(totalAmountByChainId); - }, [projects]); - // reduce the number of re-renders by memoizing the chainIds - const memoizedChainIds = useMemo(() => chainIds, [JSON.stringify(chainIds)]); + const totalAmountByChainId: Record = useMemo( + () => + Object.keys(groupedCartProjects).reduce( + (acc, chainId) => { + const amount = Object.values( + groupedCartProjects[Number(chainId)] + ).reduce( + (acc, curr) => + acc + + curr.reduce((acc, curr) => acc + (Number(curr.amount) || 0), 0), + 0 + ); + return { ...acc, [Number(chainId)]: amount }; + }, + {} as Record + ), + [groupedCartProjects] + ); - const fetchBalances = async () => { + const fetchBalances = useCallback(async () => { const allBalances = await Promise.all( chainIds.map(async (chainId) => { const chainIdNumber = Number(chainId); @@ -136,7 +140,10 @@ export default function ViewCart() { address: token.address, chainId: chainIdNumber, formattedAmount: Number( - formatUnits(balance.value / BigInt(getMultiplier(chainIdNumber)), balance.decimals) + formatUnits( + balance.value / BigInt(getMultiplier(chainIdNumber)), + balance.decimals + ) ), }; } catch (e) { @@ -169,15 +176,15 @@ export default function ViewCart() { map[chainId] = balanceMap; return map; }, {} as BalanceMap); - setBalances(newBalances); - }; + setBalances({ balances: newBalances, addressOfBalances: address }); + }, [address, chainIds]); useEffect(() => { // Fetch balances if they have not been fetched yet - if (Object.keys(balances) && address) { + if (address !== addressOfBalances) { fetchBalances(); } - }, [memoizedChainIds, address, config]); + }, [address, addressOfBalances, fetchBalances]); useEffect(() => { if ( @@ -198,7 +205,7 @@ export default function ViewCart() { ); setEnoughBalanceByChainId(enoughBalanceByChainId); } - }, [balances, totalAmountByChainId]); + }, [balances, totalAmountByChainId, getVotingTokenForChain]); const breadCrumbs: BreadcrumbItem[] = [ { diff --git a/packages/grant-explorer/src/index.tsx b/packages/grant-explorer/src/index.tsx index 28e12408e3..eef271be64 100644 --- a/packages/grant-explorer/src/index.tsx +++ b/packages/grant-explorer/src/index.tsx @@ -35,6 +35,7 @@ import { PostHogProvider } from "posthog-js/react"; import ViewProject from "./features/projects/ViewProject"; import { ExploreProjectsPage } from "./features/discovery/ExploreProjectsPage"; import { DirectAllocationProvider } from "./features/projects/hooks/useDirectAllocation"; +import { DonateToGitcoinProvider } from "./features/round/DonateToGitcoinContext"; initDatadog(); initTagmanager(); @@ -67,72 +68,74 @@ root.render( - - - - - - - {/* Protected Routes */} - } /> - - {/* Default Route */} - } /> - - } - /> - - {/* Round Routes */} - } - /> - } - /> - - {/* Project Routes */} - - } - /> - - } - /> - - } /> - - } /> - - } - /> - - {/* Access Denied */} - } - /> - } - /> - - {/* 404 */} - } /> - - - - - - + + + + + + + + {/* Protected Routes */} + } /> + + {/* Default Route */} + } /> + + } + /> + + {/* Round Routes */} + } + /> + } + /> + + {/* Project Routes */} + + } + /> + + } + /> + + } /> + + } /> + + } + /> + + {/* Access Denied */} + } + /> + } + /> + + {/* 404 */} + } /> + + + + + + +