diff --git a/components/treasuryV2/Details/RealmAuthorityDetails/Config.tsx b/components/treasuryV2/Details/RealmAuthorityDetails/Config.tsx index 824127b6bb..ba7480a3f2 100644 --- a/components/treasuryV2/Details/RealmAuthorityDetails/Config.tsx +++ b/components/treasuryV2/Details/RealmAuthorityDetails/Config.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React from 'react' import cx from 'classnames' import { PencilIcon, @@ -10,8 +10,9 @@ import { OfficeBuildingIcon, } from '@heroicons/react/outline' import { BigNumber } from 'bignumber.js' +import { useRouter } from 'next/router' +import { MintMaxVoteWeightSourceType } from '@solana/spl-governance' -import RealmConfigModal from 'pages/dao/[symbol]/params/RealmConfigModal' import { RealmAuthority } from '@models/treasury/Asset' import useGovernanceAssets from '@hooks/useGovernanceAssets' import Tooltip from '@components/Tooltip' @@ -24,6 +25,7 @@ import useProgramVersion from '@hooks/useProgramVersion' import clsx from 'clsx' import TokenIcon from '@components/treasuryV2/icons/TokenIcon' import { NFTVotePluginSettingsDisplay } from '@components/NFTVotePluginSettingsDisplay' +import useQueryContext from '@hooks/useQueryContext' const DISABLED = new BigNumber(DISABLED_VOTER_WEIGHT.toString()) @@ -34,8 +36,9 @@ interface Props { export default function Config(props: Props) { const { canUseAuthorityInstruction } = useGovernanceAssets() - const { mint } = useRealm() - const [editRealmOpen, setEditRealmOpen] = useState(false) + const { mint, symbol } = useRealm() + const router = useRouter() + const { fmtUrlWithCluster } = useQueryContext() const programVersion = useProgramVersion() const councilRulesSupported = programVersion >= 3 @@ -65,7 +68,9 @@ export default function Config(props: Props) { 'disabled:opacity-50' )} disabled={!canUseAuthorityInstruction} - onClick={() => setEditRealmOpen(true)} + onClick={() => + router.push(fmtUrlWithCluster(`/realm/${symbol}/config/edit`)) + } >
Edit Rules
@@ -77,7 +82,16 @@ export default function Config(props: Props) {
} name="Community mint max vote weight source" - value={props.realmAuthority.config.communityMintMaxVoteWeightSource.fmtSupplyFractionPercentage()} + value={ + props.realmAuthority.config.communityMintMaxVoteWeightSource + .type === MintMaxVoteWeightSourceType.Absolute + ? formatNumber( + new BigNumber( + props.realmAuthority.config.communityMintMaxVoteWeightSource.value.toString() + ).shiftedBy(-(mint ? mint.decimals : 0)) + ) + : `${props.realmAuthority.config.communityMintMaxVoteWeightSource.fmtSupplyFractionPercentage()}%` + } /> )}
- {editRealmOpen && ( - setEditRealmOpen(false)} - /> - )} ) } diff --git a/hub/App.tsx b/hub/App.tsx index b3e730f218..e38711724b 100644 --- a/hub/App.tsx +++ b/hub/App.tsx @@ -58,7 +58,9 @@ interface Props { export function App(props: Props) { const router = useRouter(); - const isDarkMode = router.pathname.startsWith('/realm/[id]/governance'); + const isDarkMode = + router.pathname.startsWith('/realm/[id]/governance') || + router.pathname.startsWith('/realm/[id]/config'); useEffect(() => { if (isDarkMode) { diff --git a/hub/components/EditRealmConfig/AddressValidator/index.tsx b/hub/components/EditRealmConfig/AddressValidator/index.tsx new file mode 100644 index 0000000000..b0c048513f --- /dev/null +++ b/hub/components/EditRealmConfig/AddressValidator/index.tsx @@ -0,0 +1,167 @@ +import CheckmarkIcon from '@carbon/icons-react/lib/Checkmark'; +import ErrorIcon from '@carbon/icons-react/lib/Error'; +import WarningFilledIcon from '@carbon/icons-react/lib/WarningFilled'; +import { Program, AnchorProvider } from '@coral-xyz/anchor'; +import { PublicKey } from '@solana/web3.js'; +import React, { useEffect, useState } from 'react'; + +import { + vsrPluginsPks, + nftPluginsPks, + gatewayPluginsPks, + switchboardPluginsPks, + pythPluginsPks, +} from '@hooks/useVotingPlugins'; +import { Input } from '@hub/components/controls/Input'; +import { useCluster } from '@hub/hooks/useCluster'; +import { useWallet } from '@hub/hooks/useWallet'; + +const RECOGNIZED_PLUGINS = new Set([ + ...vsrPluginsPks, + ...nftPluginsPks, + ...gatewayPluginsPks, + ...switchboardPluginsPks, + ...pythPluginsPks, +]); + +interface Props { + className?: string; + value: PublicKey | null; + onChange?(value: PublicKey | null): void; +} + +export function AddressValidator(props: Props) { + const [address, setAddress] = useState(props.value?.toBase58() || ''); + const [isValidAddress, setIsValidAddress] = useState(true); + const [isRecognized, setIsRecognized] = useState(true); + const [isVerified, setIsVerified] = useState(true); + const [showMessages, setShowMessages] = useState(false); + const [cluster] = useCluster(); + const wallet = useWallet(); + + useEffect(() => { + const text = props.value?.toBase58() || ''; + + if (address !== text) { + setAddress(text); + } + }, [props.value]); + + return ( +
+ { + const text = e.currentTarget.value; + let key: PublicKey | null = null; + + try { + setIsValidAddress(true); + key = new PublicKey(text); + props.onChange?.(key); + } catch { + setIsValidAddress(false); + props.onChange?.(null); + return; + } + + if (RECOGNIZED_PLUGINS.has(text)) { + setIsRecognized(true); + } else { + setIsRecognized(false); + } + + try { + setIsVerified(false); + const publicKey = await wallet.connect(); + const provider = new AnchorProvider( + cluster.connection, + { + publicKey, + signAllTransactions: wallet.signAllTransactions, + signTransaction: wallet.signTransaction, + }, + AnchorProvider.defaultOptions(), + ); + const program = await Program.at(key, provider); + if (program) { + setIsVerified(true); + } else { + setIsVerified(false); + } + } catch { + setIsVerified(false); + } + + setShowMessages(true); + }} + onChange={(e) => { + const text = e.currentTarget.value; + setIsRecognized(false); + setIsVerified(false); + setShowMessages(false); + + try { + new PublicKey(text); + setIsValidAddress(true); + } catch { + setIsValidAddress(false); + } + + setAddress(text); + }} + onFocus={(e) => { + if (e.currentTarget.value !== props.value?.toBase58()) { + setShowMessages(false); + } + }} + /> + {showMessages && + address && + isValidAddress && + (isRecognized || isVerified) && ( +
+
+ {isRecognized && ( +
+ +
+ Realms recognizes this program ID +
+
+ )} + {isVerified && ( +
+ +
Anchor verified
+
+ )} +
+
+
+ )} + {showMessages && + address && + isValidAddress && + !(isRecognized || isVerified) && ( +
+ +
+ You are proposing an update to your DAO’s voting structure. Realms + can recognize that this as a program ID, but cannot verify it is + safe. Mistyping an address risks losing access to your DAO + forever. +
+
+ )} + {showMessages && address && !isValidAddress && ( +
+ +
Not a valid program ID
+
+ )} +
+ ); +} diff --git a/hub/components/EditRealmConfig/AdvancedOptions/index.tsx b/hub/components/EditRealmConfig/AdvancedOptions/index.tsx new file mode 100644 index 0000000000..9bdc2a9ad4 --- /dev/null +++ b/hub/components/EditRealmConfig/AdvancedOptions/index.tsx @@ -0,0 +1,236 @@ +import ScaleIcon from '@carbon/icons-react/lib/Scale'; +import { + RealmConfig, + MintMaxVoteWeightSourceType, + MintMaxVoteWeightSource, +} from '@solana/spl-governance'; +import { BigNumber } from 'bignumber.js'; +import BN from 'bn.js'; +import { produce } from 'immer'; +import { clamp } from 'ramda'; +import { useEffect } from 'react'; + +import { Config } from '../fetchConfig'; +import { ButtonToggle } from '@hub/components/controls/ButtonToggle'; +import { Input } from '@hub/components/controls/Input'; +import { SectionBlock } from '@hub/components/EditWalletRules/SectionBlock'; +import { SectionHeader } from '@hub/components/EditWalletRules/SectionHeader'; +import { ValueBlock } from '@hub/components/EditWalletRules/ValueBlock'; +import { formatNumber } from '@hub/lib/formatNumber'; +import { FormProps } from '@hub/types/FormProps'; + +interface Props + extends FormProps<{ + config: RealmConfig; + }> { + communityMint: Config['communityMint']; + currentConfig: RealmConfig; + className?: string; +} + +export function AdvancedOptions(props: Props) { + const isSupplyFraction = + props.config.communityMintMaxVoteWeightSource.type === + MintMaxVoteWeightSourceType.SupplyFraction; + + useEffect(() => { + const newConfig = produce({ ...props.config }, (data) => { + data.communityMintMaxVoteWeightSource = new MintMaxVoteWeightSource({ + type: data.communityMintMaxVoteWeightSource.type, + value: + props.config.communityMintMaxVoteWeightSource.type === + props.currentConfig.communityMintMaxVoteWeightSource.type + ? props.currentConfig.communityMintMaxVoteWeightSource.value + : new BN(0), + }); + }); + + props.onConfigChange?.(newConfig); + }, [isSupplyFraction]); + + return ( + + } + text="Maximum Voter Weight" + /> + + { + const newValue = value + ? MintMaxVoteWeightSourceType.SupplyFraction + : MintMaxVoteWeightSourceType.Absolute; + + const newConfig = produce({ ...props.config }, (data) => { + data.communityMintMaxVoteWeightSource = new MintMaxVoteWeightSource( + { + type: newValue, + value: data.communityMintMaxVoteWeightSource.value, + }, + ); + }); + + props.onConfigChange?.(newConfig); + }} + /> + + {props.config.communityMintMaxVoteWeightSource.type === + MintMaxVoteWeightSourceType.SupplyFraction && ( + +
+
+ { + const value = e.currentTarget.valueAsNumber; + + const newConfig = produce({ ...props.config }, (data) => { + const percent = + value && !Number.isNaN(value) ? clamp(0, 100, value) : 0; + + const newValue = new BigNumber( + MintMaxVoteWeightSource.SUPPLY_FRACTION_BASE.toString(), + ) + .multipliedBy(percent) + .div(100); + + data.communityMintMaxVoteWeightSource = new MintMaxVoteWeightSource( + { + type: data.communityMintMaxVoteWeightSource.type, + value: new BN(newValue.toString()), + }, + ); + }); + + props.onConfigChange?.(newConfig); + }} + /> +
+ % of Total Supply +
+
+
+ {props.communityMint.account.supply.toString() !== '0' ? ( +
+ + {props.config.communityMintMaxVoteWeightSource.isFullSupply() + ? formatNumber( + new BigNumber( + props.communityMint.account.supply.toString(), + ).shiftedBy(-props.communityMint.account.decimals), + ) + : formatNumber( + new BigNumber( + props.communityMint.account.supply.toString(), + ) + .shiftedBy(-props.communityMint.account.decimals) + .multipliedBy( + parseFloat( + props.config.communityMintMaxVoteWeightSource.fmtSupplyFractionPercentage(), + ), + ) + .dividedBy(100), + undefined, + { + maximumFractionDigits: 2, + }, + )} + {' '} + Community Tokens +
+ ) : ( +
+ Note: There are currently + no tokens in supply +
+ )} +
+
+
+ )} + {props.config.communityMintMaxVoteWeightSource.type === + MintMaxVoteWeightSourceType.Absolute && ( + +
+
+ { + const value = e.currentTarget.valueAsNumber; + + const newConfig = produce({ ...props.config }, (data) => { + const newValue = value && !Number.isNaN(value) ? value : 0; + + data.communityMintMaxVoteWeightSource = new MintMaxVoteWeightSource( + { + type: data.communityMintMaxVoteWeightSource.type, + value: new BN( + new BigNumber(newValue) + .shiftedBy(props.communityMint.account.decimals) + .toString(), + ), + }, + ); + }); + + props.onConfigChange?.(newConfig); + }} + /> +
+ # of Tokens +
+
+
+ {props.communityMint.account.supply.toString() !== '0' ? ( +
+ + {new BigNumber( + props.config.communityMintMaxVoteWeightSource.value.toString(), + ) + .dividedBy(props.communityMint.account.supply.toString()) + .multipliedBy(100) + .toFormat(2)} + % + {' '} + of Community Tokens +
+ ) : ( +
+ Note: There are currently + no tokens in supply +
+ )} +
+
+
+ )} +
+ ); +} diff --git a/hub/components/EditRealmConfig/CommunityStructure/index.tsx b/hub/components/EditRealmConfig/CommunityStructure/index.tsx new file mode 100644 index 0000000000..d1395f9328 --- /dev/null +++ b/hub/components/EditRealmConfig/CommunityStructure/index.tsx @@ -0,0 +1,257 @@ +import EventsIcon from '@carbon/icons-react/lib/Events'; +import WarningFilledIcon from '@carbon/icons-react/lib/WarningFilled'; +import { GoverningTokenType } from '@solana/spl-governance'; +import type { PublicKey } from '@solana/web3.js'; +import BigNumber from 'bignumber.js'; +import BN from 'bn.js'; +import { produce } from 'immer'; + +import { Config } from '../fetchConfig'; +import { TokenTypeSelector } from '../TokenTypeSelector'; +import { VotingStructureSelector } from '../VotingStructureSelector'; +import { ButtonToggle } from '@hub/components/controls/ButtonToggle'; +import { Input } from '@hub/components/controls/Input'; +import { MAX_NUM } from '@hub/components/EditWalletRules/constants'; +import { SectionBlock } from '@hub/components/EditWalletRules/SectionBlock'; +import { SectionHeader } from '@hub/components/EditWalletRules/SectionHeader'; +import { ValueBlock } from '@hub/components/EditWalletRules/ValueBlock'; +import { formatNumber } from '@hub/lib/formatNumber'; +import { FormProps } from '@hub/types/FormProps'; + +interface Props + extends FormProps<{ + config: Config['config']; + configAccount: Config['configAccount']; + nftCollection?: PublicKey; + nftCollectionSize: number; + nftCollectionWeight: BN; + }> { + currentConfigAccount: Config['configAccount']; + currentNftCollection?: PublicKey; + currentNftCollectionSize: number; + currentNftCollectionWeight: BN; + communityMint: Config['communityMint']; + className?: string; +} + +export function CommunityStructure(props: Props) { + const currentVotingStructure = { + votingProgramId: + props.currentConfigAccount.communityTokenConfig.voterWeightAddin, + maxVotingProgramId: + props.currentConfigAccount.communityTokenConfig.maxVoterWeightAddin, + nftCollection: props.currentNftCollection, + nftCollectionSize: props.currentNftCollectionSize, + nftCollectionWeight: props.currentNftCollectionWeight, + }; + + const votingStructure = { + votingProgramId: props.configAccount.communityTokenConfig.voterWeightAddin, + maxVotingProgramId: + props.configAccount.communityTokenConfig.maxVoterWeightAddin, + nftCollection: props.nftCollection, + nftCollectionSize: props.nftCollectionSize, + nftCollectionWeight: props.nftCollectionWeight, + }; + + const minTokensToManage = new BigNumber( + props.config.minCommunityTokensToCreateGovernance.toString(), + ).shiftedBy(-props.communityMint.account.decimals); + + const manageEnabled = minTokensToManage.isLessThan( + MAX_NUM.shiftedBy(-props.communityMint.account.decimals), + ); + + return ( + + } + text="Community Structure" + /> + +
+ { + const newConfigAccount = produce( + { ...props.configAccount }, + (data) => { + data.communityTokenConfig.tokenType = tokenType; + }, + ); + + props.onConfigAccountChange?.(newConfigAccount); + + if (tokenType === GoverningTokenType.Dormant) { + const newConfig = produce({ ...props.config }, (data) => { + data.minCommunityTokensToCreateGovernance = new BN( + MAX_NUM.toString(), + ); + }); + + setTimeout(() => { + props.onConfigChange?.(newConfig); + }, 0); + } + }} + /> +
+
+ {props.configAccount.communityTokenConfig.tokenType === + GoverningTokenType.Dormant && ( +
+ +
+ Disabling the community token will remove voting and managing + privileges for all community members. +
+
+ )} + {props.configAccount.communityTokenConfig.tokenType !== + GoverningTokenType.Dormant && ( + <> + + { + const newMinTokens = value + ? new BN(0) + : new BN(MAX_NUM.toString()); + + const newConfig = produce({ ...props.config }, (data) => { + data.minCommunityTokensToCreateGovernance = newMinTokens; + }); + + props.onConfigChange?.(newConfig); + }} + /> + + {manageEnabled && ( +
+ +
+ This will allow members to update information including name, + description, and other hub information. +
+
+ )} + {manageEnabled && ( + +
+ { + const text = e.currentTarget.value.replaceAll( + /[^\d.-]/g, + '', + ); + const value = text ? new BigNumber(text) : new BigNumber(0); + + const newConfig = produce({ ...props.config }, (data) => { + data.minCommunityTokensToCreateGovernance = new BN( + value + .shiftedBy(props.communityMint.account.decimals) + .toString(), + ); + }); + + props.onConfigChange?.(newConfig); + }} + /> +
+ Governance Power +
+
+
+ )} + + )} + {props.configAccount.communityTokenConfig.tokenType !== + GoverningTokenType.Dormant && ( + +
+ { + const newConfig = produce( + { ...props.configAccount }, + (data) => { + data.communityTokenConfig.maxVoterWeightAddin = maxVotingProgramId; + data.communityTokenConfig.voterWeightAddin = votingProgramId; + }, + ); + + props.onConfigAccountChange?.(newConfig); + + setTimeout(() => { + if ( + (!props.currentNftCollection && nftCollection) || + (props.currentNftCollection && nftCollection) || + (props.currentNftCollection && + nftCollection && + !props.currentNftCollection.equals(nftCollection)) + ) { + props.onNftCollectionChange?.(nftCollection); + } + + if ( + typeof nftCollectionSize !== 'undefined' && + props.nftCollectionSize !== nftCollectionSize + ) { + props.onNftCollectionSizeChange?.(nftCollectionSize); + } + + if ( + typeof nftCollectionWeight !== 'undefined' && + !props.nftCollectionWeight.eq(nftCollectionWeight) + ) { + props.onNftCollectionWeightChange?.(nftCollectionWeight); + } + }, 0); + }} + /> +
+
+ )} +
+ ); +} diff --git a/hub/components/EditRealmConfig/CouncilStructure/index.tsx b/hub/components/EditRealmConfig/CouncilStructure/index.tsx new file mode 100644 index 0000000000..47001bade7 --- /dev/null +++ b/hub/components/EditRealmConfig/CouncilStructure/index.tsx @@ -0,0 +1,105 @@ +import RuleIcon from '@carbon/icons-react/lib/Rule'; +import WarningFilledIcon from '@carbon/icons-react/lib/WarningFilled'; +import { RealmConfigAccount, GoverningTokenType } from '@solana/spl-governance'; +import { produce } from 'immer'; + +import { Config } from '../fetchConfig'; +import { TokenTypeSelector } from '../TokenTypeSelector'; +// import { VotingStructureSelector } from '../VotingStructureSelector'; +import { SectionBlock } from '@hub/components/EditWalletRules/SectionBlock'; +import { SectionHeader } from '@hub/components/EditWalletRules/SectionHeader'; +import { ValueBlock } from '@hub/components/EditWalletRules/ValueBlock'; +import { FormProps } from '@hub/types/FormProps'; + +interface Props + extends FormProps<{ + configAccount: RealmConfigAccount; + }> { + communityMint: Config['communityMint']; + currentConfigAccount: RealmConfigAccount; + className?: string; +} + +export function CouncilStructure(props: Props) { + // const currentVotingStructure = { + // votingProgramId: + // props.currentConfigAccount.councilTokenConfig.voterWeightAddin, + // maxVotingProgramId: + // props.currentConfigAccount.councilTokenConfig.maxVoterWeightAddin, + // }; + + // const votingStructure = { + // votingProgramId: props.configAccount.councilTokenConfig.voterWeightAddin, + // maxVotingProgramId: + // props.configAccount.councilTokenConfig.maxVoterWeightAddin, + // }; + + return ( + + } + text="Council Structure" + /> + +
+ { + const newConfigAccount = produce( + { ...props.configAccount }, + (data) => { + data.councilTokenConfig.tokenType = tokenType; + }, + ); + + props.onConfigAccountChange?.(newConfigAccount); + }} + /> +
+
+ {props.configAccount.councilTokenConfig.tokenType === + GoverningTokenType.Dormant && ( +
+ +
+ Disabling the council token will remove voting and managing + privileges for all council members. +
+
+ )} + {/* {props.configAccount.councilTokenConfig.tokenType !== + GoverningTokenType.Dormant && ( + +
+ { + const newConfig = produce( + { ...props.configAccount }, + (data) => { + data.councilTokenConfig.maxVoterWeightAddin = maxVotingProgramId; + data.councilTokenConfig.voterWeightAddin = votingProgramId; + }, + ); + + props.onConfigAccountChange?.(newConfig); + }} + /> +
+
+ )} */} +
+ ); +} diff --git a/hub/components/EditRealmConfig/Form/index.tsx b/hub/components/EditRealmConfig/Form/index.tsx new file mode 100644 index 0000000000..bd008aae9a --- /dev/null +++ b/hub/components/EditRealmConfig/Form/index.tsx @@ -0,0 +1,161 @@ +import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown'; +import WarningFilledIcon from '@carbon/icons-react/lib/WarningFilled'; +import type { PublicKey } from '@solana/web3.js'; +import { produce } from 'immer'; +import { TypeOf } from 'io-ts'; +import { useState } from 'react'; + +import { AdvancedOptions } from '../AdvancedOptions'; +import { CommunityStructure } from '../CommunityStructure'; +import { CouncilStructure } from '../CouncilStructure'; +import { Config } from '../fetchConfig'; +import { getGovernanceResp } from '../gql'; +import cx from '@hub/lib/cx'; +import { FormProps } from '@hub/types/FormProps'; + +type CouncilRules = TypeOf< + typeof getGovernanceResp +>['realmByUrlId']['governance']['councilTokenRules']; + +interface Props + extends FormProps<{ + config: Config; + }> { + className?: string; + councilRules: CouncilRules; + currentConfig: Config; + walletAddress: PublicKey; +} + +export function Form(props: Props) { + const [showAdvanceOptions, setShowAdvanceOptions] = useState(false); + + return ( +
+
+ You are changing the underlying structure of your organization. +
+
+ Updates to a realms’s config will create a proposal to be voted on on. + If approved, the updates will become ready for execution. +
+
+ +
+ Be careful editing your Realm’s configuration. All changes are + extremely consequential. +
+
+ { + const newConfig = produce(props.config, (data) => { + data.config = config; + }); + + props.onConfigChange?.(newConfig); + }} + onConfigAccountChange={(configAccount) => { + const newConfig = produce(props.config, (data) => { + data.configAccount = configAccount; + }); + + props.onConfigChange?.(newConfig); + }} + onNftCollectionChange={(nftCollection) => { + const newConfig = produce(props.config, (data) => { + data.nftCollection = nftCollection; + }); + + props.onConfigChange?.(newConfig); + }} + onNftCollectionSizeChange={(nftCollectionSize) => { + const newConfig = produce(props.config, (data) => { + data.nftCollectionSize = nftCollectionSize; + }); + + props.onConfigChange?.(newConfig); + }} + onNftCollectionWeightChange={(nftCollectionWeight) => { + const newConfig = produce(props.config, (data) => { + data.nftCollectionWeight = nftCollectionWeight; + }); + + props.onConfigChange?.(newConfig); + }} + /> + {!!props.councilRules && ( + { + const newConfig = produce(props.config, (data) => { + data.configAccount = configAccount; + }); + + props.onConfigChange?.(newConfig); + }} + /> + )} + {typeof props.config.configAccount.communityTokenConfig + .maxVoterWeightAddin === 'undefined' && ( +
+ + {showAdvanceOptions && ( + { + const newConfig = produce(props.config, (data) => { + data.config = config; + }); + + props.onConfigChange?.(newConfig); + }} + /> + )} +
+ )} +
+ ); +} diff --git a/hub/components/EditRealmConfig/ManageInformation/index.tsx b/hub/components/EditRealmConfig/ManageInformation/index.tsx new file mode 100644 index 0000000000..ee23caff69 --- /dev/null +++ b/hub/components/EditRealmConfig/ManageInformation/index.tsx @@ -0,0 +1,103 @@ +import EditIcon from '@carbon/icons-react/lib/Edit'; +import WarningFilledIcon from '@carbon/icons-react/lib/WarningFilled'; +import BigNumber from 'bignumber.js'; +import BN from 'bn.js'; +import { produce } from 'immer'; + +import { Config } from '../fetchConfig'; +import { ButtonToggle } from '@hub/components/controls/ButtonToggle'; +import { Input } from '@hub/components/controls/Input'; +import { MAX_NUM } from '@hub/components/EditWalletRules/constants'; +import { SectionBlock } from '@hub/components/EditWalletRules/SectionBlock'; +import { SectionHeader } from '@hub/components/EditWalletRules/SectionHeader'; +import { ValueBlock } from '@hub/components/EditWalletRules/ValueBlock'; +import { formatNumber } from '@hub/lib/formatNumber'; +import { FormProps } from '@hub/types/FormProps'; + +interface Props + extends FormProps<{ + config: Config['config']; + }> { + className?: string; + communityMint: Config['communityMint']; +} + +export function ManageInformation(props: Props) { + const minTokensToManage = new BigNumber( + props.config.minCommunityTokensToCreateGovernance.toString(), + ).shiftedBy(-props.communityMint.account.decimals); + + const manageEnabled = minTokensToManage.isLessThan( + MAX_NUM.shiftedBy(-props.communityMint.account.decimals), + ); + + return ( + + } + text="Manage Information" + /> + + { + const newMinTokens = value ? new BN(0) : new BN(MAX_NUM.toString()); + + const newConfig = produce({ ...props.config }, (data) => { + data.minCommunityTokensToCreateGovernance = newMinTokens; + }); + + props.onConfigChange?.(newConfig); + }} + /> + + {manageEnabled && ( +
+ +
+ This will allow members to update information including name, + description, and other hub information. +
+
+ )} + {manageEnabled && ( + +
+ { + const text = e.currentTarget.value.replaceAll(/[^\d.-]/g, ''); + const value = text ? new BigNumber(text) : new BigNumber(0); + + const newConfig = produce({ ...props.config }, (data) => { + data.minCommunityTokensToCreateGovernance = new BN( + value + .shiftedBy(props.communityMint.account.decimals) + .toString(), + ); + }); + + props.onConfigChange?.(newConfig); + }} + /> +
+ Governance Power +
+
+
+ )} +
+ ); +} diff --git a/hub/components/EditRealmConfig/NFTValidator/index.tsx b/hub/components/EditRealmConfig/NFTValidator/index.tsx new file mode 100644 index 0000000000..6284aa574d --- /dev/null +++ b/hub/components/EditRealmConfig/NFTValidator/index.tsx @@ -0,0 +1,148 @@ +import ErrorIcon from '@carbon/icons-react/lib/Error'; +import WarningFilledIcon from '@carbon/icons-react/lib/WarningFilled'; +import { Metaplex } from '@metaplex-foundation/js'; +import { PublicKey } from '@solana/web3.js'; +import { useEffect, useState } from 'react'; + +import { Input } from '@hub/components/controls/Input'; +import { useCluster } from '@hub/hooks/useCluster'; + +interface NFTCollection { + description?: string; + img?: string; + name: string; + symbol?: string; +} + +interface Props { + className?: string; + disabled?: boolean; + value: PublicKey | null; + onChange?(value: PublicKey | null): void; +} + +export function NFTValidator(props: Props) { + const [address, setAddress] = useState(props.value?.toBase58() || ''); + const [isValid, setIsValid] = useState(true); + const [cluster] = useCluster(); + const [collectionInfo, setCollectionInfo] = useState( + null, + ); + const [isValidCollection, setIsValidCollection] = useState(true); + const [showPreview, setShowPreview] = useState(true); + + useEffect(() => { + const text = props.value?.toBase58() || ''; + + if (address !== text) { + setAddress(text); + } + + if (props.value) { + setCollectionInfo(null); + setIsValidCollection(true); + const metaplex = new Metaplex(cluster.connection); + + metaplex + .nfts() + .findByMint({ mintAddress: props.value }) + .then((info) => { + const name = info.name; + const img = info.json?.image; + const symbol = info.json?.symbol; + const description = info.json?.description; + setCollectionInfo({ name, img, symbol, description }); + }) + .catch((e) => { + console.error(e); + setIsValidCollection(false); + setCollectionInfo(null); + }); + } else { + setIsValidCollection(true); + setCollectionInfo(null); + } + }, [props.value, cluster]); + + return ( +
+ { + const text = e.currentTarget.value; + + try { + const pk = new PublicKey(text); + setIsValid(true); + setShowPreview(true); + props.onChange?.(pk); + } catch { + setIsValid(false); + setShowPreview(true); + props.onChange?.(null); + } + }} + onChange={(e) => { + const text = e.currentTarget.value; + setShowPreview(false); + + try { + new PublicKey(text); + setIsValid(true); + } catch { + setIsValid(false); + } + + setAddress(text); + }} + /> + {showPreview && address && isValid && collectionInfo && ( +
+ {collectionInfo.img && ( + + )} +
+
+ {collectionInfo.name} +
+ {collectionInfo.description && ( +
+ {collectionInfo.description} +
+ )} + {collectionInfo.symbol && ( +
+ Symbol:{' '} + {collectionInfo.description} +
+ )} +
+
+ )} + {showPreview && address && isValid && !isValidCollection && ( +
+ +
+ You are proposing an update to your DAO’s voting structure. Realms + can recognize that this as a valid address, but cannot verify the + collection it belongs to. +
+
+ )} + {showPreview && address && !isValid && ( +
+ +
+ Not a valid collection address +
+
+ )} +
+ ); +} diff --git a/hub/components/EditRealmConfig/RealmHeader/index.tsx b/hub/components/EditRealmConfig/RealmHeader/index.tsx new file mode 100644 index 0000000000..70d0469bb3 --- /dev/null +++ b/hub/components/EditRealmConfig/RealmHeader/index.tsx @@ -0,0 +1,30 @@ +import cx from '@hub/lib/cx'; + +interface Props { + className?: string; + realmIconUrl?: null | string; + realmName: string; +} + +export function RealmHeader(props: Props) { + return ( +
+
+ {props.realmIconUrl && ( + + )} +
{props.realmName}
+
+
+ ); +} diff --git a/hub/components/EditRealmConfig/Summary/index.tsx b/hub/components/EditRealmConfig/Summary/index.tsx new file mode 100644 index 0000000000..adbc26eefa --- /dev/null +++ b/hub/components/EditRealmConfig/Summary/index.tsx @@ -0,0 +1,74 @@ +import BotIcon from '@carbon/icons-react/lib/Bot'; +import type { PublicKey } from '@solana/web3.js'; +import { TypeOf } from 'io-ts'; + +import { Config } from '../fetchConfig'; +import * as gql from '../gql'; +import { UpdatesList } from '../UpdatesList'; +import { ProposalDetails } from '@hub/components/EditWalletRules/ProposalDetails'; +import { ProposalVoteType } from '@hub/components/EditWalletRules/ProposalVoteType'; +import { FormProps } from '@hub/types/FormProps'; + +type Governance = TypeOf< + typeof gql.getGovernanceResp +>['realmByUrlId']['governance']; + +interface Props + extends FormProps<{ + proposalDescription: string; + proposalTitle: string; + proposalVoteType: 'council' | 'community'; + }> { + className?: string; + config: Config; + currentConfig: Config; + governance: Governance; + walletAddress: PublicKey; +} + +export function Summary(props: Props) { + return ( +
+
+ Your proposal is almost ready. Does everything look correct? +
+
+ Before submitting, ensure your description is correct and rules updates + are accurate. +
+ + +
+
+ Proposed Rules Updates +
+
+ +
This section is automatically generated
+
+
+ +
+ ); +} diff --git a/hub/components/EditRealmConfig/TokenTypeSelector/index.tsx b/hub/components/EditRealmConfig/TokenTypeSelector/index.tsx new file mode 100644 index 0000000000..d25b35bc95 --- /dev/null +++ b/hub/components/EditRealmConfig/TokenTypeSelector/index.tsx @@ -0,0 +1,123 @@ +import CheckmarkIcon from '@carbon/icons-react/lib/Checkmark'; +import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { GoverningTokenType } from '@solana/spl-governance'; +import { useEffect, useRef, useState } from 'react'; + +import cx from '@hub/lib/cx'; + +export function getLabel(value: GoverningTokenType): string { + switch (value) { + case GoverningTokenType.Dormant: + return 'Disabled'; + case GoverningTokenType.Liquid: + return 'Liquid'; + case GoverningTokenType.Membership: + return 'Membership'; + } +} + +function getDescription(value: GoverningTokenType): string { + switch (value) { + case GoverningTokenType.Dormant: + return 'This removes voting & managing power for token owners'; + case GoverningTokenType.Liquid: + return 'May be bought, sold, or transferred'; + case GoverningTokenType.Membership: + return 'Cannot be traded or transferred, but can be revoked by the DAO'; + } +} + +const itemStyles = cx( + 'border', + 'cursor-pointer', + 'gap-x-4', + 'grid-cols-[100px,1fr,20px]', + 'grid', + 'h-14', + 'items-center', + 'px-4', + 'rounded-md', + 'text-left', + 'transition-colors', + 'dark:bg-neutral-800', + 'dark:border-neutral-700', + 'dark:hover:bg-neutral-700', +); + +const labelStyles = cx('font-700', 'dark:text-neutral-50'); +const descriptionStyles = cx('dark:text-neutral-400'); +const iconStyles = cx('fill-neutral-500', 'h-5', 'transition-transform', 'w-4'); + +interface Props { + className?: string; + value: GoverningTokenType; + onChange?(value: GoverningTokenType): void; +} + +export function TokenTypeSelector(props: Props) { + const [open, setOpen] = useState(false); + const [width, setWidth] = useState(0); + const trigger = useRef(null); + + useEffect(() => { + if (trigger.current) { + setWidth(trigger.current.clientWidth); + } else { + setWidth(0); + } + }, [trigger, open]); + + return ( + +
+ +
{getLabel(props.value)}
+
{getDescription(props.value)}
+ +
+ + + {[ + GoverningTokenType.Dormant, + GoverningTokenType.Liquid, + GoverningTokenType.Membership, + ] + .filter((voteTippingType) => voteTippingType !== props.value) + .map((voteTippingType) => ( + props.onChange?.(voteTippingType)} + > +
{getLabel(voteTippingType)}
+
+ {getDescription(voteTippingType)} +
+ {voteTippingType === props.value && ( + + )} +
+ ))} +
+
+
+
+ ); +} diff --git a/hub/components/EditRealmConfig/UpdatesList/index.tsx b/hub/components/EditRealmConfig/UpdatesList/index.tsx new file mode 100644 index 0000000000..489e4eb219 --- /dev/null +++ b/hub/components/EditRealmConfig/UpdatesList/index.tsx @@ -0,0 +1,626 @@ +import EditIcon from '@carbon/icons-react/lib/Edit'; +import EventsIcon from '@carbon/icons-react/lib/Events'; +import RuleIcon from '@carbon/icons-react/lib/Rule'; +import ScaleIcon from '@carbon/icons-react/lib/Scale'; +import { + MintMaxVoteWeightSourceType, + MintMaxVoteWeightSource, +} from '@solana/spl-governance'; +import { PublicKey } from '@solana/web3.js'; +import { BigNumber } from 'bignumber.js'; +import BN from 'bn.js'; + +import { Config } from '../fetchConfig'; +import { getLabel } from '../TokenTypeSelector'; +import { + DEFAULT_NFT_CONFIG, + DEFAULT_VSR_CONFIG, + DEFAULT_CIVIC_CONFIG, +} from '../VotingStructureSelector'; +import { SectionBlock } from '@hub/components/EditWalletRules/SectionBlock'; +import { SectionHeader } from '@hub/components/EditWalletRules/SectionHeader'; +import { SummaryItem } from '@hub/components/EditWalletRules/SummaryItem'; +import { abbreviateAddress } from '@hub/lib/abbreviateAddress'; +import cx from '@hub/lib/cx'; +import { formatNumber } from '@hub/lib/formatNumber'; + +const MAX_NUM = new BN('18446744073709551615'); + +export function buildUpdates(config: Config) { + return { + minCommunityTokensToCreateGovernance: + config.config.minCommunityTokensToCreateGovernance, + communityTokenType: config.configAccount.communityTokenConfig.tokenType, + councilTokenType: config.configAccount.councilTokenConfig.tokenType, + communityVotingPlugin: + config.configAccount.communityTokenConfig.voterWeightAddin, + communityMaxVotingPlugin: + config.configAccount.communityTokenConfig.maxVoterWeightAddin, + councilVotingPlugin: + config.configAccount.councilTokenConfig.voterWeightAddin, + councilMaxVotingPlugin: + config.configAccount.councilTokenConfig.maxVoterWeightAddin, + maxVoterWeightType: config.config.communityMintMaxVoteWeightSource.type, + maxVoterWeightValue: config.config.communityMintMaxVoteWeightSource.value, + nftCollection: config.nftCollection, + nftCollectionSize: config.nftCollectionSize, + nftCollectionWeight: config.nftCollectionWeight, + }; +} + +export function diff( + existing: T, + changed: T, +) { + const diffs = {} as { + [K in keyof T]: [T[K], T[K]]; + }; + + for (const key of Object.keys(existing) as (keyof T)[]) { + const existingValue = existing[key]; + const changedValue = changed[key]; + + if ( + existingValue instanceof PublicKey && + changedValue instanceof PublicKey + ) { + if (!existingValue.equals(changedValue)) { + diffs[key] = [existingValue, changedValue]; + } + } else if (BN.isBN(existingValue) && BN.isBN(changedValue)) { + if (!existingValue.eq(changedValue)) { + diffs[key] = [existingValue, changedValue]; + } + } else { + if (existingValue !== changedValue) { + diffs[key] = [existingValue, changedValue]; + } + } + } + + return diffs; +} + +function votingStructureText( + votingPluginDiff: [PublicKey | undefined, PublicKey | undefined], + maxVotingPluginDiff: [PublicKey | undefined, PublicKey | undefined], +) { + let newText = 'Default'; + let existingText = 'Default'; + + if ( + votingPluginDiff[0]?.equals(DEFAULT_NFT_CONFIG.votingProgramId) && + maxVotingPluginDiff[0]?.equals(DEFAULT_NFT_CONFIG.maxVotingProgramId) + ) { + existingText = 'NFT'; + } else if ( + votingPluginDiff[0]?.equals(DEFAULT_VSR_CONFIG.votingProgramId) && + typeof maxVotingPluginDiff[0] === 'undefined' + ) { + existingText = 'VSR'; + } else if ( + votingPluginDiff[0]?.equals(DEFAULT_CIVIC_CONFIG.votingProgramId) && + typeof maxVotingPluginDiff[0] === 'undefined' + ) { + existingText = 'Civic'; + } else if (votingPluginDiff[0] || maxVotingPluginDiff[0]) { + existingText = 'Custom'; + } + + if ( + votingPluginDiff[1]?.equals(DEFAULT_NFT_CONFIG.votingProgramId) && + maxVotingPluginDiff[1]?.equals(DEFAULT_NFT_CONFIG.maxVotingProgramId) + ) { + newText = 'NFT'; + } else if ( + votingPluginDiff[1]?.equals(DEFAULT_VSR_CONFIG.votingProgramId) && + typeof maxVotingPluginDiff[1] === 'undefined' + ) { + newText = 'VSR'; + } else if ( + votingPluginDiff[1]?.equals(DEFAULT_CIVIC_CONFIG.votingProgramId) && + typeof maxVotingPluginDiff[1] === 'undefined' + ) { + newText = 'Civic'; + } else if (votingPluginDiff[1] || maxVotingPluginDiff[1]) { + newText = 'Custom'; + } + + return [existingText, newText]; +} + +function voterWeightLabel(voterWeightType: MintMaxVoteWeightSourceType) { + switch (voterWeightType) { + case MintMaxVoteWeightSourceType.Absolute: + return 'Absolute'; + case MintMaxVoteWeightSourceType.SupplyFraction: + return 'Supply Fraction'; + } +} + +interface Props { + className?: string; + config: Config; + currentConfig: Config; +} + +export function UpdatesList(props: Props) { + const updates = diff( + buildUpdates(props.currentConfig), + buildUpdates(props.config), + ); + + const hasCommunityUpdates = + 'communityTokenType' in updates || + 'communityVotingPlugin' in updates || + 'communityMaxVotingPlugin' in updates || + 'nftCollection' in updates || + 'nftCollectionSize' in updates || + 'nftCollectionWeight' in updates; + + const hasCouncilUpdates = + 'councilTokenType' in updates || + 'councilVotingPlugin' in updates || + 'councilMaxVotingPlugin' in updates; + + if (Object.keys(updates).length === 0) { + return ( + +
+ There are no proposed changes +
+
+ ); + } + + return ( + + {'minCommunityTokensToCreateGovernance' in updates && ( +
+ } + text="Manage Information" + /> +
+ {updates.minCommunityTokensToCreateGovernance[0].eq(MAX_NUM) && + !updates.minCommunityTokensToCreateGovernance[1].eq(MAX_NUM) && ( + +
Yes
+
+ No +
+
+ } + /> + )} + {!updates.minCommunityTokensToCreateGovernance[0].eq(MAX_NUM) && + updates.minCommunityTokensToCreateGovernance[1].eq(MAX_NUM) && ( + +
No
+
+ Yes +
+
+ } + /> + )} + {!updates.minCommunityTokensToCreateGovernance[1].eq(MAX_NUM) && ( + +
+ {formatNumber( + new BigNumber( + updates.minCommunityTokensToCreateGovernance[1].toString(), + ).shiftedBy( + -props.config.communityMint.account.decimals, + ), + undefined, + { + maximumFractionDigits: 2, + }, + )} +
+
+ {updates.minCommunityTokensToCreateGovernance[0].eq( + MAX_NUM, + ) + ? 'Disabled' + : formatNumber( + new BigNumber( + updates.minCommunityTokensToCreateGovernance[0].toString(), + ).shiftedBy( + -props.config.communityMint.account.decimals, + ), + undefined, + { + maximumFractionDigits: 2, + }, + )} +
+ + } + /> + )} + + + )} + {hasCommunityUpdates && ( +
+ } + text="Community Structure" + /> +
+ {'communityTokenType' in updates && ( + +
{getLabel(updates.communityTokenType[1])}
+
+ {getLabel(updates.communityTokenType[0])} +
+
+ } + /> + )} + {('communityVotingPlugin' in updates || + 'communityMaxVotingPlugin' in updates) && + !( + votingStructureText( + updates.communityVotingPlugin || [], + updates.communityMaxVotingPlugin || [], + ).join(',') === 'Custom,Custom' + ) && ( + +
+ { + votingStructureText( + updates.communityVotingPlugin || [], + updates.communityMaxVotingPlugin || [], + )[1] + } +
+
+ { + votingStructureText( + updates.communityVotingPlugin || [], + updates.communityMaxVotingPlugin || [], + )[0] + } +
+
+ } + /> + )} + {('communityVotingPlugin' in updates || + 'communityMaxVotingPlugin' in updates) && + votingStructureText( + updates.communityVotingPlugin || [], + updates.communityMaxVotingPlugin || [], + ).join(',') === 'Custom,Custom' && ( + <> + {'communityVotingPlugin' in updates && ( + +
+ {updates.communityVotingPlugin[1] + ? abbreviateAddress( + updates.communityVotingPlugin[1], + ) + : 'No Plugin'} +
+
+ {updates.communityVotingPlugin[0] + ? abbreviateAddress( + updates.communityVotingPlugin[0], + ) + : 'No Plugin'} +
+ + } + /> + )} + {'communityMaxVotingPlugin' in updates && ( + +
+ {updates.communityMaxVotingPlugin[1] + ? abbreviateAddress( + updates.communityMaxVotingPlugin[1], + ) + : 'No Plugin'} +
+
+ {updates.communityMaxVotingPlugin[0] + ? abbreviateAddress( + updates.communityMaxVotingPlugin[0], + ) + : 'No Plugin'} +
+ + } + /> + )} + + )} + {'nftCollection' in updates && ( + +
+ {updates.nftCollection[1] + ? abbreviateAddress(updates.nftCollection[1]) + : 'No Collection'} +
+
+ {updates.nftCollection[0] + ? abbreviateAddress(updates.nftCollection[0]) + : 'No Collection'} +
+ + } + /> + )} + {'nftCollectionSize' in updates && ( + +
{updates.nftCollectionSize[1]}
+
+ {updates.nftCollectionSize[0]} +
+ + } + /> + )} + {'nftCollectionWeight' in updates && ( + +
+ {new BigNumber(updates.nftCollectionWeight[1].toString()) + .shiftedBy(-props.config.communityMint.account.decimals) + .toFormat()} +
+
+ {new BigNumber(updates.nftCollectionWeight[0].toString()) + .shiftedBy(-props.config.communityMint.account.decimals) + .toFormat()} +
+ + } + /> + )} + + + )} + {hasCouncilUpdates && ( +
+ } + text="Council Structure" + /> +
+ {'councilTokenType' in updates && ( + +
{getLabel(updates.councilTokenType[1])}
+
+ {getLabel(updates.councilTokenType[0])} +
+
+ } + /> + )} + {('councilVotingPlugin' in updates || + 'councilMaxVotingPlugin' in updates) && + !( + votingStructureText( + updates.councilVotingPlugin, + updates.councilMaxVotingPlugin, + ).join(',') === 'Custom,Custom' + ) && ( + +
+ { + votingStructureText( + updates.councilVotingPlugin, + updates.councilMaxVotingPlugin, + )[1] + } +
+
+ { + votingStructureText( + updates.councilVotingPlugin, + updates.councilMaxVotingPlugin, + )[0] + } +
+
+ } + /> + )} + {('councilVotingPlugin' in updates || + 'councilMaxVotingPlugin' in updates) && + votingStructureText( + updates.councilVotingPlugin || [], + updates.councilMaxVotingPlugin || [], + ).join(',') === 'Custom,Custom' && ( + <> + {'councilVotingPlugin' in updates && ( + +
+ {updates.councilVotingPlugin[1] + ? abbreviateAddress( + updates.councilVotingPlugin[1], + ) + : 'No Plugin'} +
+
+ {updates.councilVotingPlugin[0] + ? abbreviateAddress( + updates.councilVotingPlugin[0], + ) + : 'No Plugin'} +
+ + } + /> + )} + {'councilMaxVotingPlugin' in updates && ( + +
+ {updates.councilMaxVotingPlugin[1] + ? abbreviateAddress( + updates.councilMaxVotingPlugin[1], + ) + : 'No Plugin'} +
+
+ {updates.councilMaxVotingPlugin[0] + ? abbreviateAddress( + updates.councilMaxVotingPlugin[0], + ) + : 'No Plugin'} +
+ + } + /> + )} + + )} + + + )} + {('maxVoterWeightType' in updates || + 'maxVoterWeightValue' in updates) && ( +
+ } + text="Advanced Options" + /> +
+ {'maxVoterWeightType' in updates && ( + +
{voterWeightLabel(updates.maxVoterWeightType[1])}
+
+ {voterWeightLabel(updates.maxVoterWeightType[0])} +
+
+ } + /> + )} + {'maxVoterWeightValue' in updates && ( + +
+ {formatNumber( + props.config.config.communityMintMaxVoteWeightSource + .type === MintMaxVoteWeightSourceType.SupplyFraction + ? new BigNumber( + props.config.config.communityMintMaxVoteWeightSource + .getSupplyFraction() + .toString(), + ) + .shiftedBy( + -MintMaxVoteWeightSource.SUPPLY_FRACTION_DECIMALS, + ) + .multipliedBy(100) + : new BigNumber( + updates.maxVoterWeightValue[1].toString(), + ).shiftedBy( + -props.config.communityMint.account.decimals, + ), + undefined, + { + maximumFractionDigits: 2, + minimumFractionDigits: 0, + }, + )} + {props.config.config.communityMintMaxVoteWeightSource + .type === MintMaxVoteWeightSourceType.SupplyFraction && + '%'} +
+
+ {formatNumber( + props.currentConfig.config + .communityMintMaxVoteWeightSource.type === + MintMaxVoteWeightSourceType.SupplyFraction + ? new BigNumber( + props.currentConfig.config.communityMintMaxVoteWeightSource + .getSupplyFraction() + .toString(), + ) + .shiftedBy( + -MintMaxVoteWeightSource.SUPPLY_FRACTION_DECIMALS, + ) + .multipliedBy(100) + : new BigNumber( + updates.maxVoterWeightValue[0].toString(), + ).shiftedBy( + -props.currentConfig.communityMint.account + .decimals, + ), + )} + {props.currentConfig.config + .communityMintMaxVoteWeightSource.type === + MintMaxVoteWeightSourceType.SupplyFraction && '%'} +
+
+ } + /> + )} + + + )} +
+ ); +} diff --git a/hub/components/EditRealmConfig/VotingStructureSelector/Custom.tsx b/hub/components/EditRealmConfig/VotingStructureSelector/Custom.tsx new file mode 100644 index 0000000000..21b3d56690 --- /dev/null +++ b/hub/components/EditRealmConfig/VotingStructureSelector/Custom.tsx @@ -0,0 +1,87 @@ +import WarningFilledIcon from '@carbon/icons-react/lib/WarningFilled'; +import type { PublicKey } from '@solana/web3.js'; + +import { AddressValidator } from '../AddressValidator'; +import cx from '@hub/lib/cx'; +import { FormProps } from '@hub/types/FormProps'; + +interface Props + extends FormProps<{ + votingProgramId?: null | PublicKey; + maxVotingProgramId?: null | PublicKey; + }> { + className?: string; +} + +export function Custom(props: Props) { + return ( +
+
+
+
+ +
+ Realms does not have a list of voting program IDs. Be sure to input + a valid address in the following fields. +
+
+
+
+
+
+
+ What is your custom voting program ID? +
+
+
+ +
+
+
+
+
+
+
+ What is your custom max voting program ID? +
+
+
+ +
+
+
+
+ ); +} diff --git a/hub/components/EditRealmConfig/VotingStructureSelector/NFT.tsx b/hub/components/EditRealmConfig/VotingStructureSelector/NFT.tsx new file mode 100644 index 0000000000..c778cfeec7 --- /dev/null +++ b/hub/components/EditRealmConfig/VotingStructureSelector/NFT.tsx @@ -0,0 +1,152 @@ +import WarningFilledIcon from '@carbon/icons-react/lib/WarningFilled'; +import type { PublicKey } from '@solana/web3.js'; +import { BigNumber } from 'bignumber.js'; +import BN from 'bn.js'; + +import { Config } from '../fetchConfig'; +import { NFTValidator } from '../NFTValidator'; +import { Input } from '@hub/components/controls/Input'; +import cx from '@hub/lib/cx'; + +interface Props { + className?: string; + currentNftCollection?: PublicKey; + nftCollection?: PublicKey; + nftCollectionSize: number; + nftCollectionWeight: BN; + communityMint: Config['communityMint']; + onCollectionChange?(value: PublicKey | null): void; + onCollectionSizeChange?(value: number): void; + onCollectionWeightChange?(value: BN): void; +} + +export function NFT(props: Props) { + return ( +
+
+
+
+ {!!props.currentNftCollection && ( +
+ +
+ You cannot edit an existing NFT governance structure from this + screen. If you want to change the way your nft governance is + structured, please use the "Create Proposal" screen. +
+
+ )} +
+ What is the NFT Collection's address? +
+
+
+ +
+
+
+
+
+
+
+ How many NFTs are in the collection? +
+
+
+ { + const value = e.currentTarget.valueAsNumber; + + if (Number.isNaN(value)) { + props.onCollectionSizeChange?.(0); + } else { + props.onCollectionSizeChange?.(value); + } + }} + /> +
+
+
+
+
+
+
+ How many votes should each NFT count as? +
+
+
+ { + const value = e.currentTarget.valueAsNumber; + + if (Number.isNaN(value)) { + props.onCollectionWeightChange?.(new BN(0)); + } else { + const weight = new BN( + new BigNumber(value) + .shiftedBy(props.communityMint.account.decimals) + .toString(), + ); + props.onCollectionWeightChange?.(weight); + } + }} + /> +
+
+
+
+ ); +} diff --git a/hub/components/EditRealmConfig/VotingStructureSelector/index.tsx b/hub/components/EditRealmConfig/VotingStructureSelector/index.tsx new file mode 100644 index 0000000000..4bf6bde1bb --- /dev/null +++ b/hub/components/EditRealmConfig/VotingStructureSelector/index.tsx @@ -0,0 +1,319 @@ +import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; +import { produce } from 'immer'; +import { useEffect, useRef, useState } from 'react'; + +import { Config } from '../fetchConfig'; +import cx from '@hub/lib/cx'; + +import { Custom } from './Custom'; +import { NFT } from './NFT'; + +export const DEFAULT_NFT_CONFIG = { + votingProgramId: new PublicKey( + 'GnftV5kLjd67tvHpNGyodwWveEKivz3ZWvvE3Z4xi2iw', + ), + maxVotingProgramId: new PublicKey( + 'GnftV5kLjd67tvHpNGyodwWveEKivz3ZWvvE3Z4xi2iw', + ), +}; + +export const DEFAULT_VSR_CONFIG = { + votingProgramId: new PublicKey('vsr2nfGVNHmSY8uxoBGqq8AQbwz3JwaEaHqGbsTPXqQ'), + maxVotingProgramId: undefined, +}; + +export const DEFAULT_CIVIC_CONFIG = { + votingProgramId: new PublicKey( + 'GgathUhdrCWRHowoRKACjgWhYHfxCEdBi5ViqYN6HVxk', + ), + maxVotingProgramId: undefined, +}; + +const itemStyles = cx( + 'border', + 'cursor-pointer', + 'gap-x-4', + 'grid-cols-[100px,1fr,20px]', + 'grid', + 'h-14', + 'items-center', + 'px-4', + 'rounded-md', + 'text-left', + 'transition-colors', + 'dark:bg-neutral-800', + 'dark:border-neutral-700', + 'dark:hover:bg-neutral-700', +); + +const labelStyles = cx('font-700', 'dark:text-neutral-50'); +const descriptionStyles = cx('dark:text-neutral-400'); +const iconStyles = cx('fill-neutral-500', 'h-5', 'transition-transform', 'w-4'); + +interface Props { + allowNFT?: boolean; + allowCivic?: boolean; + allowVSR?: boolean; + className?: string; + communityMint: Config['communityMint']; + currentStructure: { + votingProgramId?: PublicKey; + maxVotingProgramId?: PublicKey; + nftCollection?: PublicKey; + nftCollectionSize?: number; + nftCollectionWeight?: BN; + }; + structure: { + votingProgramId?: PublicKey; + maxVotingProgramId?: PublicKey; + nftCollection?: PublicKey; + nftCollectionSize?: number; + nftCollectionWeight?: BN; + }; + onChange?(value: { + votingProgramId?: PublicKey; + maxVotingProgramId?: PublicKey; + nftCollection?: PublicKey; + nftCollectionSize?: number; + nftCollectionWeight?: BN; + }): void; +} + +function areConfigsEqual(a: Props['structure'], b: Props['structure']) { + if ( + (a.maxVotingProgramId && !b.maxVotingProgramId) || + (!a.maxVotingProgramId && b.maxVotingProgramId) + ) { + return false; + } + + if ( + a.maxVotingProgramId && + b.maxVotingProgramId && + !a.maxVotingProgramId.equals(b.maxVotingProgramId) + ) { + return false; + } + + if ( + (a.votingProgramId && !b.votingProgramId) || + (!a.votingProgramId && b.votingProgramId) + ) { + return false; + } + + if ( + a.votingProgramId && + b.votingProgramId && + !a.votingProgramId.equals(b.votingProgramId) + ) { + return false; + } + + return true; +} + +function isNFTConfig(config: Props['structure']) { + return areConfigsEqual(config, DEFAULT_NFT_CONFIG); +} + +function isVSRConfig(config: Props['structure']) { + return areConfigsEqual(config, DEFAULT_VSR_CONFIG); +} + +function isCivicConfig(config: Props['structure']) { + return areConfigsEqual(config, DEFAULT_CIVIC_CONFIG); +} + +function isCustomConfig(config: Props['structure']) { + return !isNFTConfig(config) && !isVSRConfig(config) && !isCivicConfig(config); +} + +export function getLabel(value: Props['structure']): string { + if (isNFTConfig(value)) { + return 'NFT'; + } + + if (isVSRConfig(value)) { + return 'VSR'; + } + + if (isCivicConfig(value)) { + return 'Civic'; + } + + return 'Custom'; +} + +function getDescription(value: Props['structure']): string { + if (isNFTConfig(value)) { + return 'Voting enabled and weighted based on NFTs owned'; + } + + if (isVSRConfig(value)) { + return 'Locked tokens (VeTokens)'; + } + + if (isCivicConfig(value)) { + return 'Governance based on Civic verification'; + } + + return 'Add a custom program ID for governance structure'; +} + +export function VotingStructureSelector(props: Props) { + const [open, setOpen] = useState(false); + const [width, setWidth] = useState(0); + const [isDefault, setIsDefault] = useState( + !props.currentStructure.maxVotingProgramId && + !props.structure.votingProgramId, + ); + const trigger = useRef(null); + + useEffect(() => { + if (trigger.current) { + setWidth(trigger.current.clientWidth); + } else { + setWidth(0); + } + }, [trigger, open]); + + return ( + +
+ +
+ {areConfigsEqual({}, props.structure) && isDefault + ? 'Default' + : getLabel(props.structure)} +
+
+ {areConfigsEqual({}, props.structure) && isDefault + ? 'Governance is based on token ownership' + : getDescription(props.structure)} +
+ +
+ {isCustomConfig(props.structure) && !isDefault && ( + { + const newConfig = produce({ ...props.structure }, (data) => { + data.votingProgramId = value || undefined; + data.nftCollection = undefined; + }); + + props.onChange?.(newConfig); + }} + onMaxVotingProgramIdChange={(value) => { + const newConfig = produce({ ...props.structure }, (data) => { + data.maxVotingProgramId = value || undefined; + data.nftCollection = undefined; + }); + + props.onChange?.(newConfig); + }} + /> + )} + {isNFTConfig(props.structure) && ( + { + const newConfig = produce({ ...props.structure }, (data) => { + data.nftCollection = value || undefined; + }); + + props.onChange?.(newConfig); + }} + onCollectionSizeChange={(value) => { + const newConfig = produce({ ...props.structure }, (data) => { + data.nftCollectionSize = value; + }); + + props.onChange?.(newConfig); + }} + onCollectionWeightChange={(value) => { + const newConfig = produce({ ...props.structure }, (data) => { + data.nftCollectionWeight = value; + }); + + props.onChange?.(newConfig); + }} + /> + )} + + + {([ + ...(props.allowCivic ? [DEFAULT_CIVIC_CONFIG] : []), + ...(props.allowNFT ? [DEFAULT_NFT_CONFIG] : []), + ...(props.allowVSR ? [DEFAULT_VSR_CONFIG] : []), + ...(isCustomConfig(props.currentStructure) + ? [props.currentStructure] + : [{}]), + 'default', + ] as const) + .filter((config) => { + if (typeof config === 'string') { + return !areConfigsEqual({}, props.structure); + } + + return !areConfigsEqual(config, props.structure); + }) + .map((config, i) => ( + { + if (typeof config === 'string') { + props.onChange?.({}); + setIsDefault(true); + } else { + props.onChange?.(config); + setIsDefault(false); + } + }} + > +
+ {typeof config === 'string' ? 'Default' : getLabel(config)} +
+
+ {typeof config === 'string' + ? 'Governance is based on token ownership' + : getDescription(config)} +
+
+ ))} +
+
+
+
+ ); +} diff --git a/hub/components/EditRealmConfig/createTransaction.ts b/hub/components/EditRealmConfig/createTransaction.ts new file mode 100644 index 0000000000..9190c23e99 --- /dev/null +++ b/hub/components/EditRealmConfig/createTransaction.ts @@ -0,0 +1,173 @@ +import { AnchorProvider, Wallet } from '@project-serum/anchor'; +import { NftVoterClient } from '@solana/governance-program-library'; +import { + createSetRealmConfig, + GoverningTokenType, + GoverningTokenConfigAccountArgs, + tryGetRealmConfig, + getRealm, + getGovernanceProgramVersion, + SYSTEM_PROGRAM_ID, +} from '@solana/spl-governance'; +import type { + Connection, + PublicKey, + TransactionInstruction, +} from '@solana/web3.js'; + +import { + getMaxVoterWeightRecord, + getRegistrarPDA, +} from '@utils/plugin/accounts'; + +import { Config } from './fetchConfig'; +import { buildUpdates, diff } from './UpdatesList'; + +function shouldAddConfigInstruction(config: Config, currentConfig: Config) { + const updates = diff(buildUpdates(currentConfig), buildUpdates(config)); + + if ( + updates.communityMaxVotingPlugin || + updates.communityTokenType || + updates.communityVotingPlugin || + updates.councilMaxVotingPlugin || + updates.councilTokenType || + updates.councilVotingPlugin || + updates.maxVoterWeightType || + updates.maxVoterWeightValue || + updates.minCommunityTokensToCreateGovernance + ) { + return true; + } + + return false; +} + +export async function createTransaction( + programId: PublicKey, + realm: PublicKey, + governance: PublicKey, + config: Config, + currentConfig: Config, + connection: Connection, + isDevnet?: boolean, + wallet?: Omit, +) { + const instructions: TransactionInstruction[] = []; + const realmConfig = await tryGetRealmConfig(connection, programId, realm); + const programVersion = await getGovernanceProgramVersion( + connection, + programId, + ); + const realmAccount = await getRealm(connection, realm); + + if ( + realmAccount.account.authority && + wallet && + config.nftCollection && + (!currentConfig.nftCollection || + !currentConfig.nftCollection.equals(config.nftCollection) || + currentConfig.nftCollectionSize !== config.nftCollectionSize || + !currentConfig.nftCollectionWeight.eq(config.nftCollectionWeight)) + ) { + const defaultOptions = AnchorProvider.defaultOptions(); + const anchorProvider = new AnchorProvider( + connection, + wallet, + defaultOptions, + ); + + const nftClient = await NftVoterClient.connect(anchorProvider, isDevnet); + const { registrar } = await getRegistrarPDA( + realm, + config.communityMint.publicKey, + nftClient.program.programId, + ); + const { maxVoterWeightRecord } = await getMaxVoterWeightRecord( + realm, + config.communityMint.publicKey, + nftClient.program.programId, + ); + + instructions.push( + await nftClient.program.methods + .createRegistrar(10) + .accounts({ + registrar, + realm, + governanceProgramId: programId, + realmAuthority: realmAccount.account.authority, + governingTokenMint: config.communityMint.publicKey, + payer: wallet.publicKey, + systemProgram: SYSTEM_PROGRAM_ID, + }) + .instruction(), + ); + + instructions.push( + await nftClient.program.methods + .createMaxVoterWeightRecord() + .accounts({ + maxVoterWeightRecord, + realm, + governanceProgramId: programId, + realmGoverningTokenMint: config.communityMint.publicKey, + payer: wallet.publicKey, + systemProgram: SYSTEM_PROGRAM_ID, + }) + .instruction(), + ); + + instructions.push( + await nftClient.program.methods + .configureCollection( + config.nftCollectionWeight, + config.nftCollectionSize, + ) + .accounts({ + registrar, + realm, + maxVoterWeightRecord, + realmAuthority: realmAccount.account.authority, + collection: config.nftCollection, + }) + .instruction(), + ); + } + + if (shouldAddConfigInstruction(config, currentConfig)) { + instructions.push( + await createSetRealmConfig( + programId, + programVersion, + realm, + governance, + config.configAccount.councilTokenConfig.tokenType === + GoverningTokenType.Dormant + ? undefined + : config.config.councilMint, + config.config.communityMintMaxVoteWeightSource, + config.config.minCommunityTokensToCreateGovernance, + new GoverningTokenConfigAccountArgs({ + voterWeightAddin: + config.configAccount.communityTokenConfig.voterWeightAddin, + maxVoterWeightAddin: + config.configAccount.communityTokenConfig.maxVoterWeightAddin, + tokenType: config.configAccount.communityTokenConfig.tokenType, + }), + programVersion === 3 + ? new GoverningTokenConfigAccountArgs({ + voterWeightAddin: + config.configAccount.councilTokenConfig.voterWeightAddin, + maxVoterWeightAddin: + config.configAccount.councilTokenConfig.maxVoterWeightAddin, + tokenType: config.configAccount.councilTokenConfig.tokenType, + }) + : undefined, + !realmConfig ? wallet?.publicKey : undefined, + ), + ); + } + + return instructions; +} diff --git a/hub/components/EditRealmConfig/fetchConfig.ts b/hub/components/EditRealmConfig/fetchConfig.ts new file mode 100644 index 0000000000..920d63fd8d --- /dev/null +++ b/hub/components/EditRealmConfig/fetchConfig.ts @@ -0,0 +1,162 @@ +import { AnchorProvider, Wallet } from '@project-serum/anchor'; +import { NftVoterClient } from '@solana/governance-program-library'; +import { + RealmConfig, + RealmConfigAccount, + getRealm, + getRealmConfigAddress, + ProgramAccount, + GovernanceAccountParser, + GoverningTokenType, + GoverningTokenConfig, +} from '@solana/spl-governance'; +import { Connection, PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; + +import { tryGetNftRegistrar } from 'VoteStakeRegistry/sdk/api'; + +import { nftPluginsPks } from '@hooks/useVotingPlugins'; +import { getRegistrarPDA as getPluginRegistrarPDA } from '@utils/plugin/accounts'; +import { parseMintAccountData, MintAccount } from '@utils/tokens'; + +export interface Config { + config: RealmConfig; + configAccount: RealmConfigAccount; + communityMint: { + publicKey: PublicKey; + account: MintAccount; + }; + nftCollection?: PublicKey; + nftCollectionSize: number; + nftCollectionWeight: BN; + realmAuthority?: PublicKey; +} + +export async function fetchConfig( + connection: Connection, + realmPublicKey: PublicKey, + programPublicKey: PublicKey, + wallet: Pick, + isDevnet?: boolean, +): Promise { + const [realm, realmConfigPublicKey] = await Promise.all([ + getRealm(connection, realmPublicKey), + getRealmConfigAddress(programPublicKey, realmPublicKey), + ]); + + const realmConfig = realm.account.config; + const configAccountInfo = await connection.getAccountInfo( + realmConfigPublicKey, + ); + + const configProgramAccount: ProgramAccount = configAccountInfo + ? GovernanceAccountParser(RealmConfigAccount)( + realmConfigPublicKey, + configAccountInfo, + ) + : { + pubkey: realmConfigPublicKey, + owner: programPublicKey, + account: new RealmConfigAccount({ + realm: realmPublicKey, + communityTokenConfig: new GoverningTokenConfig({ + voterWeightAddin: undefined, + maxVoterWeightAddin: undefined, + tokenType: GoverningTokenType.Liquid, + reserved: new Uint8Array(), + }), + councilTokenConfig: new GoverningTokenConfig({ + voterWeightAddin: undefined, + maxVoterWeightAddin: undefined, + tokenType: GoverningTokenType.Liquid, + reserved: new Uint8Array(), + }), + reserved: new Uint8Array(), + }), + }; + + let nftCollection: PublicKey | undefined = undefined; + let nftCollectionSize = 0; + let nftCollectionWeight = new BN(0); + const defaultOptions = AnchorProvider.defaultOptions(); + const anchorProvider = new AnchorProvider(connection, wallet, defaultOptions); + const nftClient = await NftVoterClient.connect(anchorProvider, isDevnet); + const pluginPublicKey = + configProgramAccount.account.communityTokenConfig.voterWeightAddin; + + if (pluginPublicKey && nftPluginsPks.includes(pluginPublicKey.toBase58())) { + if (nftClient && realm.account.communityMint) { + const programId = nftClient.program.programId; + const registrarPDA = ( + await getPluginRegistrarPDA( + realmPublicKey, + realm.account.communityMint, + programId, + ) + ).registrar; + + const registrar: any = await tryGetNftRegistrar(registrarPDA, nftClient); + + const collections = registrar?.collectionConfigs || []; + + if (collections[0]) { + nftCollection = new PublicKey(collections[0].collection); + nftCollectionSize = collections[0].size; + nftCollectionWeight = collections[0].weight; + } + } + } + + const mintPkStr = realm.account.communityMint.toBase58(); + const communityMint = await fetch(connection.rpcEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: mintPkStr, + method: 'getAccountInfo', + params: [ + mintPkStr, + { + commitment: connection.commitment, + encoding: 'base64', + }, + ], + }), + }) + .then<{ + result: { + context: { + apiVersion: string; + slot: number; + }; + value: { + data: any[]; + executable: boolean; + lamports: number; + owner: string; + rentEpoch: number; + }; + }; + }>((resp) => { + return resp.json(); + }) + .then(({ result: { value } }) => { + const publicKey = realm.account.communityMint; + const data = Buffer.from(value.data[0], 'base64'); + const account = parseMintAccountData(data); + return { publicKey, account }; + }); + + return { + communityMint, + nftCollection, + nftCollectionSize, + nftCollectionWeight, + config: realmConfig, + configAccount: configProgramAccount.account, + realmAuthority: realm.account.authority, + }; +} diff --git a/hub/components/EditRealmConfig/gql.ts b/hub/components/EditRealmConfig/gql.ts new file mode 100644 index 0000000000..673cb62c7d --- /dev/null +++ b/hub/components/EditRealmConfig/gql.ts @@ -0,0 +1,110 @@ +import * as IT from 'io-ts'; +import { gql } from 'urql'; + +import { BigNumber } from '@hub/types/decoders/BigNumber'; +import { GovernanceTokenType } from '@hub/types/decoders/GovernanceTokenType'; +import { GovernanceVoteTipping } from '@hub/types/decoders/GovernanceVoteTipping'; +import { PublicKey } from '@hub/types/decoders/PublicKey'; + +export const getRealm = gql` + query($realmUrlId: String!) { + me { + publicKey + } + realmByUrlId(urlId: $realmUrlId) { + iconUrl + name + programPublicKey + publicKey + } + } +`; + +export const getGovernance = gql` + query($realmUrlId: String!, $governancePublicKey: PublicKey!) { + realmByUrlId(urlId: $realmUrlId) { + publicKey + governance(governance: $governancePublicKey) { + communityTokenRules { + canCreateProposal + canVeto + canVote + quorumPercent + tokenMintAddress + tokenMintDecimals + tokenType + totalSupply + vetoQuorumPercent + voteTipping + votingPowerToCreateProposals + } + coolOffHours + councilTokenRules { + canCreateProposal + canVeto + canVote + quorumPercent + tokenMintAddress + tokenMintDecimals + tokenType + totalSupply + vetoQuorumPercent + voteTipping + votingPowerToCreateProposals + } + depositExemptProposalCount + governanceAddress + maxVoteDays + minInstructionHoldupDays + version + walletAddress + } + } + } +`; + +const Rules = IT.type({ + canCreateProposal: IT.boolean, + canVeto: IT.boolean, + canVote: IT.boolean, + quorumPercent: IT.number, + tokenMintAddress: PublicKey, + tokenMintDecimals: BigNumber, + tokenType: GovernanceTokenType, + totalSupply: BigNumber, + vetoQuorumPercent: IT.number, + voteTipping: GovernanceVoteTipping, + votingPowerToCreateProposals: BigNumber, +}); + +export const getRealmResp = IT.type({ + me: IT.union([ + IT.null, + IT.type({ + publicKey: PublicKey, + }), + ]), + realmByUrlId: IT.type({ + iconUrl: IT.union([IT.null, IT.string]), + name: IT.string, + programPublicKey: PublicKey, + publicKey: PublicKey, + }), +}); + +export const getGovernanceResp = IT.type({ + realmByUrlId: IT.type({ + publicKey: PublicKey, + governance: IT.type({ + communityTokenRules: Rules, + coolOffHours: IT.number, + councilTokenRules: IT.union([IT.null, Rules]), + depositExemptProposalCount: IT.number, + governanceAddress: PublicKey, + maxVoteDays: IT.number, + minInstructionHoldupDays: IT.number, + version: IT.number, + walletAddress: PublicKey, + }), + }), +}); diff --git a/hub/components/EditRealmConfig/index.tsx b/hub/components/EditRealmConfig/index.tsx new file mode 100644 index 0000000000..0534d0bbb5 --- /dev/null +++ b/hub/components/EditRealmConfig/index.tsx @@ -0,0 +1,368 @@ +import CheckmarkIcon from '@carbon/icons-react/lib/Checkmark'; +import ChevronLeftIcon from '@carbon/icons-react/lib/ChevronLeft'; +import EditIcon from '@carbon/icons-react/lib/Edit'; +import { getRealm, GoverningTokenType } from '@solana/spl-governance'; +import { PublicKey } from '@solana/web3.js'; +import { pipe } from 'fp-ts/lib/function'; +import { TypeOf } from 'io-ts'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import { useEffect, useState, useRef } from 'react'; + +import { Primary, Secondary } from '@hub/components/controls/Button'; +import { Connect } from '@hub/components/GlobalHeader/User/Connect'; +import { ProposalCreationProgress } from '@hub/components/ProposalCreationProgress'; +import { useCluster, ClusterType } from '@hub/hooks/useCluster'; +import { useProposal } from '@hub/hooks/useProposal'; +import { useQuery } from '@hub/hooks/useQuery'; +import { useToast, ToastType } from '@hub/hooks/useToast'; +import { useWallet } from '@hub/hooks/useWallet'; +import cx from '@hub/lib/cx'; +import * as RE from '@hub/types/Result'; + +import { createTransaction } from './createTransaction'; +import { fetchConfig, Config } from './fetchConfig'; +import { Form } from './Form'; +import * as gql from './gql'; +import { RealmHeader } from './RealmHeader'; +import { Summary } from './Summary'; + +type Governance = TypeOf< + typeof gql.getGovernanceResp +>['realmByUrlId']['governance']; + +enum Step { + Form, + Summary, +} + +function stepNum(step: Step): number { + switch (step) { + case Step.Form: + return 1; + case Step.Summary: + return 2; + } +} + +function stepName(step: Step): string { + switch (step) { + case Step.Form: + return 'Update Org Configuration'; + case Step.Summary: + return 'Create Proposal'; + } +} + +interface Props { + className?: string; + realmUrlId: string; +} + +export function EditRealmConfig(props: Props) { + const [cluster] = useCluster(); + const wallet = useWallet(); + const [step, setStep] = useState(Step.Form); + const [realmAuthority, setRealmAuthority] = useState( + undefined, + ); + const [result] = useQuery(gql.getRealmResp, { + query: gql.getRealm, + variables: { + realmUrlId: props.realmUrlId, + }, + }); + const [governance, setGovernance] = useState(null); + const [governanceResult] = useQuery(gql.getGovernanceResp, { + query: gql.getGovernance, + variables: { + realmUrlId: props.realmUrlId, + governancePublicKey: realmAuthority?.toBase58(), + }, + pause: !realmAuthority, + }); + const router = useRouter(); + const { publish } = useToast(); + const [submitting, setSubmitting] = useState(false); + + const [proposalVoteType, setProposalVoteType] = useState< + 'community' | 'council' + >('community'); + const [proposalDescription, setProposalDescription] = useState(''); + const [proposalTitle, setProposalTitle] = useState( + 'Update Realms Configuration', + ); + + const [config, setConfig] = useState(null); + const existingConfig = useRef(null); + const { createProposal, progress } = useProposal(); + + useEffect(() => { + if (typeof window !== 'undefined') { + window.scrollTo({ top: 0 }); + } + }, [step]); + + useEffect(() => { + if (RE.isOk(result)) { + (wallet.publicKey ? Promise.resolve(wallet.publicKey) : wallet.connect()) + .then((publicKey) => + fetchConfig( + cluster.connection, + result.data.realmByUrlId.publicKey, + result.data.realmByUrlId.programPublicKey, + { + publicKey, + signAllTransactions: wallet.signAllTransactions, + signTransaction: wallet.signTransaction, + }, + cluster.type === ClusterType.Devnet, + ), + ) + .then((config) => { + setConfig({ ...config }); + setProposalTitle( + `Update Realms Config for "${result.data.realmByUrlId.name}"`, + ); + + existingConfig.current = { + ...config, + config: { ...config.config }, + configAccount: { + ...config.configAccount, + communityTokenConfig: { + ...config.configAccount.communityTokenConfig, + }, + councilTokenConfig: { + ...config.configAccount.councilTokenConfig, + }, + }, + }; + }) + .then(() => + getRealm(cluster.connection, result.data.realmByUrlId.publicKey).then( + (realm) => { + setRealmAuthority(realm.account.authority); + }, + ), + ); + } + }, [result._tag]); + + useEffect(() => { + if (RE.isOk(governanceResult)) { + setGovernance(governanceResult.data.realmByUrlId.governance); + + if (existingConfig.current) { + if ( + existingConfig.current.config.councilMint && + (existingConfig.current.configAccount.communityTokenConfig + .tokenType === GoverningTokenType.Dormant || + !governanceResult.data.realmByUrlId.governance.communityTokenRules + .canCreateProposal) + ) { + setProposalVoteType('council'); + } + } + } + }, [governanceResult._tag]); + + return pipe( + result, + RE.match( + () =>
, + () =>
, + ({ me, realmByUrlId }) => { + if (!me && !(wallet.softConnect && wallet.publicKey)) { + return ( +
+ + Edit Org Config - {realmByUrlId.name} + + +
+
+
+ Please sign in to edit the realm config +
+ for "{realmByUrlId.name}" +
+ +
+
+
+ ); + } + + if (!(config && existingConfig.current && governance)) { + return
; + } + + const userPublicKey = (me?.publicKey || wallet.publicKey) as PublicKey; + + return ( +
+ +
+ + Edit Org Config - {realmByUrlId.name} + + +
+
+ Step {stepNum(step)} of 2 +
+
+ {stepName(step)} +
+
+
+ + {step === Step.Form && ( + <> +
+
+ + setStep(Step.Summary)} + > + Continue + +
+ + )} + {step === Step.Summary && ( + <> + +
+ + { + if (!existingConfig.current) { + return; + } + + setSubmitting(true); + + const userPublicKey = await wallet.connect(); + + const transaction = await createTransaction( + realmByUrlId.programPublicKey, + realmByUrlId.publicKey, + governance.governanceAddress, + config, + existingConfig.current, + cluster.connection, + cluster.type === ClusterType.Devnet, + { + publicKey: userPublicKey, + signAllTransactions: wallet.signAllTransactions, + signTransaction: wallet.signTransaction, + }, + ); + + const governingTokenMintPublicKey = + proposalVoteType === 'council' && + existingConfig.current.config.councilMint + ? existingConfig.current.config.councilMint + : existingConfig.current.communityMint.publicKey; + + try { + const proposalAddress = await createProposal({ + proposalDescription, + proposalTitle, + governingTokenMintPublicKey, + programPublicKey: realmByUrlId.programPublicKey, + governancePublicKey: governance.governanceAddress, + instructions: transaction, + isDraft: false, + realmPublicKey: realmByUrlId.publicKey, + councilTokenMintPublicKey: + governance.councilTokenRules + ?.tokenMintAddress || undefined, + communityTokenMintPublicKey: + governance.communityTokenRules.tokenMintAddress, + }); + + if (proposalAddress) { + router.push( + `/dao/${ + props.realmUrlId + }/proposal/${proposalAddress.toBase58()}` + + (cluster.type === ClusterType.Devnet + ? '?cluster=devnet' + : ''), + ); + } + } catch (e) { + console.error(e); + publish({ + type: ToastType.Error, + title: 'Could not create proposal.', + message: String(e), + }); + } + + setSubmitting(false); + }} + > + + Create Proposal + +
+ + )} +
+
+
+ ); + }, + ), + ); +} diff --git a/hub/components/EditWalletRules/ProposalDetails/index.tsx b/hub/components/EditWalletRules/ProposalDetails/index.tsx index fba59eb818..6708ea7e00 100644 --- a/hub/components/EditWalletRules/ProposalDetails/index.tsx +++ b/hub/components/EditWalletRules/ProposalDetails/index.tsx @@ -13,7 +13,6 @@ interface Props proposalTitle: string; }> { className?: string; - governanceAddress: PublicKey; walletAddress: PublicKey; } diff --git a/hub/components/EditWalletRules/ProposalVoteType/index.tsx b/hub/components/EditWalletRules/ProposalVoteType/index.tsx index 96cd023279..89382b7dce 100644 --- a/hub/components/EditWalletRules/ProposalVoteType/index.tsx +++ b/hub/components/EditWalletRules/ProposalVoteType/index.tsx @@ -1,8 +1,7 @@ -import BuildingIcon from '@carbon/icons-react/lib/Building'; import ChevronDownIcon from '@carbon/icons-react/lib/ChevronDown'; +import RuleIcon from '@carbon/icons-react/lib/Rule'; import UserMultipleIcon from '@carbon/icons-react/lib/UserMultiple'; import WalletIcon from '@carbon/icons-react/lib/Wallet'; -import WarningFilledIcon from '@carbon/icons-react/lib/WarningFilled'; import { useState } from 'react'; import { SectionBlock } from '../SectionBlock'; @@ -86,7 +85,7 @@ export function ProposalVoteType(props: Props) { {props.proposalVoteType === 'community' ? ( ) : ( - + )}
Current{' '} diff --git a/hub/components/EditWalletRules/SectionBlock/index.tsx b/hub/components/EditWalletRules/SectionBlock/index.tsx index 2140868087..5d21137ed7 100644 --- a/hub/components/EditWalletRules/SectionBlock/index.tsx +++ b/hub/components/EditWalletRules/SectionBlock/index.tsx @@ -8,7 +8,13 @@ interface Props { export function SectionBlock(props: Props) { return (
{props.children}
diff --git a/hub/components/EditWalletRules/SectionHeader/index.tsx b/hub/components/EditWalletRules/SectionHeader/index.tsx index 0333ecd577..5f24ea88bf 100644 --- a/hub/components/EditWalletRules/SectionHeader/index.tsx +++ b/hub/components/EditWalletRules/SectionHeader/index.tsx @@ -17,12 +17,15 @@ export function SectionHeader(props: Props) { 'items-center', 'space-x-2', 'text-neutral-500', + 'pb-3', + 'border-b', + 'dark:border-neutral-800', )} > {cloneElement(props.icon, { className: cx(props.icon.props.className, 'fill-current', 'h-4', 'w-4'), })} -
{props.text}
+
{props.text}
); } diff --git a/hub/components/EditWalletRules/Summary/index.tsx b/hub/components/EditWalletRules/Summary/index.tsx index 18286f6fc9..d3411f1f9e 100644 --- a/hub/components/EditWalletRules/Summary/index.tsx +++ b/hub/components/EditWalletRules/Summary/index.tsx @@ -49,7 +49,6 @@ export function Summary(props: Props) { +
{props.text}
); diff --git a/hub/components/EditWalletRules/VoteTippingSelector/index.tsx b/hub/components/EditWalletRules/VoteTippingSelector/index.tsx index 02548e71e2..cd5f4fc034 100644 --- a/hub/components/EditWalletRules/VoteTippingSelector/index.tsx +++ b/hub/components/EditWalletRules/VoteTippingSelector/index.tsx @@ -72,7 +72,11 @@ export function VoteTippingSelector(props: Props) {
{getLabel(props.value)}
@@ -81,7 +85,7 @@ export function VoteTippingSelector(props: Props) {
diff --git a/hub/components/EditWalletRules/index.tsx b/hub/components/EditWalletRules/index.tsx index 12bb0501f4..7e874789f6 100644 --- a/hub/components/EditWalletRules/index.tsx +++ b/hub/components/EditWalletRules/index.tsx @@ -17,6 +17,7 @@ import { useCluster, ClusterType } from '@hub/hooks/useCluster'; import { useProposal } from '@hub/hooks/useProposal'; import { useQuery } from '@hub/hooks/useQuery'; import { useToast, ToastType } from '@hub/hooks/useToast'; +import { useWallet } from '@hub/hooks/useWallet'; import cx from '@hub/lib/cx'; import { GovernanceTokenType } from '@hub/types/GovernanceTokenType'; import { GovernanceVoteTipping } from '@hub/types/GovernanceVoteTipping'; @@ -59,6 +60,7 @@ interface Props { export function EditWalletRules(props: Props) { const [cluster] = useCluster(); + const wallet = useWallet(); const { createProposal, progress } = useProposal(); const { publish } = useToast(); const [result] = useQuery(gql.getGovernanceRulesResp, { @@ -147,7 +149,6 @@ export function EditWalletRules(props: Props) { return pipe( result, - RE.match( () =>
, () =>
, @@ -157,7 +158,7 @@ export function EditWalletRules(props: Props) { getAccountName(governance.governanceAddress) || governance.walletAddress.toBase58(); - if (!me) { + if (!me && !(wallet.softConnect && wallet.publicKey)) { return (
diff --git a/hub/components/GlobalHeader/User/Connect.tsx b/hub/components/GlobalHeader/User/Connect.tsx index da0682e7c1..57f7964040 100644 --- a/hub/components/GlobalHeader/User/Connect.tsx +++ b/hub/components/GlobalHeader/User/Connect.tsx @@ -42,7 +42,7 @@ interface Props { } export function Connect(props: Props) { - const { connect, signMessage } = useWallet(); + const { connect, signMessage, setSoftConnect } = useWallet(); const [, createClaim] = useMutation(getClaimResp, getClaim); const [, createToken] = useMutation(getTokenResp, getToken); const [, setJwt] = useJWT(); @@ -73,6 +73,7 @@ export function Connect(props: Props) { )} onClick={async () => { try { + localStorage.removeItem('walletName'); const publicKey = await connect(); const claimResult = await createClaim({ @@ -88,7 +89,15 @@ export function Connect(props: Props) { } = claimResult.data; const claimBlob = sig.toUint8Array(claim); - const signatureResp = await signMessage(claimBlob); + const signatureResp = await signMessage(claimBlob).catch( + () => null, + ); + + if (!signatureResp) { + setSoftConnect(true); + return; + } + const signature = sig.toHex(signatureResp); const tokenResult = await createToken({ claim, signature }); diff --git a/hub/components/GlobalHeader/User/Connected.tsx b/hub/components/GlobalHeader/User/Connected.tsx new file mode 100644 index 0000000000..ed18d8448a --- /dev/null +++ b/hub/components/GlobalHeader/User/Connected.tsx @@ -0,0 +1,41 @@ +import type { PublicKey } from '@solana/web3.js'; + +import { AuthorAvatar } from '@hub/components/AuthorAvatar'; +import { abbreviateAddress } from '@hub/lib/abbreviateAddress'; +import cx from '@hub/lib/cx'; + +interface Props { + className?: string; + compressed?: boolean; + userPublicKey: PublicKey; +} + +export function Connected(props: Props) { + const username = abbreviateAddress(props.userPublicKey); + + return ( +
+
+ + {!props.compressed && ( +
{username}
+ )} +
+
+
+ ); +} diff --git a/hub/components/GlobalHeader/User/index.tsx b/hub/components/GlobalHeader/User/index.tsx index d37028dc19..0a88e438a8 100644 --- a/hub/components/GlobalHeader/User/index.tsx +++ b/hub/components/GlobalHeader/User/index.tsx @@ -1,10 +1,13 @@ import { pipe } from 'fp-ts/function'; +import { useJWT } from '@hub/hooks/useJWT'; import { useQuery } from '@hub/hooks/useQuery'; +import { useWallet } from '@hub/hooks/useWallet'; import cx from '@hub/lib/cx'; import * as RE from '@hub/types/Result'; import { Connect } from './Connect'; +import { Connected } from './Connected'; import { DialectNotifications } from './DialectNotifications'; import * as gql from './gql'; import { Loading } from './Loading'; @@ -17,6 +20,18 @@ interface Props { export function User(props: Props) { const [result, refetch] = useQuery(gql.getUserResp, { query: gql.getUser }); + const { publicKey, softConnect } = useWallet(); + const [jwt] = useJWT(); + + if (!jwt && publicKey && softConnect) { + return ( + + ); + } return pipe( result, diff --git a/hub/components/GlobalStats/ValueByDao/index.tsx b/hub/components/GlobalStats/ValueByDao/index.tsx index 77f5cf0538..adea5f8fe8 100644 --- a/hub/components/GlobalStats/ValueByDao/index.tsx +++ b/hub/components/GlobalStats/ValueByDao/index.tsx @@ -1,4 +1,3 @@ -import type { BigNumber } from 'bignumber.js'; import React from 'react'; import * as common from '../common'; diff --git a/hub/hooks/useWallet.ts b/hub/hooks/useWallet.ts index 7398cce267..e71482768d 100644 --- a/hub/hooks/useWallet.ts +++ b/hub/hooks/useWallet.ts @@ -6,6 +6,8 @@ export function useWallet() { const { connect, publicKey, + softConnect, + setSoftConnect, signMessage, signTransaction, signAllTransactions, @@ -13,6 +15,8 @@ export function useWallet() { return { connect, publicKey, + softConnect, + setSoftConnect, signMessage, signTransaction, signAllTransactions, diff --git a/hub/providers/Proposal/createProposal.ts b/hub/providers/Proposal/createProposal.ts index 0bc90106b2..d273fcd5f7 100644 --- a/hub/providers/Proposal/createProposal.ts +++ b/hub/providers/Proposal/createProposal.ts @@ -29,7 +29,6 @@ import { vsrPluginsPks, nftPluginsPks, gatewayPluginsPks, - switchboardPluginsPks, pythPluginsPks, } from '@hooks/useVotingPlugins'; import { getRegistrarPDA as getPluginRegistrarPDA } from '@utils/plugin/accounts'; @@ -205,13 +204,6 @@ export async function createProposal(args: Args) { } } - // if ( - // switchboardPluginsPks.includes(pluginPublicKeyStr) && - // votingPlugins.switchboardClient - // ) { - // client = votingPlugins.switchboardClient; - // } - if ( gatewayPluginsPks.includes(pluginPublicKeyStr) && votingPlugins.gatewayClient diff --git a/hub/providers/Proposal/fetchPlugins.ts b/hub/providers/Proposal/fetchPlugins.ts index 1e2093284c..bc317d4bb5 100644 --- a/hub/providers/Proposal/fetchPlugins.ts +++ b/hub/providers/Proposal/fetchPlugins.ts @@ -8,8 +8,6 @@ import { Connection, PublicKey } from '@solana/web3.js'; import { PythClient } from 'pyth-staking-api'; import { VsrClient } from 'VoteStakeRegistry/sdk/client'; -import { SwitchboardQueueVoterClient } from '../../../SwitchboardVotePlugin/SwitchboardQueueVoterClient'; - export async function fetchPlugins( connection: Connection, programPublicKey: PublicKey, @@ -19,17 +17,10 @@ export async function fetchPlugins( const defaultOptions = AnchorProvider.defaultOptions(); const anchorProvider = new AnchorProvider(connection, wallet, defaultOptions); - const [ - vsrClient, - nftClient, - gatewayClient, - //switchboardClient, - pythClient, - ] = await Promise.all([ + const [vsrClient, nftClient, gatewayClient, pythClient] = await Promise.all([ VsrClient.connect(anchorProvider, programPublicKey, isDevnet), NftVoterClient.connect(anchorProvider, isDevnet), GatewayClient.connect(anchorProvider, isDevnet), - //SwitchboardQueueVoterClient.connect(anchorProvider, isDevnet), PythClient.connect(anchorProvider, connection.rpcEndpoint), ]); @@ -37,7 +28,6 @@ export async function fetchPlugins( vsrClient, nftClient, gatewayClient, - //switchboardClient, pythClient, }; } diff --git a/hub/providers/Wallet/index.tsx b/hub/providers/Wallet/index.tsx index f25e1fb44d..65a5d9a7b4 100644 --- a/hub/providers/Wallet/index.tsx +++ b/hub/providers/Wallet/index.tsx @@ -1,6 +1,6 @@ import { WalletContextState, useWallet } from '@solana/wallet-adapter-react'; import type { PublicKey } from '@solana/web3.js'; -import React, { createContext } from 'react'; +import React, { createContext, useState } from 'react'; import { useWalletSelector } from '@hub/hooks/useWalletSelector'; import { WalletSelector } from '@hub/providers/WalletSelector'; @@ -8,6 +8,8 @@ import { WalletSelector } from '@hub/providers/WalletSelector'; interface Value { connect(): Promise; publicKey?: PublicKey; + softConnect: boolean; + setSoftConnect(value: boolean): void; signMessage: NonNullable; signTransaction: NonNullable; signAllTransactions: NonNullable; @@ -18,6 +20,10 @@ export const DEFAULT: Value = { throw new Error('Not implemented'); }, publicKey: undefined, + softConnect: false, + setSoftConnect: () => { + throw new Error('Not implemented'); + }, signMessage: async () => { throw new Error('Not implemented'); }, @@ -38,12 +44,15 @@ interface Props { function WalletProviderInner(props: Props) { const { wallet } = useWallet(); const { getAdapter } = useWalletSelector(); + const [softConnect, setSoftConnect] = useState(false); return ( getAdapter().then(({ publicKey }) => publicKey), publicKey: wallet?.adapter.publicKey || undefined, + setSoftConnect: (val) => setSoftConnect(val), signMessage: async (message) => { const { signMessage } = await getAdapter(); return signMessage(message); diff --git a/pages/_app.tsx b/pages/_app.tsx index b54b99f6bd..41c0d20f92 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -23,7 +23,8 @@ export default function App({ Component, pageProps, router }: AppProps) { if ( router.pathname.startsWith('/verify-wallet') || router.pathname.startsWith('/matchday/verify-wallet') || - router.pathname.startsWith('/realm/[id]/governance') + router.pathname.startsWith('/realm/[id]/governance') || + router.pathname.startsWith('/realm/[id]/config') ) { return ( diff --git a/pages/dao/[symbol]/params/components/ParamsView.tsx b/pages/dao/[symbol]/params/components/ParamsView.tsx index 243720b5c5..6a4fe9f195 100644 --- a/pages/dao/[symbol]/params/components/ParamsView.tsx +++ b/pages/dao/[symbol]/params/components/ParamsView.tsx @@ -6,12 +6,16 @@ import Button from '@components/Button' import { VoteTipping } from '@solana/spl-governance' import { AddressField, NumberField } from '../index' import useProgramVersion from '@hooks/useProgramVersion' +import { useRouter } from 'next/router' +import useQueryContext from '@hooks/useQueryContext' -const ParamsView = ({ activeGovernance, openGovernanceProposalModal }) => { - const { realm, mint, councilMint, ownVoterWeight } = useRealm() +const ParamsView = ({ activeGovernance }) => { + const { realm, mint, councilMint, ownVoterWeight, symbol } = useRealm() const programVersion = useProgramVersion() const realmAccount = realm?.account const communityMint = realmAccount?.communityMint.toBase58() + const router = useRouter() + const { fmtUrlWithCluster } = useQueryContext() const minCommunityTokensToCreateProposal = activeGovernance?.account?.config ?.minCommunityTokensToCreateProposal @@ -134,7 +138,9 @@ const ParamsView = ({ activeGovernance, openGovernanceProposalModal }) => { tooltipMessage={ 'Please connect wallet with enough voting power to create governance config proposals' } - onClick={openGovernanceProposalModal} + onClick={() => { + router.push(fmtUrlWithCluster(`/realm/${symbol}/config/edit`)) + }} className="ml-auto" > Change config diff --git a/pages/dao/[symbol]/params/index.tsx b/pages/dao/[symbol]/params/index.tsx index 3a16a7264e..211e4cdc84 100644 --- a/pages/dao/[symbol]/params/index.tsx +++ b/pages/dao/[symbol]/params/index.tsx @@ -30,9 +30,9 @@ import useQueryContext from '@hooks/useQueryContext' const Params = () => { const router = useRouter() - const { fmtUrlWithCluster } = useQueryContext() const { realm, mint, config, symbol } = useRealm() const wallet = useWalletStore((s) => s.current) + const { fmtUrlWithCluster } = useQueryContext() const { canUseAuthorityInstruction, assetAccounts, @@ -85,9 +85,6 @@ const Params = () => { const communityMintMaxVoteWeightSource = realmAccount?.config.communityMintMaxVoteWeightSource const realmConfig = realmAccount?.config - const openRealmProposalModal = () => { - setIsRealmProposalModalOpen(true) - } const closeRealmProposalModal = () => { setIsRealmProposalModalOpen(false) } @@ -262,7 +259,11 @@ const Params = () => { ? 'None of the governances is realm authority' : '' } - onClick={openRealmProposalModal} + onClick={() => { + router.push( + fmtUrlWithCluster(`/realm/${symbol}/config/edit`) + ) + }} className="ml-auto" > Change config @@ -331,16 +332,7 @@ const Params = () => { /> ) : null} {activeTab === 'Params' && ( - - router.push( - fmtUrlWithCluster( - `/realm/${symbol}/governance/${activeGovernance.pubkey.toBase58()}/edit` - ) - ) - } - /> + )} {activeTab === 'Accounts' && ( { + if (id === ECOSYSTEM_PAGE.toBase58()) { + router.replace('/ecosystem') + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- TODO please fix, it can cause difficult bugs. You might wanna check out https://bobbyhadz.com/blog/react-hooks-exhaustive-deps for info. -@asktree + }, [id]) + + if (id === ECOSYSTEM_PAGE.toBase58()) { + return
+ } + + return ( +
+ + Edit Org Config + + + +
+ ) +}