From bddda2002049ead29a8e4a15981411d316b901cc Mon Sep 17 00:00:00 2001 From: Asgeir <2156509+asgeir-s@users.noreply.github.com> Date: Thu, 4 Jul 2024 12:55:57 +0200 Subject: [PATCH] Add support for Delegate Registry v2 (#4492) * feat: set form.to as delegate address * feat-wip: initial split delegation UI updates to SpaceDelegatesDelegateModal * choir: Adds addDelegate, deleteDelegate, deleteAllDelegates functions * wip: updates divide equally and cleans up definition * feat: add delegates logic for Delegate Row Form * Updates clear all delegates logic * Updates x button hover * Refactor the delegation setup to be agnostic to how the data fetching and setting happens * Add delegate registry v2 reading * Add delegate registry v2 balance * Make it possible to view delegateRegistryV2 delegates * Activate the delegates sidebar link for delegate registry v2 delegations * Fix prettier problems * [delegate registry v2]Add offset/skip to delegat query * Add separate delegation UI for different delegation types * Update DELEGATE_REGISTRY_BACKEND_URL * feat: Connect the logic for setting the delegation * Update weight calculation in handleConfirm function * Add expiration time to setDelegates function and get it from the Modal * WIP: Adds Expiration field * feat: implement expiration date logic * chore: clean code * chore: remove unnecessary variables * Default expiration time to one year in the future + Some refactoring * chore: Apply PR comments * fix: adjust file imports * fix orderBy filter * format units to default to 18 decimals * bug: remove wrong percentage format * add number of delegators to read * use new api structure * fix object key reference * fix units * add fetch method * rename registry v2 -> split delegation * adjust for api changes * cleanup split delegation modal * fix address ordering * fix sub-strategy param on fetch * update read.ts to new api schema * use delegators from new api schema * update api request body to match new schema * use strategy set backendUrl if available * PR fixups * display current delegations in modal * add error if weights are over 100 * switch to using delegatePortal settings where appropriate * adds ability to clear all delegations * chore: file renaming * refactor: centralize delegation portal enabling * refactor: DRY-ing modal definition * refactor: remove redundant import * refactor: modify ref inside same function for more readability * refactor: remove redundant return * refactor: follow coding style for error arg name * refactor: remove uneeded ref * fix: remove unused function * feat: set form.to as delegate address * feat-wip: initial split delegation UI updates to SpaceDelegatesDelegateModal * choir: Adds addDelegate, deleteDelegate, deleteAllDelegates functions * wip: updates divide equally and cleans up definition * feat: add delegates logic for Delegate Row Form * Updates clear all delegates logic * Updates x button hover * Refactor the delegation setup to be agnostic to how the data fetching and setting happens * Add delegate registry v2 reading * Add delegate registry v2 balance * Make it possible to view delegateRegistryV2 delegates * Activate the delegates sidebar link for delegate registry v2 delegations * Fix prettier problems * [delegate registry v2]Add offset/skip to delegat query * Add separate delegation UI for different delegation types * Update DELEGATE_REGISTRY_BACKEND_URL * feat: Connect the logic for setting the delegation * Update weight calculation in handleConfirm function * Add expiration time to setDelegates function and get it from the Modal * WIP: Adds Expiration field * feat: implement expiration date logic * chore: clean code * chore: remove unnecessary variables * Default expiration time to one year in the future + Some refactoring * chore: Apply PR comments * fix: adjust file imports * fix orderBy filter * format units to default to 18 decimals * bug: remove wrong percentage format * add number of delegators to read * use new api structure * fix object key reference * fix units * add fetch method * rename registry v2 -> split delegation * adjust for api changes * cleanup split delegation modal * fix address ordering * fix sub-strategy param on fetch * update read.ts to new api schema * use delegators from new api schema * update api request body to match new schema * use strategy set backendUrl if available * PR fixups * display current delegations in modal * add error if weights are over 100 * switch to using delegatePortal settings where appropriate * adds ability to clear all delegations * refactor: rename file to follow convention * chore: revert back changes to .env * chore: remove redundant file * fix: use property from composable to check valid delegation portal * refactor: refactor to follow coding style * refactor: refactor to follow coding style * refactor: removed uneeded variable * fix: show notification error on tx building error * fix: only use `hasDelegationPortal` from composable to check for delegation validity * fix: fix invalid variable type * fix: fix delegate statement not loading * fix: fix content flashing * fix: fix event triggering on page quit * fix: do not return undefined delegation * add sender to delegations when power < 100, clarify language * fix: handle floats when equally distributing power * fix: hide self delegate button on split delegation * fix(ui): delegate form should start with a blank delegate field * fix: add min and max to weight * fix: ensure all addresses are unique * fix: fix missing translation text * refactor: code standard * fix: remove empty string from duplicate addresses comparison * fix: remove unused ref * chore: remove leftover code from copy/paste * fix: UI fix * remove unused function, notify on wrong network * fix: refresh delegatingTo properly on account change * fix: fix wrong comparison --------- Co-authored-by: juliopavila Co-authored-by: Colin Spence Co-authored-by: samepant Co-authored-by: samepant Co-authored-by: Wan Qi Chen <495709+wa0x6e@users.noreply.github.com> --- src/components/BaseSearch.vue | 3 + .../SpaceDelegatesDelegateModal.vue | 29 +- .../SpaceDelegatesSplitDelegationModal.vue | 372 ++++++++++++++++++ src/components/SpaceSidebarNavigation.vue | 2 +- src/components/SpaceSplitDelegationRow.vue | 117 ++++++ src/composables/useDelegates.ts | 214 +++------- src/helpers/delegation/compound.ts | 144 ------- src/helpers/delegation/index.ts | 14 - src/helpers/delegation/standardConfig.ts | 29 -- src/helpers/delegationV2/compound/index.ts | 2 + src/helpers/delegationV2/compound/queries.ts | 54 +++ src/helpers/delegationV2/compound/read.ts | 147 +++++++ src/helpers/delegationV2/compound/write.ts | 26 ++ src/helpers/delegationV2/index.ts | 38 ++ .../delegationV2/splitDelegation/abi.ts | 56 +++ .../delegationV2/splitDelegation/index.ts | 2 + .../delegationV2/splitDelegation/read.ts | 162 ++++++++ .../delegationV2/splitDelegation/write.ts | 79 ++++ src/helpers/delegationV2/types.ts | 40 ++ src/helpers/interfaces.ts | 3 +- src/helpers/queries.ts | 16 - src/helpers/validation.ts | 2 + src/locales/default.json | 2 +- src/views/SpaceDelegate.vue | 46 ++- src/views/SpaceDelegates.vue | 47 ++- 25 files changed, 1248 insertions(+), 398 deletions(-) create mode 100644 src/components/SpaceDelegatesSplitDelegationModal.vue create mode 100644 src/components/SpaceSplitDelegationRow.vue delete mode 100644 src/helpers/delegation/compound.ts delete mode 100644 src/helpers/delegation/index.ts delete mode 100644 src/helpers/delegation/standardConfig.ts create mode 100644 src/helpers/delegationV2/compound/index.ts create mode 100644 src/helpers/delegationV2/compound/queries.ts create mode 100644 src/helpers/delegationV2/compound/read.ts create mode 100644 src/helpers/delegationV2/compound/write.ts create mode 100644 src/helpers/delegationV2/index.ts create mode 100644 src/helpers/delegationV2/splitDelegation/abi.ts create mode 100644 src/helpers/delegationV2/splitDelegation/index.ts create mode 100644 src/helpers/delegationV2/splitDelegation/read.ts create mode 100644 src/helpers/delegationV2/splitDelegation/write.ts create mode 100644 src/helpers/delegationV2/types.ts diff --git a/src/components/BaseSearch.vue b/src/components/BaseSearch.vue index 4ccedb167dca..1f2ed828eb48 100644 --- a/src/components/BaseSearch.vue +++ b/src/components/BaseSearch.vue @@ -4,6 +4,7 @@ const props = defineProps<{ placeholder?: string; modal?: boolean; focusOnMount?: boolean; + isDisabled?: boolean; }>(); const emit = defineEmits(['update:modelValue']); @@ -49,6 +50,8 @@ watch( autocorrect="off" autocapitalize="none" class="input w-full border-none" + :class="{ '!cursor-not-allowed': isDisabled }" + :disabled="isDisabled" @input="handleInput" />

{{ $t('delegates.delegateModal.title') }}

{{ $t('delegates.delegateModal.sub') }} - - - {{ formatCompactNumber(Number(accountBalance)) }} - {{ space.symbol }} -
+
+ Voting power +
+ + + {{ formatCompactNumber(Number(accountBalance)) }} + {{ space.symbol }} + +
+
+
Delegation scope
diff --git a/src/components/SpaceDelegatesSplitDelegationModal.vue b/src/components/SpaceDelegatesSplitDelegationModal.vue new file mode 100644 index 000000000000..b712e2a32fa1 --- /dev/null +++ b/src/components/SpaceDelegatesSplitDelegationModal.vue @@ -0,0 +1,372 @@ + + + diff --git a/src/components/SpaceSidebarNavigation.vue b/src/components/SpaceSidebarNavigation.vue index 7fd48f65a88b..1959ce7a5a23 100644 --- a/src/components/SpaceSidebarNavigation.vue +++ b/src/components/SpaceSidebarNavigation.vue @@ -31,7 +31,7 @@ const isLegacySpace = computed(() => { diff --git a/src/components/SpaceSplitDelegationRow.vue b/src/components/SpaceSplitDelegationRow.vue new file mode 100644 index 000000000000..0b696527d2c1 --- /dev/null +++ b/src/components/SpaceSplitDelegationRow.vue @@ -0,0 +1,117 @@ + + + diff --git a/src/composables/useDelegates.ts b/src/composables/useDelegates.ts index d8f741e76cb5..edbaba9fe2a7 100644 --- a/src/composables/useDelegates.ts +++ b/src/composables/useDelegates.ts @@ -1,18 +1,14 @@ +import { getInstance } from '@snapshot-labs/lock/plugins/vue3'; import { DelegateWithPercent, DelegatesVote, DelegatesProposal, ExtendedSpace } from '@/helpers/interfaces'; -import { createStandardConfig } from '@/helpers/delegation/index'; -import { getInstance } from '@snapshot-labs/lock/plugins/vue3'; -import { DELEGATE_VOTES_AND_PROPOSALS } from '@/helpers/queries'; import { - subgraphRequest, - sendTransaction -} from '@snapshot-labs/snapshot.js/src/utils'; -import { call } from '@snapshot-labs/snapshot.js/src/utils'; -import getProvider from '@snapshot-labs/snapshot.js/src/utils/provider'; + DelegationTypes, + setupDelegation as getDelegationAdapter +} from '@/helpers/delegationV2'; type DelegatesStats = Record< string, @@ -21,26 +17,11 @@ type DelegatesStats = Record< const DELEGATES_LIMIT = 18; -function adjustUrl(apiUrl: string) { - const hostedPattern = - /https:\/\/thegraph\.com\/hosted-service\/subgraph\/([\w-]+)\/([\w-]+)/; - const hostedMatch = apiUrl.match(hostedPattern); - - return hostedMatch - ? `https://api.thegraph.com/subgraphs/name/${hostedMatch[1]}/${hostedMatch[2]}` - : apiUrl; -} - export function useDelegates(space: ExtendedSpace) { - const { resolveName } = useResolveName(); - const { apolloQuery } = useApolloQuery(); const auth = getInstance(); - const { loadStatements } = useStatement(); - const { loadProfiles } = useProfiles(); + const { resolveName } = useResolveName(); - const standardConfig = createStandardConfig( - space.delegationPortal.delegationType - ); + const { reader, writer } = getDelegationAdapter(space, auth); const delegates = ref([]); const delegate = ref(null); @@ -51,53 +32,31 @@ export function useDelegates(space: ExtendedSpace) { const isLoadingDelegatingTo = ref(false); const isLoadingDelegateBalance = ref(false); const hasMoreDelegates = ref(false); - const resolvedAddress = ref(null); const delegatesStats = ref({}); - async function loadExtraDelegateData(spaceId: string, delegates: string[]) { - loadProfiles(delegates); - await Promise.all([ - fetchDelegateVotesAndProposals(spaceId, delegates), - loadStatements(spaceId, delegates) - ]); - } + const hasDelegationPortal = + space.delegationPortal.delegationType === DelegationTypes.COMPOUND || + (space.delegationPortal.delegationType === + DelegationTypes.SPLIT_DELEGATION && + space.strategies.some( + ({ name }) => name === DelegationTypes.SPLIT_DELEGATION + )); async function fetchDelegateBatch(orderBy: string, skip = 0) { - hasDelegatesLoadFailed.value = false; - - const query: any = standardConfig.getDelegatesQuery({ - skip, - first: DELEGATES_LIMIT, - orderBy - }); - - const response = await subgraphRequest( - adjustUrl(space.delegationPortal.delegationApi), - query - ); - - const formattedResponse = standardConfig.formatDelegatesResponse(response); - - await loadExtraDelegateData( - space.id, - formattedResponse.map(d => d.id) - ); - - return formattedResponse; + return reader.getDelegates(DELEGATES_LIMIT, skip, orderBy); } async function loadDelegates(orderBy: string) { if (isLoadingDelegates.value) return; isLoadingDelegates.value = true; + hasDelegatesLoadFailed.value = false; try { const response = await fetchDelegateBatch(orderBy); - delegates.value = response; - hasMoreDelegates.value = response.length === DELEGATES_LIMIT; - } catch (err) { - console.error(err); + } catch (e) { + console.error(e); hasDelegatesLoadFailed.value = true; } finally { isLoadingDelegates.value = false; @@ -107,52 +66,36 @@ export function useDelegates(space: ExtendedSpace) { async function fetchMoreDelegates(orderBy: string) { if (!delegates.value.length || isLoadingMoreDelegates.value) return; isLoadingMoreDelegates.value = true; + hasDelegatesLoadFailed.value = false; try { const response = await fetchDelegateBatch( orderBy, delegates.value.length ); - delegates.value = [...delegates.value, ...response]; - hasMoreDelegates.value = response.length === DELEGATES_LIMIT; - } catch (err) { - console.error(err); + } catch (e) { + console.error(e); hasDelegatesLoadFailed.value = true; } finally { isLoadingMoreDelegates.value = false; } } - async function loadDelegate(id: string) { - hasDelegatesLoadFailed.value = false; - + async function loadDelegate(addressOrEns: string) { if (isLoadingDelegate.value) return; - delegate.value = null; + hasDelegatesLoadFailed.value = false; isLoadingDelegate.value = true; - try { - resolvedAddress.value = await resolveName(id); - - if (!resolvedAddress.value) return; - const query: any = standardConfig.getDelegateQuery(resolvedAddress.value); - - const response = await subgraphRequest( - adjustUrl(space.delegationPortal.delegationApi), - query - ); - - if (resolvedAddress.value && !response.delegate) - response.delegate = standardConfig.initEmptyDelegate( - resolvedAddress.value - ); + delegate.value = null; - if (response.delegate) { - delegate.value = standardConfig.formatDelegateResponse(response); - await loadExtraDelegateData(space.id, [delegate.value.id]); - } - } catch (err) { - console.error(err); + try { + const resolvedAddress = await resolveName(addressOrEns); + if (!resolvedAddress) return; + const response = await reader.getDelegate(resolvedAddress); + delegate.value = response; + } catch (e) { + console.error(e); hasDelegatesLoadFailed.value = true; } finally { isLoadingDelegate.value = false; @@ -160,102 +103,49 @@ export function useDelegates(space: ExtendedSpace) { } async function loadDelegateBalance(id: string) { - const query: any = standardConfig.getBalanceQuery(id.toLowerCase()); - try { isLoadingDelegateBalance.value = true; - const response = await subgraphRequest( - adjustUrl(space.delegationPortal.delegationApi), - query - ); - return standardConfig.formatBalanceResponse(response); - } catch (err) { - console.error(err); + return await reader.getBalance(id.toLowerCase()); + } catch (e) { + console.error(e); } finally { isLoadingDelegateBalance.value = false; } } - async function setDelegate(address: string) { - const contractMethod = standardConfig.getContractDelegateMethod(); - const tx = await sendTransaction( - auth.web3, - space.delegationPortal.delegationContract, - contractMethod.abi, - contractMethod.action, - [address] - ); - return tx; + async function setDelegates( + addresses: string[], + ratio?: number[], + expirationTimestamp?: number + ) { + return writer.sendSetDelegationTx(addresses, ratio, expirationTimestamp); + } + + async function clearDelegations() { + if (!writer.sendClearDelegationsTx) { + throw new Error('Clear delegations not supported'); + } + return writer.sendClearDelegationsTx(); } async function fetchDelegatingTo(address: string) { if (!address) return; isLoadingDelegatingTo.value = true; try { - const broviderUrl = import.meta.env.VITE_BROVIDER_URL; - const contractMethod = standardConfig.getContractDelegatingToMethod(); - const provider = getProvider(space.network, { broviderUrl }); - return await call(provider, contractMethod.abi, [ - space.delegationPortal.delegationContract, - contractMethod.action, - [address] - ]); - } catch (err) { - console.error(err); + return await reader.getDelegatingTo(address); + } catch (e) { + console.error(e); } finally { isLoadingDelegatingTo.value = false; } } - async function fetchDelegateVotesAndProposals( - space: string, - delegates: string[] - ) { - const filteredDelegates = delegates.filter( - delegate => !delegatesStats.value[delegate] - ); - - const response: { votes: DelegatesVote[]; proposals: DelegatesProposal[] } = - await apolloQuery({ - query: DELEGATE_VOTES_AND_PROPOSALS, - variables: { - delegates: filteredDelegates, - space - } - }); - - if (!response) return {}; - - const votesAndProposals: DelegatesStats = {}; - - filteredDelegates.forEach(delegate => { - votesAndProposals[delegate] = { - votes: [], - proposals: [] - }; - }); - - response.votes.forEach(vote => { - const delegate = vote.voter.toLowerCase(); - votesAndProposals[delegate]?.votes.push(vote); - }); - - response.proposals.forEach(proposal => { - const delegate = proposal.author.toLowerCase(); - votesAndProposals[delegate]?.proposals.push(proposal); - }); - - delegatesStats.value = { - ...delegatesStats.value, - ...votesAndProposals - }; - } - return { isLoadingDelegate, isLoadingDelegates, isLoadingMoreDelegates, hasDelegatesLoadFailed, + hasDelegationPortal, isLoadingDelegateBalance, isLoadingDelegatingTo, hasMoreDelegates, @@ -265,9 +155,9 @@ export function useDelegates(space: ExtendedSpace) { loadDelegate, loadDelegates, fetchMoreDelegates, - setDelegate, + setDelegates, + clearDelegations, loadDelegateBalance, - fetchDelegateVotesAndProposals, fetchDelegatingTo }; } diff --git a/src/helpers/delegation/compound.ts b/src/helpers/delegation/compound.ts deleted file mode 100644 index c4b0df54c75b..000000000000 --- a/src/helpers/delegation/compound.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { DelegateWithPercent } from '@/helpers/interfaces'; -import { StandardConfig, QueryParams } from './standardConfig'; - -type Governance = { - delegatedVotes: string; - totalTokenHolders: string; - totalDelegates: string; -}; - -type Delegate = { - id: string; - delegatedVotes: string; - tokenHoldersRepresentedAmount: number; -}; -export class CompoundGovernorConfig extends StandardConfig { - getDelegatesQuery(params: QueryParams): Record { - const { first, skip, orderBy } = params; - return { - delegates: { - __args: { - first, - skip, - orderBy: orderBy ? orderBy : 'delegatedVotes', - orderDirection: 'desc' - }, - id: true, - delegatedVotes: true, - tokenHoldersRepresentedAmount: true - }, - governance: { - __args: { - id: 'GOVERNANCE' - }, - delegatedVotes: true, - totalTokenHolders: true, - totalDelegates: true - } - }; - } - - formatDelegatesResponse(response: any): DelegateWithPercent[] { - const governanceData = response.governance as Governance; - const delegatesData = response.delegates as Delegate[]; - - return delegatesData.map(delegate => { - const delegatorsPercentage = - Number(delegate.tokenHoldersRepresentedAmount) / - Number(governanceData.totalTokenHolders); - const votesPercentage = - Number(delegate.delegatedVotes) / Number(governanceData.delegatedVotes); - - delegate.id = delegate.id.toLowerCase(); - - return { - ...delegate, - delegatorsPercentage, - votesPercentage - }; - }); - } - - getDelegateQuery(id: string): Record { - return { - delegate: { - __args: { - id - }, - id: true, - delegatedVotes: true, - tokenHoldersRepresentedAmount: true - }, - governance: { - __args: { - id: 'GOVERNANCE' - }, - delegatedVotes: true, - totalTokenHolders: true, - totalDelegates: true - } - }; - } - - getBalanceQuery(id: string): Record { - return { - tokenHolder: { - __args: { - id - }, - id: true, - tokenBalance: true - } - }; - } - - formatBalanceResponse(response: any): string { - return response.tokenHolder?.tokenBalance || '0'; - } - - formatDelegateResponse(response: any): DelegateWithPercent { - const delegate = response.delegate as Delegate; - const governanceData = response.governance as Governance; - - const delegatorsPercentage = - Number(delegate.tokenHoldersRepresentedAmount) / - Number(governanceData.totalTokenHolders); - const votesPercentage = - Number(delegate.delegatedVotes) / Number(governanceData.delegatedVotes); - - return { - ...{ - id: delegate.id.toLowerCase(), - delegatedVotes: delegate?.delegatedVotes || '0', - tokenHoldersRepresentedAmount: - delegate?.tokenHoldersRepresentedAmount || 0 - }, - delegatorsPercentage: delegatorsPercentage || 0, - votesPercentage: votesPercentage || 0 - }; - } - - initEmptyDelegate(address: string): DelegateWithPercent { - return { - id: address, - delegatedVotes: '0', - tokenHoldersRepresentedAmount: 0, - delegatorsPercentage: 0, - votesPercentage: 0 - }; - } - - getContractDelegateMethod(): { abi: string[]; action: string } { - return { - abi: ['function delegate(address delegatee)'], - action: 'delegate' - }; - } - - getContractDelegatingToMethod(): { abi: string[]; action: string } { - return { - abi: ['function delegates(address) view returns (address)'], - action: 'delegates' - }; - } -} diff --git a/src/helpers/delegation/index.ts b/src/helpers/delegation/index.ts deleted file mode 100644 index 12bf9c496089..000000000000 --- a/src/helpers/delegation/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { StandardConfig } from './standardConfig'; -import { CompoundGovernorConfig } from './compound'; - -export function createStandardConfig(standard: string): StandardConfig { - switch (standard) { - case 'compound-governor': - return new CompoundGovernorConfig(); - - default: - throw new Error(`Unsupported standard: ${standard}`); - } -} - -export { StandardConfig }; diff --git a/src/helpers/delegation/standardConfig.ts b/src/helpers/delegation/standardConfig.ts deleted file mode 100644 index b3b5f258d031..000000000000 --- a/src/helpers/delegation/standardConfig.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { DelegateWithPercent } from '@/helpers/interfaces'; - -export type QueryParams = { - first: number; - skip: number; - orderBy: string; -}; - -export abstract class StandardConfig { - abstract getDelegatesQuery(params: QueryParams): Record; - abstract formatDelegatesResponse( - response: Record - ): DelegateWithPercent[]; - - abstract getDelegateQuery(id: string): Record; - abstract formatDelegateResponse( - response: Record - ): DelegateWithPercent; - - abstract getBalanceQuery(id: string): Record; - abstract formatBalanceResponse(response: Record): string; - - abstract initEmptyDelegate(address: string): DelegateWithPercent; - abstract getContractDelegateMethod(): { abi: string[]; action: string }; - abstract getContractDelegatingToMethod(): { - abi: string[]; - action: string; - }; -} diff --git a/src/helpers/delegationV2/compound/index.ts b/src/helpers/delegationV2/compound/index.ts new file mode 100644 index 000000000000..005ee01f00ec --- /dev/null +++ b/src/helpers/delegationV2/compound/index.ts @@ -0,0 +1,2 @@ +export { getDelegationReader } from './read'; +export { getDelegationWriter } from './write'; diff --git a/src/helpers/delegationV2/compound/queries.ts b/src/helpers/delegationV2/compound/queries.ts new file mode 100644 index 000000000000..0275785d9bef --- /dev/null +++ b/src/helpers/delegationV2/compound/queries.ts @@ -0,0 +1,54 @@ +export const getDelegateQuery = (id: string): Record => ({ + delegate: { + __args: { + id + }, + id: true, + delegatedVotes: true, + tokenHoldersRepresentedAmount: true + }, + governance: { + __args: { + id: 'GOVERNANCE' + }, + delegatedVotes: true, + totalTokenHolders: true, + totalDelegates: true + } +}); + +export const getDelegatesQuery = ( + first: number, + skip: number, + orderBy: string +): Record => ({ + delegates: { + __args: { + first, + skip, + orderBy: orderBy || 'delegatedVotes', + orderDirection: 'desc' + }, + id: true, + delegatedVotes: true, + tokenHoldersRepresentedAmount: true + }, + governance: { + __args: { + id: 'GOVERNANCE' + }, + delegatedVotes: true, + totalTokenHolders: true, + totalDelegates: true + } +}); + +export const getBalanceQuery = (id: string): Record => ({ + tokenHolder: { + __args: { + id + }, + id: true, + tokenBalance: true + } +}); diff --git a/src/helpers/delegationV2/compound/read.ts b/src/helpers/delegationV2/compound/read.ts new file mode 100644 index 000000000000..c625860895a8 --- /dev/null +++ b/src/helpers/delegationV2/compound/read.ts @@ -0,0 +1,147 @@ +import getProvider from '@snapshot-labs/snapshot.js/src/utils/provider'; +import { subgraphRequest, call } from '@snapshot-labs/snapshot.js/src/utils'; +import { DelegateWithPercent, ExtendedSpace } from '@/helpers/interfaces'; +import { DelegatingTo, DelegationReader } from '@/helpers/delegationV2/types'; +import { + getBalanceQuery, + getDelegateQuery, + getDelegatesQuery +} from './queries'; + +type Governance = { + delegatedVotes: string; + totalTokenHolders: string; + totalDelegates: string; +}; + +type Delegate = { + id: string; + delegatedVotes: string; + tokenHoldersRepresentedAmount: number; +}; + +function adjustUrl(apiUrl: string) { + const hostedPattern = + /https:\/\/thegraph\.com\/hosted-service\/subgraph\/([\w-]+)\/([\w-]+)/; + const hostedMatch = apiUrl.match(hostedPattern); + + return hostedMatch + ? `https://api.thegraph.com/subgraphs/name/${hostedMatch[1]}/${hostedMatch[2]}` + : apiUrl; +} + +const emptyDelegate = (address: string): DelegateWithPercent => ({ + id: address, + delegatedVotes: '0', + tokenHoldersRepresentedAmount: 0, + delegatorsPercentage: 0, + votesPercentage: 0 +}); + +const formatDelegatesResponse = (response: any): DelegateWithPercent[] => { + const governanceData = response.governance as Governance; + const delegatesData = response.delegates as Delegate[]; + + return delegatesData.map(delegate => { + const delegatorsPercentage = + Number(delegate.tokenHoldersRepresentedAmount) / + Number(governanceData.totalTokenHolders); + const votesPercentage = + Number(delegate.delegatedVotes) / Number(governanceData.delegatedVotes); + + delegate.id = delegate.id.toLowerCase(); + + return { + ...delegate, + delegatorsPercentage, + votesPercentage + }; + }); +}; + +const formatDelegateResponse = (response: any): DelegateWithPercent => { + const delegate = response.delegate as Delegate; + const governanceData = response.governance as Governance; + + const delegatorsPercentage = + Number(delegate.tokenHoldersRepresentedAmount) / + Number(governanceData.totalTokenHolders); + const votesPercentage = + Number(delegate.delegatedVotes) / Number(governanceData.delegatedVotes); + + return { + ...{ + id: delegate.id.toLowerCase(), + delegatedVotes: delegate?.delegatedVotes || '0', + tokenHoldersRepresentedAmount: + delegate?.tokenHoldersRepresentedAmount || 0 + }, + delegatorsPercentage: delegatorsPercentage || 0, + votesPercentage: votesPercentage || 0 + }; +}; + +const formatBalanceResponse = (response: any): string => + response.tokenHolder?.tokenBalance || '0'; + +const getDelegations = + (space: ExtendedSpace): DelegationReader['getDelegates'] => + async (first: number, skip: number, orderBy: string) => { + const query: any = getDelegatesQuery(first, skip, orderBy); + + const response = await subgraphRequest( + adjustUrl(space.delegationPortal.delegationApi), + query + ); + + return formatDelegatesResponse(response); + }; + +const getDelegate = + (space: ExtendedSpace): DelegationReader['getDelegate'] => + async (address: string) => { + const query: any = getDelegateQuery(address); + + const response = await subgraphRequest( + adjustUrl(space.delegationPortal.delegationApi), + query + ); + + if (!response.delegate) return emptyDelegate(address); + + return formatDelegateResponse(response); + }; + +const getBalance = + (space: ExtendedSpace): DelegationReader['getBalance'] => + async (id: string) => { + const query: any = getBalanceQuery(id.toLowerCase()); + + const response = await subgraphRequest( + adjustUrl(space.delegationPortal.delegationApi), + query + ); + return formatBalanceResponse(response); + }; + +const getDelegatingTo = + (space: ExtendedSpace): DelegationReader['getDelegatingTo'] => + async (address: string): Promise => { + const broviderUrl = import.meta.env.VITE_BROVIDER_URL; + const provider = getProvider(space.network, { broviderUrl }); + const delegates = await call( + provider, + ['function delegates(address) view returns (address)'], + [space.delegationPortal.delegationContract, 'delegates', [address]] + ); + return { delegates }; + }; + +export const getDelegationReader = ( + space: ExtendedSpace +): DelegationReader => ({ + getDelegates: getDelegations(space), + getDelegate: getDelegate(space), + getBalance: getBalance(space), + getDelegatingTo: getDelegatingTo(space) +}); diff --git a/src/helpers/delegationV2/compound/write.ts b/src/helpers/delegationV2/compound/write.ts new file mode 100644 index 000000000000..baa9af309a91 --- /dev/null +++ b/src/helpers/delegationV2/compound/write.ts @@ -0,0 +1,26 @@ +import { sendTransaction } from '@snapshot-labs/snapshot.js/src/utils'; +import { DelegationWriter } from '@/helpers/delegationV2/types'; +import { ExtendedSpace } from '@/helpers/interfaces'; + +const sendSetDelegationTx = + (space: ExtendedSpace, auth: any): DelegationWriter['sendSetDelegationTx'] => + async (addresses: string[]) => { + if (addresses.length !== 1) { + throw new Error('Compound delegation only supports one delegate'); + } + const tx = await sendTransaction( + auth.web3, + space.delegationPortal.delegationContract, + ['function delegate(address delegatee)'], + 'delegate', + [addresses[0]] + ); + return tx; + }; + +export const getDelegationWriter = ( + space: ExtendedSpace, + auth: any +): DelegationWriter => ({ + sendSetDelegationTx: sendSetDelegationTx(space, auth) +}); diff --git a/src/helpers/delegationV2/index.ts b/src/helpers/delegationV2/index.ts new file mode 100644 index 000000000000..755a0a052d68 --- /dev/null +++ b/src/helpers/delegationV2/index.ts @@ -0,0 +1,38 @@ +import { + DelegationReader, + DelegationWriter +} from '@/helpers/delegationV2/types'; +import * as compound from '@/helpers/delegationV2/compound'; +import * as splitDelegation from '@/helpers/delegationV2/splitDelegation'; +import { ExtendedSpace } from '@/helpers/interfaces'; + +export enum DelegationTypes { + COMPOUND = 'compound-governor', + SPLIT_DELEGATION = 'split-delegation' +} + +export function setupDelegation( + space: ExtendedSpace, + auth?: any +): { + reader: DelegationReader; + writer: DelegationWriter; +} { + if ( + space.delegationPortal?.delegationType === + DelegationTypes.SPLIT_DELEGATION && + space.strategies.some( + ({ name }) => name === DelegationTypes.SPLIT_DELEGATION + ) + ) { + return { + reader: splitDelegation.getDelegationReader(space), + writer: splitDelegation.getDelegationWriter(space, auth) + }; + } + + return { + reader: compound.getDelegationReader(space), + writer: compound.getDelegationWriter(space, auth) + }; +} diff --git a/src/helpers/delegationV2/splitDelegation/abi.ts b/src/helpers/delegationV2/splitDelegation/abi.ts new file mode 100644 index 000000000000..783dab4832e5 --- /dev/null +++ b/src/helpers/delegationV2/splitDelegation/abi.ts @@ -0,0 +1,56 @@ +export const abi = [ + { + inputs: [{ internalType: 'string', name: 'context', type: 'string' }], + name: 'clearDelegation', + outputs: [], + stateMutability: 'nonpayable', + type: 'function' + }, + { + inputs: [ + { internalType: 'string', name: 'context', type: 'string' }, + { internalType: 'bool', name: '_optout', type: 'bool' } + ], + name: 'optout', + outputs: [], + stateMutability: 'nonpayable', + type: 'function' + }, + { + inputs: [ + { internalType: 'string', name: 'context', type: 'string' }, + { + components: [ + { internalType: 'bytes32', name: 'delegate', type: 'bytes32' }, + { internalType: 'uint256', name: 'ratio', type: 'uint256' } + ], + internalType: 'struct Delegation[]', + name: 'delegation', + type: 'tuple[]' + }, + { + internalType: 'uint256', + name: 'expirationTimestamp', + type: 'uint256' + } + ], + name: 'setDelegation', + outputs: [], + stateMutability: 'nonpayable', + type: 'function' + }, + { + inputs: [ + { internalType: 'string', name: 'context', type: 'string' }, + { + internalType: 'uint256', + name: 'expirationTimestamp', + type: 'uint256' + } + ], + name: 'setExpiration', + outputs: [], + stateMutability: 'nonpayable', + type: 'function' + } +]; diff --git a/src/helpers/delegationV2/splitDelegation/index.ts b/src/helpers/delegationV2/splitDelegation/index.ts new file mode 100644 index 000000000000..005ee01f00ec --- /dev/null +++ b/src/helpers/delegationV2/splitDelegation/index.ts @@ -0,0 +1,2 @@ +export { getDelegationReader } from './read'; +export { getDelegationWriter } from './write'; diff --git a/src/helpers/delegationV2/splitDelegation/read.ts b/src/helpers/delegationV2/splitDelegation/read.ts new file mode 100644 index 000000000000..bd3bf967bff6 --- /dev/null +++ b/src/helpers/delegationV2/splitDelegation/read.ts @@ -0,0 +1,162 @@ +import { DelegateWithPercent, ExtendedSpace } from '@/helpers/interfaces'; +import { + DelegateTreeItem, + DelegationReader, + DelegatorTreeItem +} from '@/helpers/delegationV2/types'; + +const SPLIT_DELEGATE_BACKEND_URL = 'https://delegate-api.gnosisguild.org'; + +type DelegateFromSD = { + address: string; + delegatorCount: number; + percentOfDelegators: number; + votingPower: number; + percentOfVotingPower: number; +}; + +type AddressResponse = { + chainId: number; + blockNumber: number; + address: string; + votingPower: Record; + percentOfVotingPower: number; + percentOfDelegators: number; + delegates: string[]; + delegateTree: DelegateTreeItem[]; + delegators: string[]; + delegatorTree: DelegatorTreeItem[]; +}; + +// const emptyDelegate = (address: string): DelegateWithPercent => ({ +// id: address, +// delegatedVotes: '0', +// tokenHoldersRepresentedAmount: 0, +// delegatorsPercentage: 0, +// votesPercentage: 0 +// }); + +const bpsToPercent = (bps: number): number => bps / 10000; + +const getDelegations = + (space: ExtendedSpace): DelegationReader['getDelegates'] => + async (first: number, skip: number, matchFilter: string) => { + let orderBy = 'power'; + if (matchFilter === 'tokenHoldersRepresentedAmount') { + orderBy = 'count'; + } + + const splitDelStrategy = space.strategies.find( + strat => strat.name === 'split-delegation' + ); + + const response = (await fetch( + `${ + space.delegationPortal.delegationApi || SPLIT_DELEGATE_BACKEND_URL + }/api/v1/${ + space.id + }/pin/top-delegates?by=${orderBy}&limit=${first}&offset=${skip}`, + { + method: 'POST', + body: JSON.stringify({ + strategy: splitDelStrategy + }) + } + ).then(res => res.json())) as { delegates: DelegateFromSD[] }; + + const formatted: DelegateWithPercent[] = response.delegates.map(d => ({ + id: d.address, + delegatedVotes: d.votingPower.toString(), + tokenHoldersRepresentedAmount: d.delegatorCount, + delegatorsPercentage: bpsToPercent(d.percentOfDelegators), + votesPercentage: bpsToPercent(d.percentOfVotingPower) + })); + + return formatted; + }; + +const getDelegate = + (space: ExtendedSpace): DelegationReader['getDelegate'] => + async (address: string) => { + const splitDelStrategy = space.strategies.find( + strat => strat.name === 'split-delegation' + ); + + const response = (await fetch( + `${ + space.delegationPortal.delegationApi || SPLIT_DELEGATE_BACKEND_URL + }/api/v1/${space.id}/pin/${address}`, + { + method: 'POST', + body: JSON.stringify({ + strategy: splitDelStrategy + }) + } + ).then(res => res.json())) as AddressResponse; + + const formatted: DelegateWithPercent = { + id: address, + delegatedVotes: response.votingPower.toString(), + tokenHoldersRepresentedAmount: response.delegators.length, + delegatorsPercentage: bpsToPercent(response.percentOfDelegators), + votesPercentage: bpsToPercent(response.percentOfVotingPower) + }; + + return formatted; + }; + +const getBalance = + (space: ExtendedSpace): DelegationReader['getBalance'] => + async (address: string) => { + const splitDelStrategy = space.strategies.find( + strat => strat.name === 'split-delegation' + ); + + const response = (await fetch( + `${ + space.delegationPortal.delegationApi || SPLIT_DELEGATE_BACKEND_URL + }/api/v1/${space.id}/pin/${address}`, + { + method: 'POST', + body: JSON.stringify({ + strategy: splitDelStrategy + }) + } + ).then(res => res.json())) as DelegateFromSD; + + return response.votingPower.toString(); + }; + +const getDelegatingTo = + (space: ExtendedSpace): DelegationReader['getDelegatingTo'] => + async (address: string) => { + const splitDelStrategy = space.strategies.find( + strat => strat.name === 'split-delegation' + ); + + const response = (await fetch( + `${ + space.delegationPortal.delegationApi || SPLIT_DELEGATE_BACKEND_URL + }/api/v1/${space.id}/pin/${address}`, + { + method: 'POST', + body: JSON.stringify({ + strategy: splitDelStrategy + }) + } + ).then(res => res.json())) as AddressResponse; + + return { + delegates: response.delegates, + delegateTree: response.delegateTree + }; + }; + +export const getDelegationReader = ( + space: ExtendedSpace +): DelegationReader => ({ + getDelegates: getDelegations(space), + getDelegate: getDelegate(space), + getBalance: getBalance(space), + getDelegatingTo: getDelegatingTo(space) +}); diff --git a/src/helpers/delegationV2/splitDelegation/write.ts b/src/helpers/delegationV2/splitDelegation/write.ts new file mode 100644 index 000000000000..0abe49b67e23 --- /dev/null +++ b/src/helpers/delegationV2/splitDelegation/write.ts @@ -0,0 +1,79 @@ +import { sendTransaction } from '@snapshot-labs/snapshot.js/src/utils'; +import { hexZeroPad } from '@ethersproject/bytes'; +import { DelegationWriter } from '@/helpers/delegationV2/types'; +import { ExtendedSpace } from '@/helpers/interfaces'; +import { abi } from './abi'; + +const DELEGATION_CONTRACT = '0xDE1e8A7E184Babd9F0E3af18f40634e9Ed6F0905'; //All chains + +const sendSetDelegationTx = + (space: ExtendedSpace, auth: any): DelegationWriter['sendSetDelegationTx'] => + async (addresses, ratio, expirationTimestamp) => { + const delegationContract = + space.delegationPortal.delegationContract || DELEGATION_CONTRACT; + console.log('sendSetDelegationTx', addresses, ratio, expirationTimestamp); + if (addresses.length <= 0) { + throw new Error('Delegation must have at least one delegate'); + } + + if (addresses.length !== ratio?.length) { + throw new Error( + 'Delegation must have the same number of delegates and ratios' + ); + } + + if (expirationTimestamp == null) { + throw new Error('Delegation must have an expiration timestamp'); + } + + if ( + expirationTimestamp && + expirationTimestamp < Math.floor(Date.now() / 1000) + ) { + throw new Error('Delegation expiration must be in the future'); + } + + const delegations = addresses + .map((address, index) => ({ + delegate: hexZeroPad(address, 32), + ratio: ratio[index] + })) + .sort((a, b) => { + return BigInt(a.delegate) < BigInt(b.delegate) ? -1 : 1; + }); + console.log('delegations', delegations); + const tx = await sendTransaction( + auth.web3, + delegationContract, + abi, + 'setDelegation', + [space.id, delegations, expirationTimestamp] //space.id should be the ENS name + ); + return tx; + }; + +const sendClearDelegationsTx = + ( + space: ExtendedSpace, + auth: any + ): DelegationWriter['sendClearDelegationsTx'] => + async () => { + const delegationContract = + space.delegationPortal.delegationContract || DELEGATION_CONTRACT; + const tx = await sendTransaction( + auth.web3, + delegationContract, + abi, + 'clearDelegation', + [space.id] //space.id should be the ENS name + ); + return tx; + }; + +export const getDelegationWriter = ( + space: ExtendedSpace, + auth: any +): DelegationWriter => ({ + sendSetDelegationTx: sendSetDelegationTx(space, auth), + sendClearDelegationsTx: sendClearDelegationsTx(space, auth) +}); diff --git a/src/helpers/delegationV2/types.ts b/src/helpers/delegationV2/types.ts new file mode 100644 index 000000000000..77751ee2b332 --- /dev/null +++ b/src/helpers/delegationV2/types.ts @@ -0,0 +1,40 @@ +import { DelegateWithPercent } from '@/helpers/interfaces'; + +export type DelegationReader = { + getDelegates( + first: number, + skip: number, + orderBy: string + ): Promise; + getDelegate(id: string): Promise; + getBalance(id: string): Promise; + getDelegatingTo(address: string): Promise; +}; + +export type DelegationWriter = { + sendSetDelegationTx: ( + addresses: string[], + ratio?: number[], + expirationTimestamp?: number + ) => Promise; + sendClearDelegationsTx?: () => Promise; +}; + +export type DelegatingTo = { + delegates: string[]; + delegateTree?: DelegateTreeItem[]; +}; + +export type DelegateTreeItem = { + delegate: string; + weight: number; + delegatedPower: number; + children: DelegateTreeItem[]; +}; + +export type DelegatorTreeItem = { + delegator: string; + weight: number; + delegatedPower: number; + parents: []; +}; diff --git a/src/helpers/interfaces.ts b/src/helpers/interfaces.ts index 20980e948ff6..dc64722b030e 100644 --- a/src/helpers/interfaces.ts +++ b/src/helpers/interfaces.ts @@ -1,5 +1,6 @@ import { BigNumber } from '@ethersproject/bignumber'; import { Fragment, JsonFragment } from '@ethersproject/abi'; +import { DelegationTypes } from '@/helpers/delegationV2'; export interface Strategy { id: string; @@ -186,7 +187,7 @@ export interface ExtendedSpace { } export interface DelegatesConfig { - delegationType: string; + delegationType: DelegationTypes; delegationContract: string; delegationApi: string; } diff --git a/src/helpers/queries.ts b/src/helpers/queries.ts index 239aa05196ba..3e7ad4c4ab30 100644 --- a/src/helpers/queries.ts +++ b/src/helpers/queries.ts @@ -526,19 +526,3 @@ export const SPACE_QUERY = gql` } } `; - -export const DELEGATE_VOTES_AND_PROPOSALS = gql` - query VotesAndProposals($delegates: [String]!, $space: String!) { - votes(first: 1000, where: { voter_in: $delegates, space: $space }) { - created - voter - choice - vp - } - proposals(first: 1000, where: { author_in: $delegates, space: $space }) { - created - author - title - } - } -`; diff --git a/src/helpers/validation.ts b/src/helpers/validation.ts index 56b3d03acef6..eb330094f330 100644 --- a/src/helpers/validation.ts +++ b/src/helpers/validation.ts @@ -15,6 +15,8 @@ function getErrorMessage(errorObject): string { return 'Must be a valid URL.'; case 'uri': return 'Must be a valid URL.'; + case 'percentage': + return 'Percentage must be between 0 and 100.'; default: return 'Invalid format.'; } diff --git a/src/locales/default.json b/src/locales/default.json index 7526bf7ec792..7e226caf28a1 100644 --- a/src/locales/default.json +++ b/src/locales/default.json @@ -251,7 +251,7 @@ }, "delegateModal": { "title": "Delegate", - "sub": "You are about to delegate " + "sub": "You are about to delegate all of your voting power." }, "profileModal": { "title": "Profile" diff --git a/src/views/SpaceDelegate.vue b/src/views/SpaceDelegate.vue index e908bec0c479..355a1dacfbaf 100644 --- a/src/views/SpaceDelegate.vue +++ b/src/views/SpaceDelegate.vue @@ -2,6 +2,8 @@ import { ExtendedSpace } from '@/helpers/interfaces'; import { useConfirmDialog } from '@vueuse/core'; import { clone } from '@snapshot-labs/snapshot.js/src/utils'; +import { DelegatingTo } from '../helpers/delegationV2/types'; +import { DelegationTypes } from '@/helpers/delegationV2'; const INITIAL_STATEMENT = { about: '', @@ -24,15 +26,20 @@ const { delegate, delegatesStats, isLoadingDelegate, - isLoadingDelegatingTo + isLoadingDelegatingTo, + hasDelegationPortal } = useDelegates(props.space); -const { reloadStatement, getStatement, formatPercentageNumber } = - useStatement(); +const { + reloadStatement, + getStatement, + formatPercentageNumber, + loadingStatements +} = useStatement(); const { modalAccountOpen } = useModal(); const showEdit = ref(false); const showDelegateModal = ref(false); -const web3AccountDelegatingTo = ref(''); +const web3AccountDelegatingTo = ref(); const fetchedStatement = ref(INITIAL_STATEMENT); const statementForm = ref(INITIAL_STATEMENT); @@ -51,7 +58,7 @@ const isLoggedUser = computed(() => { const showUndelegate = computed(() => { return ( - web3AccountDelegatingTo.value?.toLowerCase() === + web3AccountDelegatingTo.value?.[0]?.toLowerCase() === address.value?.toLowerCase() ); }); @@ -113,6 +120,8 @@ async function handleReload() { async function init() { loadDelegatingTo(); await loadDelegate(address.value); + + await reloadStatement(props.space.id, address.value); statementForm.value = getStatement(address.value); fetchedStatement.value = getStatement(address.value); } @@ -126,13 +135,17 @@ function handleClickDelegate() { showDelegateModal.value = true; } -watch(address, init, { - immediate: true -}); +watch( + address, + addr => { + showEdit.value = false; -watch(address, () => { - showEdit.value = false; -}); + if (addr) init(); + }, + { + immediate: true + } +); watch(web3Account, async () => { loadDelegatingTo(); @@ -189,7 +202,7 @@ onBeforeRouteLeave(async () => {