Skip to content

Commit

Permalink
SOV-3945: prevent creation of "dead vestings" (#964)
Browse files Browse the repository at this point in the history
* feat: custom row wrapper for table component

* feat: add alert message

* chore: fix review comments

* chore: add changeset

---------

Co-authored-by: soulBit <[email protected]>
  • Loading branch information
creed-victor and soulBit authored Aug 1, 2024
1 parent 68e9c96 commit d0899e6
Show file tree
Hide file tree
Showing 17 changed files with 368 additions and 49 deletions.
5 changes: 5 additions & 0 deletions .changeset/orange-lizards-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sovryn/ui': patch
---

feat: allow custom row wrapper in table component
5 changes: 5 additions & 0 deletions .changeset/real-paws-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"frontend": patch
---

SOV-3945: prevent "dead vestings" from LM rewards
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import { Outlet } from 'react-router-dom';

import { applyDataAttr } from '@sovryn/ui';

import { RSK_CHAIN_ID } from '../../../config/chains';

import { DappLocked } from '../../1_atoms/DappLocked/DappLocked';
import { Header, Footer } from '../../3_organisms';
import { UnclaimcedVestingAlert } from '../../5_pages/RewardsPage/components/Vesting/components/UnclaimedVestingAlert/UnclaimedVestingAlert';
import { useIsDappLocked } from '../../../hooks/maintenances/useIsDappLocked';
import { useAccount } from '../../../hooks/useAccount';
import { useCurrentChain } from '../../../hooks/useChainStore';

type PageContainerProps = {
className?: string;
Expand All @@ -21,6 +26,8 @@ export const PageContainer: FC<PageContainerProps> = ({
dataAttribute,
}) => {
const dappLocked = useIsDappLocked();
const { account } = useAccount();
const chainID = useCurrentChain();
return (
<div
className={classNames('flex flex-col flex-grow', className)}
Expand All @@ -31,6 +38,7 @@ export const PageContainer: FC<PageContainerProps> = ({
) : (
<>
<Header />
{account && chainID === RSK_CHAIN_ID && <UnclaimcedVestingAlert />}
<div
className={classNames('my-2 px-4 flex flex-grow', contentClassName)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { translations } from '../../../../../locales/i18n';
import { VestingContractType } from '../../../../../utils/graphql/rsk/generated';
import { columnsConfig } from './Vestings.constants';
import { generateRowTitle } from './Vestings.utils';
import { VestingContextProvider } from './context/VestingContext';
import { useGetVestingContracts } from './hooks/useGetVestingContracts';

const pageSize = DEFAULT_PAGE_SIZE;
Expand Down Expand Up @@ -57,6 +58,7 @@ export const Vesting: FC = () => {
isLoading={loading}
dataAttribute="vesting-rewards-history-table"
rowTitle={generateRowTitle}
rowComponent={VestingContextProvider}
/>
<Pagination
page={page}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const MIN_ALERT_COUNT = 32;
export const LOCK_CLAIM_COUNT = 43;
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react';

import { t } from 'i18next';
import { Link } from 'react-router-dom';

import { Paragraph } from '@sovryn/ui';

import { translations } from '../../../../../../../locales/i18n';
import { useGetUnclaimedUserVestingCount } from '../../hooks/useLmLimit';
import {
LOCK_CLAIM_COUNT,
MIN_ALERT_COUNT,
} from './UnclaimedVestingAlert.constants';

export const UnclaimcedVestingAlert = () => {
const count = useGetUnclaimedUserVestingCount();

if (count > MIN_ALERT_COUNT && count < LOCK_CLAIM_COUNT) {
return (
<div className="bg-error-light bg-opacity-50 p-4 rounded-lg mt-12 my-4 mx-8">
<Paragraph>
{t(translations.unclaimedVestings.text, { value: count })}{' '}
<Link
to="/rewards"
className="underline text-primary-20 hover:text-primary-10"
>
{t(translations.unclaimedVestings.cta)}
</Link>
</Paragraph>
</div>
);
}

return null;
};
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';

import { Button, ButtonStyle, ButtonType } from '@sovryn/ui';

import { VestingContractTableRecord } from '../../Vesting.types';
import { vestingTypeToTitleMapping } from '../../Vestings.utils';
import { useVestingContext } from '../../context/VestingContext';
import { useGetUnlockSchedule } from '../../hooks/useGetUnlockSchedule';
import { UnlockScheduleDialog } from '../UnlockScheduleDialog/UnlockScheduleDialog';

export const UnlockSchedule = (item: VestingContractTableRecord) => {
const update = useVestingContext().update;
const unlockSchedule = useGetUnlockSchedule(item);

const [showDialog, setShowDialog] = useState(false);
Expand All @@ -17,6 +19,10 @@ export const UnlockSchedule = (item: VestingContractTableRecord) => {
[item.type],
);

useEffect(() => {
update(state => (state.item = item));
}, [item, update]);

return (
<>
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,25 @@ import { t } from 'i18next';
import { Button, ButtonType, ButtonStyle } from '@sovryn/ui';

import { translations } from '../../../../../../../locales/i18n';
import { VestingContractType } from '../../../../../../../utils/graphql/rsk/generated';
import { VestingContractTableRecord } from '../../Vesting.types';
import { useVestingContext } from '../../context/VestingContext';
import { useGetUnlockedBalance } from '../../hooks/useGetUnlockedBalance';
import { useHandleWithdraw } from '../../hooks/useHandleWithdraw';
import { LOCK_CLAIM_COUNT } from '../UnclaimedVestingAlert/UnclaimedVestingAlert.constants';

export const WithdrawButton = (item: VestingContractTableRecord) => {
const { count } = useVestingContext();
const handleWithdraw = useHandleWithdraw(item);

const { isLoading, result } = useGetUnlockedBalance(item);
const isDisabled = useMemo(() => !result || result === 0, [result]);
const isDisabled = useMemo(
() =>
!result ||
result === 0 ||
(count >= LOCK_CLAIM_COUNT && item.type === VestingContractType.Rewards),
[count, item.type, result],
);

return (
<div className="flex justify-end w-full md:w-auto h-full pt-3">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React, {
useContext,
createContext,
useState,
useCallback,
FC,
PropsWithChildren,
} from 'react';

import { produce } from 'immer';

import { VestingContractTableRecord } from '../Vesting.types';

export type State = {
count: number;
item?: VestingContractTableRecord;
};

type Update = (state: State) => void;

type Actions = {
update: (handler: Update) => void;
};

const defaultValue: State & Actions = {
count: 0,
item: undefined,
update: () => {},
};

const VestingContext = createContext<State & Actions>(defaultValue);

export function useVestingContext() {
const context = useContext(VestingContext);
if (context === undefined) {
throw new Error(
'useVestingContext must be used within a VestingContextProvider',
);
}
return context;
}

export const VestingContextProvider: FC<PropsWithChildren> = ({ children }) => {
const [state, setState] = useState<State>({
count: 0,
item: undefined,
});

const handleOnChange = useCallback(
(handler: (value: State) => void) => setState(produce(handler)),
[],
);

return (
<VestingContext.Provider value={{ ...state, update: handleOnChange }}>
{children}
</VestingContext.Provider>
);
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';

import dayjs from 'dayjs';

Expand All @@ -8,12 +8,14 @@ import {
VestingContractTableRecord,
VestingHistoryItem,
} from '../Vesting.types';
import { useVestingContext } from '../context/VestingContext';

const MAXIMUM_UNLOCKED_DATES = 2;

export const useGetUnlockSchedule = (
item: VestingContractTableRecord,
): VestingHistoryItem[] | undefined => {
const update = useVestingContext().update;
const { data } = useGetVestingHistoryQuery({
variables: { vestingAddress: item.address },
client: rskClient,
Expand Down Expand Up @@ -49,11 +51,20 @@ export const useGetUnlockSchedule = (
const pastDatesLength = unlockDates?.filter(item => item.isUnlocked).length;

if (!pastDatesLength || pastDatesLength < MAXIMUM_UNLOCKED_DATES) {
return unlockDates;
return { unlockDates, pastDatesLength };
}

return unlockDates.slice(pastDatesLength - MAXIMUM_UNLOCKED_DATES);
return {
unlockDates: unlockDates.slice(pastDatesLength - MAXIMUM_UNLOCKED_DATES),
pastDatesLength,
};
}, [data?.vestingContracts]);

return result;
useEffect(() => {
update(state => {
state.count = result?.pastDatesLength || 0;
});
}, [result?.pastDatesLength, update]);

return result?.unlockDates;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useMemo } from 'react';

import dayjs from 'dayjs';

import { useAccount } from '../../../../../../hooks/useAccount';
import { rskClient } from '../../../../../../utils/clients';
import {
useGetUserVestingsOfTypeQuery,
VestingContractType,
} from '../../../../../../utils/graphql/rsk/generated';

export const useGetUnclaimedUserVestingCount = () => {
const { account } = useAccount();
const { data } = useGetUserVestingsOfTypeQuery({
variables: {
user: account.toLowerCase(),
type: VestingContractType.Rewards,
},
client: rskClient,
});

const result = useMemo(() => {
const stakeHistoryItems = data?.vestingContracts[0]?.stakeHistory?.map(
item => ({
amount: item.amount,
lockedUntil: item.lockedUntil,
}),
);

if (!stakeHistoryItems) {
return 0;
}

const normalizedStakeHistoryItems = stakeHistoryItems.reduce((map, obj) => {
const { lockedUntil, amount } = obj;
map.set(lockedUntil, (map.get(lockedUntil) || 0) + Number(amount));
return map;
}, new Map());

const unlockDates = Array.from(
normalizedStakeHistoryItems,
([lockedUntil, amount]) => ({
lockedUntil,
amount,
isUnlocked: lockedUntil <= dayjs().unix(),
}),
).sort((a, b) => a.lockedUntil - b.lockedUntil);

const pastDatesLength = unlockDates?.filter(item => item.isUnlocked).length;

return pastDatesLength;
}, [data?.vestingContracts]);

return result;
};
4 changes: 4 additions & 0 deletions apps/frontend/src/locales/en/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -2034,5 +2034,9 @@
"title": "Claim your POWA",
"description": "Claim your POWA points"
}
},
"unclaimedVestings": {
"text": "You have {{value}} LM SOV rewards vested stakes that you need to withdraw before claiming more.",
"cta": "Open Rewards page"
}
}
Loading

0 comments on commit d0899e6

Please sign in to comment.