Skip to content

Commit

Permalink
feat: add my delegatee to Delegates page (#1110)
Browse files Browse the repository at this point in the history
* feat: fetch delegatee for onchain delegations

* feat: display voted amound and share

* feat: add undelegate action

* feat: add delegation registry delegatee

* feat: get delegate registry VP

* fix: user where filter

In TheGraph's GraphQL not passing variable it will be used anyway.

* fix: handle evm null address

* fix: use network specific delegation subgraph

* fix: undelegate via clearDelegate

* feat: show undelegate progress

* feat: disable undelegate for erc20 votes if already delegating to self

* refactor: use vue-query to load delegatee

* feat: undelegate to zero address instead of self

* fix: make web3.value.account dep reactive

* feat: show 0 balance delegations

* feat: show undelegate option for current delegatee
  • Loading branch information
Sekhmet authored Jan 27, 2025
1 parent cdc9ef9 commit 8186f40
Show file tree
Hide file tree
Showing 8 changed files with 462 additions and 50 deletions.
267 changes: 254 additions & 13 deletions apps/ui/src/components/SpaceDelegates.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
<script setup lang="ts">
import { sanitizeUrl } from '@braintree/sanitize-url';
import { useInfiniteQuery } from '@tanstack/vue-query';
import { getAddress } from '@ethersproject/address';
import {
useInfiniteQuery,
useQuery,
useQueryClient
} from '@tanstack/vue-query';
import removeMarkdown from 'remove-markdown';
import { getDelegationNetwork } from '@/helpers/delegation';
import { getGenericExplorerUrl } from '@/helpers/explorer';
import { _n, _p, _vp, shorten } from '@/helpers/utils';
import { getNames } from '@/helpers/stamp';
import { _n, _p, _vp, compareAddresses, shorten } from '@/helpers/utils';
import { getNetwork, supportsNullCurrent } from '@/networks';
import { SNAPSHOT_URLS } from '@/networks/offchain';
import { DelegationType, Space, SpaceMetadataDelegation } from '@/types';
import { RequiredProperty, Space, SpaceMetadataDelegation } from '@/types';
const props = defineProps<{
space: Space;
Expand All @@ -14,6 +22,8 @@ const props = defineProps<{
const delegateModalOpen = ref(false);
const delegateModalState = ref<{ delegatee: string } | null>(null);
const isUndelegating = ref(false);
const undelegateFn = ref(undelegate);
const sortBy = ref(
'delegatedVotes-desc' as
| 'delegatedVotes-desc'
Expand All @@ -22,15 +32,22 @@ const sortBy = ref(
| 'tokenHoldersRepresentedAmount-asc'
);
const { setTitle } = useTitle();
const { getDelegates } = useDelegates(
props.delegation.apiType as DelegationType,
props.delegation.apiUrl as string,
props.delegation.contractAddress as string,
const { getDelegates, getDelegation } = useDelegates(
props.delegation as RequiredProperty<typeof props.delegation>,
props.space
);
const { getDelegatee } = useActions();
const { getCurrent } = useMetaStore();
const { web3 } = useWeb3();
const actions = useActions();
const queryClient = useQueryClient();
const spaceKey = computed(() => `${props.space.network}:${props.space.id}`);
const delegationNetworkId = computed(() => {
if (!props.delegation.chainId) return null;
return getDelegationNetwork(props.delegation.chainId);
});
const {
data,
Expand Down Expand Up @@ -65,6 +82,108 @@ const {
}
});
const { data: delegatee } = useQuery({
queryKey: [
'delegatees',
props.delegation.contractAddress,
() => web3.value.account
],
queryFn: () => getCurrentDelegatee(),
enabled: !!web3.value.account && !web3.value.authLoading
});
async function fetchDelegateRegistryDelegatee() {
const delegation = await getDelegation(web3.value.account);
if (!delegation) return null;
const [names, votingPowers, [apiDelegate]] = await Promise.all([
getNames([delegation.delegate]),
getNetwork(props.space.network).actions.getVotingPower(
props.space.id,
props.space.strategies,
props.space.strategies_params,
props.space.strategies_parsed_metadata,
web3.value.account,
{
at: supportsNullCurrent(props.space.network)
? null
: getCurrent(props.space.network) || 0,
chainId: props.space.snapshot_chain_id
}
),
getDelegates({
first: 1,
skip: 0,
orderBy: 'delegatedVotes',
orderDirection: 'desc',
where: {
// NOTE: this is delegate registry, needs to be checksummed
user: getAddress(delegation.delegate)
}
})
]);
const balance = votingPowers.reduce(
(acc, b) => acc + Number(b.value) / 10 ** b.cumulativeDecimals,
0
);
return {
id: delegation.delegate,
balance,
share: apiDelegate ? balance / Number(apiDelegate.delegatedVotes) : 1,
name: names[delegation.delegate]
};
}
async function fetchGovernorSubgraphDelegatee() {
const delegateeData = await getDelegatee(
props.delegation,
web3.value.account
);
if (!delegateeData) return null;
const [names, [apiDelegate]] = await Promise.all([
getNames([delegateeData.address]),
getDelegates({
first: 1,
skip: 0,
orderBy: 'delegatedVotes',
orderDirection: 'desc',
where: {
// NOTE: This is subgraph, needs to be lowercase
user: delegateeData.address.toLocaleLowerCase()
}
})
]);
return {
id: delegateeData.address,
balance: Number(delegateeData.balance) / 10 ** delegateeData.decimals,
share:
apiDelegate && apiDelegate.delegatedVotesRaw !== '0'
? Number(delegateeData.balance) / Number(apiDelegate.delegatedVotesRaw)
: 1,
name: names[delegateeData.address]
};
}
async function getCurrentDelegatee() {
if (!props.delegation.apiType || !props.delegation.chainId) return null;
if (!web3.value.account) {
return null;
}
if (props.delegation.apiType === 'governor-subgraph') {
return fetchGovernorSubgraphDelegatee();
} else if (props.delegation.apiType === 'delegate-registry') {
return fetchDelegateRegistryDelegatee();
}
}
function getExplorerUrl(address: string, type: 'address' | 'token') {
let url: string | null = null;
if (props.delegation.chainId) {
Expand All @@ -90,11 +209,52 @@ function handleSortChange(
}
}
function handleDelegateClick(delegatee?: string) {
delegateModalState.value = delegatee ? { delegatee } : null;
function handleDelegateToggle(newDelegatee?: string) {
if (
newDelegatee &&
delegatee.value &&
compareAddresses(newDelegatee, delegatee.value.id)
) {
isUndelegating.value = true;
return;
}
delegateModalState.value = newDelegatee ? { delegatee: newDelegatee } : null;
delegateModalOpen.value = true;
}
async function undelegate() {
if (
!props.delegation.apiType ||
!props.delegation.chainId ||
!props.delegation.contractAddress
) {
return null;
}
return actions.delegate(
props.space,
props.delegation.apiType,
null,
props.delegation.contractAddress,
props.delegation.chainId
);
}
function handleUndelegateConfirmed() {
queryClient.invalidateQueries({
queryKey: ['delegates', props.delegation.contractAddress]
});
queryClient.invalidateQueries({
queryKey: [
'delegatees',
props.delegation.contractAddress,
web3.value.account
]
});
}
watchEffect(() => setTitle(`Delegates - ${props.space.name}`));
</script>

Expand Down Expand Up @@ -138,11 +298,73 @@ watchEffect(() => setTitle(`Delegates - ${props.space.name}`));
</UiButton>
<div class="flex-auto" />
<UiTooltip title="Delegate">
<UiButton class="!px-0 w-[46px]" @click="handleDelegateClick()">
<UiButton class="!px-0 w-[46px]" @click="handleDelegateToggle()">
<IH-user-add class="inline-block" />
</UiButton>
</UiTooltip>
</div>

<div v-if="delegatee" class="mb-3">
<UiLabel label="Delegating to" />
<div class="w-full truncate px-4">
<div class="flex w-full space-x-3 truncate border-b py-3">
<AppLink
:to="{
name: 'space-user-statement',
params: {
space: spaceKey,
user: delegatee.id
}
}"
class="w-full flex justify-between items-center"
>
<UiStamp :id="delegatee.id" type="avatar" :size="32" class="mr-3" />
<div class="flex-1 leading-[22px]">
<h4
class="text-skin-link"
v-text="delegatee.name || shorten(delegatee.id)"
/>
<div
class="text-skin-text text-[17px]"
v-text="shorten(delegatee.id)"
/>
</div>
<div
class="w-[150px] flex flex-col sm:shrink-0 text-right justify-center leading-[22px] truncate"
>
<h4 class="text-skin-link truncate">
{{ _vp(delegatee.balance) }}
{{ space.voting_power_symbol }}
</h4>
<div class="text-[17px]" v-text="_p(delegatee.share)" />
</div>
</AppLink>
<div class="flex items-center justify-center">
<UiDropdown>
<template #button>
<UiButton class="!p-0 !border-0 !h-[auto] !bg-transparent">
<IH-dots-horizontal class="text-skin-link" />
</UiButton>
</template>
<template #items>
<UiDropdownItem v-slot="{ active }">
<button
type="button"
class="flex items-center gap-2"
:class="{ 'opacity-80': active }"
@click="isUndelegating = true"
>
<IH-user-remove />
Undelegate
</button>
</UiDropdownItem>
</template>
</UiDropdown>
</div>
</div>
</div>
</div>

<UiLabel label="Delegates" sticky />
<div class="text-left table-fixed w-full">
<div
Expand Down Expand Up @@ -286,10 +508,21 @@ watchEffect(() => setTitle(`Delegates - ${props.space.name}`));
type="button"
class="flex items-center gap-2"
:class="{ 'opacity-80': active }"
@click="handleDelegateClick(delegate.user)"
@click="handleDelegateToggle(delegate.user)"
>
<IH-user-add />
Delegate
<template
v-if="
delegatee &&
compareAddresses(delegate.user, delegatee.id)
"
>
<IH-user-remove />
Undelegate
</template>
<template v-else>
<IH-user-add />
Delegate
</template>
</button>
</UiDropdownItem>
<UiDropdownItem v-slot="{ active }">
Expand Down Expand Up @@ -338,6 +571,14 @@ watchEffect(() => setTitle(`Delegates - ${props.space.name}`));
:initial-state="delegateModalState"
@close="delegateModalOpen = false"
/>
<ModalTransactionProgress
v-if="delegationNetworkId"
:open="isUndelegating"
:network-id="delegationNetworkId"
:execute="undelegateFn"
@confirmed="handleUndelegateConfirmed"
@close="isUndelegating = false"
/>
</teleport>
</template>
</template>
Loading

0 comments on commit 8186f40

Please sign in to comment.