diff --git a/src/app/flow-councils/flow-councils.tsx b/src/app/flow-councils/flow-councils.tsx index d9ce7480..2fdf11e1 100644 --- a/src/app/flow-councils/flow-councils.tsx +++ b/src/app/flow-councils/flow-councils.tsx @@ -245,8 +245,11 @@ export default function FlowCouncils(props: FlowCouncilsProps) { return ( + router.push(`/flow-councils/${selectedNetwork.id}/${flowCouncil.id}`) + } > e.stopPropagation()} > {token && ( @@ -277,7 +281,8 @@ export default function FlowCouncils(props: FlowCouncilsProps) { )} {flowCouncil.isRecipient && !flowCouncil.isConnected ? ( - +
e.stopPropagation()}> + +
) : ( diff --git a/src/app/flow-splitters/[chainId]/[poolId]/flow-splitter.tsx b/src/app/flow-splitters/[chainId]/[poolId]/flow-splitter.tsx index f0ece98f..67185b6a 100644 --- a/src/app/flow-splitters/[chainId]/[poolId]/flow-splitter.tsx +++ b/src/app/flow-splitters/[chainId]/[poolId]/flow-splitter.tsx @@ -398,6 +398,7 @@ export default function FlowSplitter(props: FlowSplitterProps) { + ) : ( + + )} + ) : connectStatus === "slots-full" ? ( + + ) : ( + + ) + ) : null; + return (
setShowToolbar(true)} @@ -159,21 +474,24 @@ function CustomNode(props: NodeProps) { gap={1} className="align-items-center cursor-pointer" > - {data.avatar ? ( - avatar - ) : ( - - )} +
+ {data.avatar ? ( + avatar + ) : ( + + )} + {statusOverlay} +
{data?.label?.toString() ?? ""} @@ -238,6 +556,7 @@ function CustomNode(props: NodeProps) { )} + {connectButton} @@ -411,19 +417,22 @@ export default function FlowSplitters(props: FlowSplittersProps) { {pool.isConnected ? ( ) : ( - +
e.stopPropagation()}> + +
)} diff --git a/src/app/pools/[chainId]/[poolAddress]/admin/page.tsx b/src/app/pools/[chainId]/[poolAddress]/admin/page.tsx new file mode 100644 index 00000000..45cba963 --- /dev/null +++ b/src/app/pools/[chainId]/[poolAddress]/admin/page.tsx @@ -0,0 +1,12 @@ +import PoolAdmin from "./pool-admin"; + +type Params = { + chainId: string; + poolAddress: string; +}; + +export default async function Page({ params }: { params: Promise }) { + const { chainId, poolAddress } = await params; + + return ; +} diff --git a/src/app/pools/[chainId]/[poolAddress]/admin/pool-admin.tsx b/src/app/pools/[chainId]/[poolAddress]/admin/pool-admin.tsx new file mode 100644 index 00000000..db2e51ad --- /dev/null +++ b/src/app/pools/[chainId]/[poolAddress]/admin/pool-admin.tsx @@ -0,0 +1,981 @@ +"use client"; + +import { useMemo, useCallback, useState, useEffect } from "react"; +import Link from "next/link"; +import { + Address, + parseAbi, + isAddress, + encodeAbiParameters, + encodeFunctionData, +} from "viem"; +import { + useConfig, + useAccount, + useWalletClient, + usePublicClient, + useSwitchChain, + useReadContract, +} from "wagmi"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; +import Papa from "papaparse"; +import { writeContract } from "@wagmi/core"; +import { useQuery, gql } from "@apollo/client"; +import { usePostHog } from "posthog-js/react"; +import Stack from "react-bootstrap/Stack"; +import Button from "react-bootstrap/Button"; +import Image from "react-bootstrap/Image"; +import Dropdown from "react-bootstrap/Dropdown"; +import Card from "react-bootstrap/Card"; +import Toast from "react-bootstrap/Toast"; +import Alert from "react-bootstrap/Alert"; +import Spinner from "react-bootstrap/Spinner"; +import FormCheck from "react-bootstrap/FormCheck"; +import Form from "react-bootstrap/Form"; +import InfoTooltip from "@/components/InfoTooltip"; +import { getApolloClient } from "@/lib/apollo"; +import { gdaAbi } from "@/lib/abi/gda"; +import { superfluidPoolAbi } from "@/lib/abi/superfluidPool"; +import { superfluidHostAbi } from "@/lib/abi/superfluidHost"; +import { useMediaQuery } from "@/hooks/mediaQuery"; +import { networks } from "@/lib/networks"; +import { isNumber, truncateStr } from "@/lib/utils"; + +type PoolAdminProps = { + chainId: number; + poolAddress: string; +}; + +type MemberEntry = { address: string; units: string; validationError: string }; + +const SUPERFLUID_QUERY = gql` + query SuperfluidQuery($token: String!, $gdaPool: String!) { + token(id: $token) { + id + symbol + } + pool(id: $gdaPool) { + id + poolMembers { + account { + id + } + units + } + poolDistributors(first: 1000, where: { flowRate_not: "0" }) { + account { + id + } + flowRate + } + } + } +`; + +export default function PoolAdmin(props: PoolAdminProps) { + const { poolAddress, chainId } = props; + + const [membersEntry, setMembersEntry] = useState([ + { address: "", units: "", validationError: "" }, + ]); + const [membersToRemove, setMembersToRemove] = useState([]); + const [transactionSuccess, setTransactionSuccess] = useState(""); + const [transactionError, setTransactionError] = useState(""); + const [isTransactionLoading, setIsTransactionLoading] = useState(false); + + const { isMobile } = useMediaQuery(); + const { data: walletClient } = useWalletClient(); + const { address, chain: connectedChain } = useAccount(); + const { switchChain } = useSwitchChain(); + const { openConnectModal } = useConnectModal(); + + const { data: poolAdmin } = useReadContract({ + address: poolAddress as Address, + abi: superfluidPoolAbi, + functionName: "admin", + chainId, + }); + + const { data: superToken, isLoading: superTokenLoading } = useReadContract({ + address: poolAddress as Address, + abi: superfluidPoolAbi, + functionName: "superToken", + chainId, + }); + + const tokenAddress = superToken + ? (superToken as Address).toLowerCase() + : undefined; + + const { data: superfluidQueryRes, loading: superfluidQueryLoading } = + useQuery(SUPERFLUID_QUERY, { + client: getApolloClient("superfluid", chainId), + variables: { + token: tokenAddress, + gdaPool: poolAddress.toLowerCase(), + }, + pollInterval: 10000, + skip: !tokenAddress, + }); + + const { data: unitsTrasnferability } = useReadContract({ + address: poolAddress as Address, + abi: parseAbi([ + "function transferabilityForUnitsOwner() view returns (bool)", + ]), + functionName: "transferabilityForUnitsOwner", + chainId, + query: { enabled: !!tokenAddress }, + }); + + const wagmiConfig = useConfig(); + const publicClient = usePublicClient(); + const postHog = usePostHog(); + + const network = networks.find((network) => network.id === chainId); + const poolToken = network?.tokens.find( + (token) => token.address.toLowerCase() === tokenAddress, + ); + const isAdmin = + address?.toLowerCase() === (poolAdmin as Address)?.toLowerCase(); + const isValidMembersEntry = membersEntry.every( + (memberEntry) => + memberEntry.validationError === "" && + memberEntry.address !== "" && + memberEntry.units !== "", + ); + + const totalUnits = useMemo( + () => + membersEntry + .map((memberEntry) => + isNumber(memberEntry.units) ? Number(memberEntry.units) : 0, + ) + .reduce((a, b) => a + b, 0), + [membersEntry], + ); + + const hasChanges = useMemo(() => { + const compareArrays = (a: string[], b: string[]) => + a.length === b.length && a.every((elem, i) => elem === b[i]); + + const sortedPoolMembers = superfluidQueryRes?.pool?.poolMembers + ? [...superfluidQueryRes.pool.poolMembers].sort( + (a: { account: { id: string } }, b: { account: { id: string } }) => + a.account.id > b.account.id ? -1 : 1, + ) + : []; + const sortedMembersEntry = membersEntry + ? [...membersEntry].sort((a, b) => + a.address.toLowerCase() > b.address.toLowerCase() ? -1 : 1, + ) + : []; + const hasChangesMembers = + sortedPoolMembers && + (!compareArrays( + sortedPoolMembers + .filter((member: { units: string }) => member.units !== "0") + .map((member: { account: { id: string } }) => member.account.id), + sortedMembersEntry.map((member) => member.address.toLowerCase()), + ) || + !compareArrays( + sortedPoolMembers + .filter((member: { units: string }) => member.units !== "0") + .map((member: { units: string }) => member.units), + sortedMembersEntry.map((member) => member.units), + )); + + return hasChangesMembers || membersToRemove.length > 0; + }, [superfluidQueryRes, membersEntry, membersToRemove]); + + const addPoolToWallet = useCallback(() => { + walletClient?.request({ + method: "wallet_watchAsset", + params: { + type: "ERC20", + options: { + address: poolAddress, + symbol: superfluidQueryRes?.pool?.token?.symbol ?? "POOL", + decimals: 0, + image: "", + }, + }, + }); + }, [poolAddress, superfluidQueryRes, walletClient]); + + useEffect(() => { + (async () => { + if (!superfluidQueryRes?.pool?.poolMembers) { + return; + } + + const membersEntry = superfluidQueryRes.pool.poolMembers + .filter((member: { units: string }) => member.units !== "0") + .map((member: { account: { id: string }; units: string }) => { + return { + address: member.account.id, + units: member.units, + validationError: "", + }; + }); + + if (membersEntry.length > 0) { + setMembersEntry(membersEntry); + } + })(); + }, [superfluidQueryRes]); + + useEffect(() => { + if (process.env.NODE_ENV !== "development") { + postHog.startSessionRecording(); + } + }, [postHog, postHog.decideEndpointWasHit]); + + const removeMemberEntry = (memberEntry: MemberEntry, memberIndex: number) => { + setMembersEntry((prev) => + prev.filter( + (_, prevMemberEntryIndex) => prevMemberEntryIndex !== memberIndex, + ), + ); + + const existingPoolMember = superfluidQueryRes?.pool?.poolMembers?.find( + (member: { account: { id: string } }) => + member.account.id === memberEntry.address.toLowerCase(), + ); + + if ( + !memberEntry.validationError && + existingPoolMember && + existingPoolMember.units !== "0" + ) { + setMembersToRemove(membersToRemove.concat(memberEntry)); + } + }; + + const handleCsvUpload = (e: React.ChangeEvent) => { + if (!e.target.files) { + return; + } + + Papa?.parse(e.target.files[0], { + complete: (results: { data: string[] }) => { + const { data } = results; + + const membersEntry: MemberEntry[] = []; + + for (const row of data) { + if (!row[0]) { + continue; + } + + membersEntry.push({ + address: row[0], + units: + isNumber(row[1]) && !row[1].includes(".") + ? row[1].replace(/\s/g, "") + : "", + validationError: !isAddress(row[0]) + ? "Invalid Address" + : membersEntry + .map((memberEntry) => memberEntry.address.toLowerCase()) + .includes(row[0].toLowerCase()) + ? "Address already added" + : "", + }); + } + + const membersToRemove = []; + + for (const i in membersEntry) { + if (membersEntry[i].units === "0") { + if (!membersEntry[i].validationError) { + membersToRemove.push(membersEntry[i]); + membersEntry.splice(Number(i), 1); + } + } + } + + const csvAddresses = data.map((row) => row[0].toLowerCase()); + const existingMembers = superfluidQueryRes?.pool.poolMembers; + const excludedMembers = existingMembers.filter( + (existingMember: { account: { id: string } }) => + !csvAddresses.some( + (address) => existingMember.account.id === address, + ), + ); + + for (const excludedMember of excludedMembers) { + membersToRemove.push({ + address: excludedMember.account.id, + units: excludedMember.units, + validationError: "", + }); + } + + setMembersEntry(membersEntry); + setMembersToRemove(membersToRemove); + }, + }); + }; + + const handleSubmit = async () => { + if (!network || !address || !publicClient) { + return; + } + + try { + setTransactionSuccess(""); + setTransactionError(""); + setIsTransactionLoading(true); + + const validMembers = membersEntry.filter( + (memberEntry) => + memberEntry.validationError === "" && + memberEntry.address !== "" && + !superfluidQueryRes?.pool?.poolMembers.some( + (member: { account: { id: string }; units: string }) => + member.account.id === memberEntry.address && + member.units === memberEntry.units, + ), + ); + + const changedMembers = validMembers + .map((member) => ({ + address: member.address as Address, + units: BigInt(member.units), + })) + .concat( + membersToRemove.map((member) => ({ + address: member.address as Address, + units: BigInt(0), + })), + ); + + const operations = changedMembers.map((member) => ({ + operationType: 201, + target: network.gda, + data: encodeAbiParameters( + [{ type: "bytes" }, { type: "bytes" }], + [ + encodeFunctionData({ + abi: gdaAbi, + functionName: "updateMemberUnits", + args: [ + poolAddress as Address, + member.address, + member.units, + "0x" as `0x${string}`, + ], + }), + "0x" as `0x${string}`, + ], + ), + })); + + const hash = await writeContract(wagmiConfig, { + address: network.superfluidHost, + abi: superfluidHostAbi, + functionName: "batchCall", + args: [operations], + }); + + await publicClient.waitForTransactionReceipt({ + hash, + confirmations: 3, + }); + + setIsTransactionLoading(false); + setTransactionSuccess("Pool Updated Successfully"); + setMembersToRemove([]); + } catch (err) { + console.error(err); + + setTransactionError("Transaction Error"); + setIsTransactionLoading(false); + } + }; + + return ( + <> + + {superTokenLoading || superfluidQueryLoading ? ( + + + + ) : !network ? ( +

Network Not Found

+ ) : ( + <> +

+ + Distribution Pool{" "} + ( + + + + {truncateStr(poolAddress, 14)} + + ) + + +

+ + Distributing{" "} + {poolToken && ( + + )} + {superfluidQueryRes?.token.symbol} on + + {network.name} + + + + + Configuration in this section cannot be edited after + deployment. + + + + + Network Icon + {network.name} + + + + + + + + {poolToken && ( + Network Icon + )} + {superfluidQueryRes?.token.symbol} + + + + + + + + + + Share Transferability + + } + content={ +

+ Should recipients be able to transfer (or trade) their + shares? +

+ } + /> +
+ + + + + Non-Transferable (Admin Only) + + + + + + Transferable by Recipients + + + +
+
+
+ + + Pool Admin + + } + content={ +

+ Distribution pool admins are fixed at deployment. +

+ } + /> +
+ + + +
+ + + Share Register (POOL) + + } + content={ +

+ As tokens are streamed to the pool, they're proportionally + distributed in real time to recipients according to their + percentage of the total outstanding shares. +
+
+ Any changes to the total number of outstanding or a + recipient's shares will be reflected in the continuing + stream allocation. +

+ } + /> +
+ + {membersEntry.map((memberEntry, i) => ( + + + + + member.account.id.toLowerCase(), + ) + .includes(memberEntry.address.toLowerCase()) && + !memberEntry.validationError) + } + placeholder={ + isMobile ? "Address" : "Recipient Address" + } + value={memberEntry.address} + className="bg-white border-0 fw-semi-bold" + style={{ paddingTop: 12, paddingBottom: 12 }} + onChange={(e) => { + const prevMembersEntry = [...membersEntry]; + const value = e.target.value; + + if (!isAddress(value)) { + prevMembersEntry[i].validationError = + "Invalid Address"; + } else if ( + prevMembersEntry + .map((prevMember) => + prevMember.address.toLowerCase(), + ) + .includes(value.toLowerCase()) + ) { + prevMembersEntry[i].validationError = + "Address already added"; + } else { + prevMembersEntry[i].validationError = ""; + } + + prevMembersEntry[i].address = value; + + setMembersEntry(prevMembersEntry); + }} + /> + {memberEntry.validationError ? ( + + {memberEntry.validationError} + + ) : null} + + + + { + const prevMembersEntry = [...membersEntry]; + const value = e.target.value; + + if (!value) { + prevMembersEntry[i].units = ""; + } else if (value.includes(".")) { + return; + } else if (isNumber(value)) { + if (value === "0") { + removeMemberEntry(prevMembersEntry[i], i); + + return; + } else { + prevMembersEntry[i].units = value; + } + } + + setMembersEntry(prevMembersEntry); + }} + /> + + + + + isNumber(memberEntry.units) + ? Number(memberEntry.units) + : 0, + ) + .reduce((a, b) => a + b, 0)) * + 100 + ).toFixed(isMobile ? 1 : 2), + )}%` + } + className="bg-white border-0 fw-semi-bold text-center" + style={{ paddingTop: 12, paddingBottom: 12 }} + /> + + + + + ))} + {membersToRemove.map((memberEntry, i) => ( + + + + + + + + + + + + + + + + + ))} + + + + + + + + + Remove + + + + + { + return [memberEntry.address, memberEntry.units]; + }), + ), + ]), + )} + target="_blank" + download="Pool_Members.csv" + className="m-0 bg-secondary px-10 py-4 rounded-4 text-light fw-semi-bold text-decoration-none" + > + Export Current + + <> + + Upload CSV + + + + Template + + +
+ + + + setTransactionSuccess("")} + className="w-100 p-4 mt-4 fs-6 fw-semi-bold" + style={{ + background: "rgb(209, 231, 220.8)", + color: "rgb(10, 54, 33.6)", + borderColor: "rgb(163, 207, 186.6)", + }} + > + Pool Updated Successfully! + + {transactionError ? ( + + {transactionError} + + ) : null} + + )} +
+ + ); +} diff --git a/src/app/pools/[chainId]/[poolAddress]/page.tsx b/src/app/pools/[chainId]/[poolAddress]/page.tsx new file mode 100644 index 00000000..df00a0da --- /dev/null +++ b/src/app/pools/[chainId]/[poolAddress]/page.tsx @@ -0,0 +1,12 @@ +import PoolDetail from "./pool-detail"; + +type Params = { + chainId: string; + poolAddress: string; +}; + +export default async function Page({ params }: { params: Promise }) { + const { chainId, poolAddress } = await params; + + return ; +} diff --git a/src/app/pools/[chainId]/[poolAddress]/pool-detail.tsx b/src/app/pools/[chainId]/[poolAddress]/pool-detail.tsx new file mode 100644 index 00000000..839a0fb3 --- /dev/null +++ b/src/app/pools/[chainId]/[poolAddress]/pool-detail.tsx @@ -0,0 +1,460 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Address, createPublicClient, http } from "viem"; +import { mainnet } from "viem/chains"; +import { normalize } from "viem/ens"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { + useAccount, + useWalletClient, + useSwitchChain, + useReadContract, +} from "wagmi"; +import { useQuery, gql } from "@apollo/client"; +import { usePostHog } from "posthog-js/react"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; +import Stack from "react-bootstrap/Stack"; +import Button from "react-bootstrap/Button"; +import Image from "react-bootstrap/Image"; +import Spinner from "react-bootstrap/Spinner"; +import Modal from "react-bootstrap/Modal"; +import InfoTooltip from "@/components/InfoTooltip"; +import PoolConnectionButton from "@/components/PoolConnectionButton"; +import ActivityFeed from "@/app/flow-splitters/components/ActivityFeed"; +import PoolGraph from "@/app/flow-splitters/components/PoolGraph"; +import OpenFlow from "@/app/flow-splitters/components/OpenFlow"; +import InstantDistribution from "@/app/flow-splitters/components/InstantDistribution"; +import { getApolloClient } from "@/lib/apollo"; +import { networks } from "@/lib/networks"; +import { truncateStr } from "@/lib/utils"; +import { superfluidPoolAbi } from "@/lib/abi/superfluidPool"; +import { IPFS_GATEWAYS } from "@/lib/constants"; + +type PoolDetailProps = { + chainId: number; + poolAddress: string; +}; + +const SUPERFLUID_QUERY = gql` + query SuperfluidQuery($token: String!, $gdaPool: String!) { + token(id: $token) { + id + symbol + } + pool(id: $gdaPool) { + id + flowRate + totalUnits + totalAmountFlowedDistributedUntilUpdatedAt + totalAmountInstantlyDistributedUntilUpdatedAt + updatedAtTimestamp + poolMembers(first: 1000, where: { units_not: "0" }) { + account { + id + } + units + isConnected + } + poolDistributors(first: 1000, where: { flowRate_not: "0" }) { + account { + id + } + flowRate + } + token { + id + symbol + } + poolCreatedEvent { + timestamp + transactionHash + name + } + memberUnitsUpdatedEvents( + first: 1000 + orderBy: timestamp + orderDirection: desc + ) { + units + oldUnits + poolMember { + account { + id + } + } + timestamp + transactionHash + } + flowDistributionUpdatedEvents( + first: 1000 + orderBy: timestamp + orderDirection: desc + ) { + newDistributorToPoolFlowRate + oldFlowRate + poolDistributor { + account { + id + } + } + timestamp + transactionHash + } + instantDistributionUpdatedEvents( + first: 1000 + orderBy: timestamp + orderDirection: desc + ) { + requestedAmount + poolDistributor { + account { + id + } + } + timestamp + transactionHash + } + } + } +`; + +export default function PoolDetail(props: PoolDetailProps) { + const { poolAddress, chainId } = props; + + const [showOpenFlow, setShowOpenFlow] = useState(false); + const [showInstantDistribution, setShowInstantDistribution] = useState(false); + const [showConnectionModal, setShowConnectionModal] = useState(false); + const [ensByAddress, setEnsByAddress] = useState<{ + [key: Address]: { name: string | null; avatar: string | null }; + } | null>(null); + + const router = useRouter(); + const { openConnectModal } = useConnectModal(); + const { switchChain } = useSwitchChain(); + const { data: walletClient } = useWalletClient(); + const { address, chain: connectedChain } = useAccount(); + const postHog = usePostHog(); + + const network = networks.find((network) => network.id === chainId); + + const { data: superToken, isLoading: superTokenLoading } = useReadContract({ + address: poolAddress as Address, + abi: superfluidPoolAbi, + functionName: "superToken", + chainId, + }); + + const tokenAddress = superToken + ? (superToken as Address).toLowerCase() + : undefined; + + const { data: superfluidQueryRes, loading: superfluidQueryLoading } = + useQuery(SUPERFLUID_QUERY, { + client: getApolloClient("superfluid", chainId), + variables: { + token: tokenAddress, + gdaPool: poolAddress.toLowerCase(), + }, + pollInterval: 10000, + skip: !tokenAddress, + }); + + const poolName = + superfluidQueryRes?.pool?.poolCreatedEvent?.name && + superfluidQueryRes.pool.poolCreatedEvent.name !== "Superfluid Pool" + ? superfluidQueryRes.pool.poolCreatedEvent.name + : "Distribution Pool"; + const poolToken = network?.tokens.find( + (token) => token.address.toLowerCase() === tokenAddress, + ) ?? { + address: (tokenAddress ?? "") as Address, + symbol: superfluidQueryRes?.token?.symbol ?? "N/A", + icon: "", + }; + const poolMember = superfluidQueryRes?.pool?.poolMembers.find( + (member: { account: { id: string } }) => + member.account.id === address?.toLowerCase(), + ); + const shouldConnect = !!poolMember && !poolMember.isConnected; + + useEffect(() => setShowConnectionModal(shouldConnect), [shouldConnect]); + + useEffect(() => { + const ensByAddress: { + [key: Address]: { name: string | null; avatar: string | null }; + } = {}; + (async () => { + if (!superfluidQueryRes?.pool) { + return; + } + + const addresses = []; + + for (const memberUnitsUpdatedEvent of superfluidQueryRes.pool + .memberUnitsUpdatedEvents) { + addresses.push(memberUnitsUpdatedEvent.poolMember.account.id); + } + + for (const flowDistributionUpdatedEvent of superfluidQueryRes.pool + .flowDistributionUpdatedEvents) { + addresses.push(flowDistributionUpdatedEvent.poolDistributor.account.id); + } + + for (const instantDistributionUpdatedEvent of superfluidQueryRes.pool + .instantDistributionUpdatedEvents) { + addresses.push( + instantDistributionUpdatedEvent.poolDistributor.account.id, + ); + } + + const publicClient = createPublicClient({ + chain: mainnet, + transport: http("https://ethereum-rpc.publicnode.com", { + batch: { + batchSize: 100, + wait: 10, + }, + }), + }); + + try { + const ensNames = await Promise.all( + addresses.map((address) => + publicClient.getEnsName({ + address: address as Address, + }), + ), + ); + + const ensAvatars = await Promise.all( + ensNames.map((ensName) => + publicClient.getEnsAvatar({ + name: normalize(ensName ?? ""), + gatewayUrls: ["https://ccip.ens.xyz"], + assetGatewayUrls: { + ipfs: IPFS_GATEWAYS[0], + }, + }), + ), + ); + + for (const i in addresses) { + ensByAddress[addresses[i] as Address] = { + name: ensNames[i] ?? null, + avatar: ensAvatars[i] ?? null, + }; + } + } catch (err) { + console.error(err); + } + + setEnsByAddress(ensByAddress); + })(); + }, [superfluidQueryRes]); + + useEffect(() => { + if (process.env.NODE_ENV !== "development") { + postHog.startSessionRecording(); + } + }, [postHog, postHog.decideEndpointWasHit]); + + const addToWallet = (args: { + address: string; + symbol: string; + decimals: number; + image: string; + }) => { + const { address, symbol, decimals, image } = args; + + walletClient?.request({ + method: "wallet_watchAsset", + params: { + type: "ERC20", + options: { + address, + symbol, + decimals, + image, + }, + }, + }); + }; + + return ( + <> + + {superTokenLoading || superfluidQueryLoading ? ( + + + + ) : !network || !superfluidQueryRes?.pool ? ( +

Distribution Pool Not Found

+ ) : ( + <> +

+ + {poolName} ( + + + + {truncateStr(poolAddress, 14)} + + ) + + +

+ + Distributing{" "} + {!!poolToken.icon && ( + + )} + {superfluidQueryRes?.token.symbol} on + + {network.name} + + + + + + {superfluidQueryRes?.pool && ( + + )} + + )} +
+ {showOpenFlow && ( + setShowOpenFlow(false)} + /> + )} + {showInstantDistribution && ( + setShowInstantDistribution(false)} + /> + )} + setShowConnectionModal(false)} + > + + + You're a recipient in this pool but haven't connected your shares. + + + + Do you want to do that now, so your{" "} + + Super Token balance + {" "} + is reflected in real time? + + + + + + + ); +} diff --git a/src/app/pools/page.tsx b/src/app/pools/page.tsx new file mode 100644 index 00000000..3d7f3a1d --- /dev/null +++ b/src/app/pools/page.tsx @@ -0,0 +1,16 @@ +import { SearchParams } from "@/types/searchParams"; +import Pools from "./pools"; +import { networks } from "@/lib/networks"; + +export default async function Page({ + searchParams, +}: { + searchParams: Promise; +}) { + const { chainId, pool } = await searchParams; + + const defaultNetwork = + networks.find((network) => network.id === Number(chainId)) ?? networks[1]; + + return ; +} diff --git a/src/app/pools/pools.tsx b/src/app/pools/pools.tsx new file mode 100644 index 00000000..3a926989 --- /dev/null +++ b/src/app/pools/pools.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Address, isAddress } from "viem"; +import { useAccount, useSwitchChain, useReadContract } from "wagmi"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; +import Stack from "react-bootstrap/Stack"; +import Dropdown from "react-bootstrap/Dropdown"; +import Button from "react-bootstrap/Button"; +import Form from "react-bootstrap/Form"; +import Spinner from "react-bootstrap/Spinner"; +import Alert from "react-bootstrap/Alert"; +import { Network } from "@/types/network"; +import { useMediaQuery } from "@/hooks/mediaQuery"; +import { networks } from "@/lib/networks"; +import { superfluidPoolAbi } from "@/lib/abi/superfluidPool"; + +type PoolsProps = { + defaultNetwork: Network; + defaultPoolAddress?: string; +}; + +export default function Pools(props: PoolsProps) { + const { defaultNetwork, defaultPoolAddress } = props; + + const [selectedNetwork, setSelectedNetwork] = + useState(defaultNetwork); + const [poolAddress, setPoolAddress] = useState(defaultPoolAddress ?? ""); + const [validationError, setValidationError] = useState(""); + const [isValidating, setIsValidating] = useState(false); + + const router = useRouter(); + const { isMobile } = useMediaQuery(); + const { chain: connectedChain } = useAccount(); + const { switchChain } = useSwitchChain(); + const { openConnectModal } = useConnectModal(); + + const isValidAddress = isAddress(poolAddress); + + const { + data: superToken, + error: superTokenError, + isLoading: superTokenLoading, + } = useReadContract({ + address: poolAddress as Address, + abi: superfluidPoolAbi, + functionName: "superToken", + chainId: selectedNetwork.id, + query: { enabled: isValidAddress }, + }); + + useEffect(() => { + if (!isValidAddress) { + setIsValidating(false); + return; + } + + if (superTokenLoading) { + setIsValidating(true); + setValidationError(""); + return; + } + + setIsValidating(false); + + if ( + superTokenError || + !superToken || + superToken === "0x0000000000000000000000000000000000000000" + ) { + setValidationError( + "Not a valid Superfluid distribution pool on this network", + ); + } else { + setValidationError(""); + } + }, [isValidAddress, superToken, superTokenError, superTokenLoading]); + + const handleSubmit = () => { + if (!isValidAddress) { + setValidationError("Invalid address format"); + return; + } + + if (validationError || isValidating) { + return; + } + + router.push(`/pools/${selectedNetwork.id}/${poolAddress}`); + }; + + return ( + + +

Distribution Pools

+

+ View and manage any Superfluid GDA distribution pool. +

+ + + {selectedNetwork.name} + + + {networks.map((network, i) => ( + { + if (!connectedChain && openConnectModal) { + openConnectModal(); + } else if (connectedChain?.id !== network.id) { + switchChain({ chainId: network.id }); + } + + setSelectedNetwork(network); + setValidationError(""); + }} + > + {network.name} + + ))} + + + + { + setPoolAddress(e.target.value); + setValidationError(""); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleSubmit(); + } + }} + /> + {poolAddress && !isValidAddress && ( + + Invalid address format + + )} + {validationError && isValidAddress && ( + + {validationError} + + )} + + +
+
+ ); +} diff --git a/src/lib/abi/gda.ts b/src/lib/abi/gda.ts new file mode 100644 index 00000000..b055e061 --- /dev/null +++ b/src/lib/abi/gda.ts @@ -0,0 +1,36 @@ +export const gdaAbi = [ + { + inputs: [ + { + internalType: "contract ISuperfluidPool", + name: "pool", + type: "address", + }, + { internalType: "address", name: "memberAddr", type: "address" }, + { internalType: "bytes", name: "ctx", type: "bytes" }, + ], + name: "tryConnectPoolFor", + outputs: [ + { internalType: "bool", name: "success", type: "bool" }, + { internalType: "bytes", name: "newCtx", type: "bytes" }, + ], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "contract ISuperfluidPool", + name: "pool", + type: "address", + }, + { internalType: "address", name: "memberAddress", type: "address" }, + { internalType: "uint128", name: "newUnits", type: "uint128" }, + { internalType: "bytes", name: "ctx", type: "bytes" }, + ], + name: "updateMemberUnits", + outputs: [{ internalType: "bytes", name: "newCtx", type: "bytes" }], + stateMutability: "nonpayable", + type: "function", + }, +] as const; diff --git a/src/lib/abi/superfluidHost.ts b/src/lib/abi/superfluidHost.ts new file mode 100644 index 00000000..1dcc8a9c --- /dev/null +++ b/src/lib/abi/superfluidHost.ts @@ -0,0 +1,35 @@ +export const superfluidHostAbi = [ + { + inputs: [ + { + internalType: "contract ISuperAgreement", + name: "agreementClass", + type: "address", + }, + { internalType: "bytes", name: "callData", type: "bytes" }, + { internalType: "bytes", name: "userData", type: "bytes" }, + ], + name: "callAgreement", + outputs: [{ internalType: "bytes", name: "returnedData", type: "bytes" }], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + components: [ + { internalType: "uint32", name: "operationType", type: "uint32" }, + { internalType: "address", name: "target", type: "address" }, + { internalType: "bytes", name: "data", type: "bytes" }, + ], + internalType: "struct ISuperfluid.Operation[]", + name: "operations", + type: "tuple[]", + }, + ], + name: "batchCall", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +] as const; diff --git a/src/lib/networks.ts b/src/lib/networks.ts index e4eaff76..a192b06d 100644 --- a/src/lib/networks.ts +++ b/src/lib/networks.ts @@ -26,6 +26,7 @@ const networks: Network[] = [ recipientSuperappFactory: "0x7C959499F285E8Ca70EfDC46afD15C36A58c087a", allo: "0x1133eA7Af70876e64665ecD07C0A0476d09465a1", alloRegistry: "0x4AAcca72145e1dF2aeC137E1f3C5E3D75DB8b5f3", + gda: "0x1e299701792a2aF01408B122419d65Fd2dF0Ba02", gdaForwarder: "0x6DA13Bde224A05a288748d857b9e7DDEffd1dE08", cfaForwarder: "0xcfA132E353cB4E398080B9700609bb008eceB125", superAppSplitterFactory: "0x", @@ -78,6 +79,7 @@ const networks: Network[] = [ recipientSuperappFactory: "0xf29933097dFC1456e8B3d934d89D90e6bbED76e5", allo: "0x1133eA7Af70876e64665ecD07C0A0476d09465a1", alloRegistry: "0x4AAcca72145e1dF2aeC137E1f3C5E3D75DB8b5f3", + gda: "0xfE6c87BE05feDB2059d2EC41bA0A09826C9FD7aa", gdaForwarder: "0x6DA13Bde224A05a288748d857b9e7DDEffd1dE08", cfaForwarder: "0xcfA132E353cB4E398080B9700609bb008eceB125", superAppSplitterFactory: "0x", @@ -135,6 +137,7 @@ const networks: Network[] = [ recipientSuperappFactory: "0x30093246dE28629d3840e0493c12bc5EE0041103", allo: "0x1133eA7Af70876e64665ecD07C0A0476d09465a1", alloRegistry: "0x4AAcca72145e1dF2aeC137E1f3C5E3D75DB8b5f3", + gda: "0x308b7405272d11494716e30C6E972DbF6fb89555", gdaForwarder: "0x6DA13Bde224A05a288748d857b9e7DDEffd1dE08", cfaForwarder: "0xcfA132E353cB4E398080B9700609bb008eceB125", superAppSplitterFactory: "0x6dEd495DEa4515d4303A01197B52A1B392E0FA80", @@ -177,6 +180,7 @@ const networks: Network[] = [ recipientSuperappFactory: "0xC0d7774AbdFBD9a30BcC1b53E1A6D90d5804d934", allo: "0x1133eA7Af70876e64665ecD07C0A0476d09465a1", alloRegistry: "0x4AAcca72145e1dF2aeC137E1f3C5E3D75DB8b5f3", + gda: "0x68Ae17fa7a31b86F306c383277552fd4813b0d35", gdaForwarder: "0x6DA13Bde224A05a288748d857b9e7DDEffd1dE08", cfaForwarder: "0xcfA132E353cB4E398080B9700609bb008eceB125", superAppSplitterFactory: "0x", @@ -235,6 +239,7 @@ const networks: Network[] = [ allo: "0x1133eA7Af70876e64665ecD07C0A0476d09465a1", alloRegistry: "0x4AAcca72145e1dF2aeC137E1f3C5E3D75DB8b5f3", cfaForwarder: "0xcfA132E353cB4E398080B9700609bb008eceB125", + gda: "0xd453d38A001B47271488886532f1CCeAbf0c7eF3", gdaForwarder: "0x6DA13Bde224A05a288748d857b9e7DDEffd1dE08", superAppSplitterFactory: "0x", feeRecipientPool: "0x", diff --git a/src/types/network.ts b/src/types/network.ts index 89b07e0a..f5f71300 100644 --- a/src/types/network.ts +++ b/src/types/network.ts @@ -24,6 +24,7 @@ export type Network = { tokens: Token[]; allo: Address; alloRegistry: Address; + gda: Address; gdaForwarder: Address; cfaForwarder: Address; superAppSplitterFactory: Address;