Skip to content

Commit

Permalink
Implement delegation form
Browse files Browse the repository at this point in the history
  • Loading branch information
jmrossy committed Mar 2, 2024
1 parent 8b16de9 commit 1efcf1f
Show file tree
Hide file tree
Showing 14 changed files with 431 additions and 38 deletions.
16 changes: 13 additions & 3 deletions src/app/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { Section } from 'src/components/layout/Section';
import { Amount, formatNumberString } from 'src/components/numbers/Amount';
import { useBalance } from 'src/features/account/hooks';
import { DelegationsTable } from 'src/features/delegation/DelegationsTable';
import { DelegationAmount } from 'src/features/delegation/types';
import { Delegatee, DelegationAmount } from 'src/features/delegation/types';
import { useDelegatees } from 'src/features/delegation/useDelegatees';
import { useDelegationBalances } from 'src/features/delegation/useDelegationBalances';
import { LockActionType, LockedBalances } from 'src/features/locking/types';
import { useLockedStatus } from 'src/features/locking/useLockedStatus';
Expand Down Expand Up @@ -41,6 +42,7 @@ export default function Page() {
const { balance: walletBalance } = useBalance(address);
const { lockedBalances } = useLockedStatus(address);
const { delegations } = useDelegationBalances(address);
const { addressToDelegatee } = useDelegatees();
const { stakeBalances, groupToStake, refetch: refetchStakes } = useStakingBalances(address);
const { totalRewardsWei, groupToReward } = useStakingRewards(address, groupToStake);
const { groupToIsActivatable, refetch: refetchActivations } = usePendingStakingActivations(
Expand All @@ -56,7 +58,7 @@ export default function Page() {

const totalLocked = getTotalLockedCelo(lockedBalances);
const totalBalance = (walletBalance || 0n) + totalLocked;
const totalDelegated = (delegations?.percentDelegated || 0n) * totalLocked;
const totalDelegated = (delegations?.totalPercent || 0n) * totalLocked;

return (
<Section className="mt-6" containerClassName="space-y-6 px-4">
Expand All @@ -82,6 +84,7 @@ export default function Page() {
groupToReward={groupToReward}
groupToIsActivatable={groupToIsActivatable}
delegateeToAmount={delegations?.delegateeToAmount}
addressToDelegatee={addressToDelegatee}
activateStake={activateStake}
/>
</Section>
Expand Down Expand Up @@ -189,13 +192,15 @@ function TableTabs({
groupToReward,
groupToIsActivatable,
delegateeToAmount,
addressToDelegatee,
activateStake,
}: {
groupToStake?: GroupToStake;
addressToGroup?: AddressTo<ValidatorGroup>;
groupToReward?: AddressTo<number>;
groupToIsActivatable?: AddressTo<boolean>;
delegateeToAmount?: AddressTo<DelegationAmount>;
addressToDelegatee?: AddressTo<Delegatee>;
activateStake: (g: Address) => void;
}) {
const [tab, setTab] = useState<'stakes' | 'rewards' | 'delegations'>('stakes');
Expand Down Expand Up @@ -224,7 +229,12 @@ function TableTabs({
{tab === 'rewards' && (
<RewardsTable groupToReward={groupToReward} addressToGroup={addressToGroup} />
)}
{tab === 'delegations' && <DelegationsTable delegateeToAmount={delegateeToAmount} />}
{tab === 'delegations' && (
<DelegationsTable
delegateeToAmount={delegateeToAmount}
addressToDelegatee={addressToDelegatee}
/>
)}
</div>
);
}
37 changes: 37 additions & 0 deletions src/components/input/RangeField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Field } from 'formik';

export function RangeField({
name,
label,
maxValue,
maxDescription,
disabled,
}: {
name: string;
label: string;
maxValue: number;
maxDescription: string;
disabled?: boolean;
}) {
return (
<div>
<div className="flex items-center justify-between">
<label htmlFor={name} className="pl-0.5 text-xs font-medium">
{label}
</label>
<span className="text-xs">{`${maxDescription} ${maxValue}`}</span>
</div>
<div className="relative mt-2">
<Field
name={name}
type="range"
min={0}
max={100}
step={1}
className="range range-secondary rounded-full"
disabled={disabled}
/>
</div>
</div>
);
}
257 changes: 257 additions & 0 deletions src/features/delegation/DelegationForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import { Form, Formik, FormikErrors, useFormikContext } from 'formik';
import { useEffect } from 'react';
import { MultiTxFormSubmitButton } from 'src/components/buttons/MultiTxFormSubmitButton';
import { RadioField } from 'src/components/input/RadioField';
import { RangeField } from 'src/components/input/RangeField';
import { TextField } from 'src/components/input/TextField';
import { MAX_NUM_DELEGATEES, ZERO_ADDRESS } from 'src/config/consts';
import { getDelegateTxPlan } from 'src/features/delegation/delegatePlan';
import {
DelegateActionType,
DelegateActionValues,
DelegateFormValues,
Delegatee,
DelegationBalances,
} from 'src/features/delegation/types';
import { useDelegatees } from 'src/features/delegation/useDelegatees';
import { useDelegationBalances } from 'src/features/delegation/useDelegationBalances';
import { LockedBalances } from 'src/features/locking/types';
import { OnConfirmedFn } from 'src/features/transactions/types';
import { useTransactionPlan } from 'src/features/transactions/useTransactionPlan';
import { useWriteContractWithReceipt } from 'src/features/transactions/useWriteContractWithReceipt';
import { cleanGroupName } from 'src/features/validators/utils';

import { isValidAddress, shortenAddress } from 'src/utils/addresses';
import { objLength } from 'src/utils/objects';
import { toTitleCase } from 'src/utils/strings';
import { useAccount } from 'wagmi';

const initialValues: DelegateFormValues = {
action: DelegateActionType.Delegate,
percent: 0,
delegatee: '' as Address,
transferDelegatee: '' as Address,
};

export function DelegationForm({
defaultFormValues,
onConfirmed,
}: {
defaultFormValues?: Partial<DelegateFormValues>;
onConfirmed: OnConfirmedFn;
}) {
const { address } = useAccount();
const { addressToDelegatee } = useDelegatees();
const { delegations, refetch } = useDelegationBalances(address);

const { getNextTx, txPlanIndex, numTxs, isPlanStarted, onTxSuccess } =
useTransactionPlan<DelegateFormValues>({
createTxPlan: (v) => getDelegateTxPlan(v, delegations),
onStepSuccess: () => refetch(),
onPlanSuccess: (v, r) =>
onConfirmed({
message: `${v.action} successful`,
receipt: r,
properties: [
{ label: 'Action', value: toTitleCase(v.action) },
{
label: 'Delegatee',
value: addressToDelegatee?.[v.delegatee]?.name || shortenAddress(v.delegatee),
},
{ label: 'Percent', value: `${v.percent} %` },
],
}),
});

const { writeContract, isLoading } = useWriteContractWithReceipt('delegation', onTxSuccess);
const isInputDisabled = isLoading || isPlanStarted;

const onSubmit = (values: DelegateFormValues) => writeContract(getNextTx(values));

const validate = (values: DelegateFormValues) => {
if (!delegations) return { amount: 'Form data not ready' };
if (txPlanIndex > 0) return {};
return validateForm(values, delegations);
};

return (
<Formik<DelegateFormValues>
initialValues={{
...initialValues,
...defaultFormValues,
}}
onSubmit={onSubmit}
validate={validate}
validateOnChange={false}
validateOnBlur={false}
>
{({ values }) => (
<Form className="mt-4 flex flex-1 flex-col justify-between">
<div className="space-y-4">
<ActionTypeField defaultAction={defaultFormValues?.action} disabled={isInputDisabled} />
<DelegateeField
fieldName="delegatee"
label={
values.action === DelegateActionType.Transfer ? 'From delegatee' : 'Delegate to'
}
addressToDelegatee={addressToDelegatee}
defaultValue={defaultFormValues?.delegatee}
disabled={isInputDisabled}
/>
{values.action === DelegateActionType.Transfer && (
<DelegateeField
fieldName="transferDelegatee"
label="To delegatee"
addressToDelegatee={addressToDelegatee}
disabled={isInputDisabled}
/>
)}
<PercentField delegations={delegations} disabled={isInputDisabled} />
</div>
<MultiTxFormSubmitButton
txIndex={txPlanIndex}
numTxs={numTxs}
isLoading={isLoading}
loadingText={ActionToVerb[values.action]}
tipText={ActionToTipText[values.action]}
>
{`${toTitleCase(values.action)}`}
</MultiTxFormSubmitButton>
</Form>
)}
</Formik>
);
}

function ActionTypeField({
defaultAction,
disabled,
}: {
defaultAction?: DelegateActionType;
disabled?: boolean;
}) {
return (
<RadioField<DelegateActionType>
name="action"
values={DelegateActionValues}
defaultValue={defaultAction}
disabled={disabled}
/>
);
}

function PercentField({
delegations,
disabled,
}: {
lockedBalances?: LockedBalances;
delegations?: DelegationBalances;
disabled?: boolean;
}) {
const { values } = useFormikContext<DelegateFormValues>();
const { action, delegatee } = values;
const maxPercent = getMaxPercent(action, delegatee, delegations);

return (
<RangeField
name="percent"
label={`${values.percent}% voting power`}
maxValue={maxPercent}
maxDescription="Available:"
disabled={disabled}
/>
);
}

function DelegateeField({
fieldName,
label,
addressToDelegatee,
defaultValue,
disabled,
}: {
fieldName: 'delegatee' | 'transferDelegatee';
label: string;
addressToDelegatee?: AddressTo<Delegatee>;
defaultValue?: Address;
disabled?: boolean;
}) {
const { values, setFieldValue } = useFormikContext<DelegateFormValues>();
useEffect(() => {
setFieldValue(fieldName, defaultValue || '');
}, [fieldName, defaultValue, setFieldValue]);

const currentDelegatee = addressToDelegatee?.[values[fieldName]];
const delegateeName = cleanGroupName(currentDelegatee?.name || '');

return (
<div className="relative flex flex-col space-y-1.5">
<label htmlFor={fieldName} className="pl-0.5 text-xs font-medium">
{label}
</label>
<TextField name={fieldName} disabled={disabled} className="px-2 text-xs" />
{currentDelegatee && (
<div className="bg-taupe-200 p-2 text-sm">
{/* TODO */}
{delegateeName}
</div>
)}
</div>
);
}

function validateForm(
values: DelegateFormValues,
delegations: DelegationBalances,
): FormikErrors<DelegateFormValues> {
const { action, percent, delegatee, transferDelegatee } = values;
const { delegateeToAmount } = delegations;

if (!delegatee || delegatee === ZERO_ADDRESS) return { delegatee: 'Delegatee required' };

if (action === DelegateActionType.Delegate) {
if (!isValidAddress(delegatee)) return { delegatee: 'Invalid address' };
if (!delegateeToAmount[delegatee] && objLength(delegateeToAmount) >= MAX_NUM_DELEGATEES)
return { delegatee: `Max number of delegatees is ${MAX_NUM_DELEGATEES}` };
}

if (action === DelegateActionType.Transfer) {
if (!transferDelegatee || transferDelegatee === ZERO_ADDRESS)
return { transferDelegatee: 'Transfer group required' };
if (!isValidAddress(transferDelegatee))
return { transferDelegatee: 'Invalid transfer address' };
if (transferDelegatee === delegatee)
return { transferDelegatee: 'Delegatees must be different' };
}

if (!percent || percent <= 0 || percent > 100) return { percent: 'Invalid percent' };
const maxPercent = getMaxPercent(action, delegatee, delegations);
if (percent > maxPercent) return { percent: 'Percent exceeds max' };

return {};
}

function getMaxPercent(
action: DelegateActionType,
delegatee: Address,
delegations?: DelegationBalances,
) {
if (action === DelegateActionType.Delegate) {
return 100 - (delegations?.totalPercent || 0);
} else if (action === DelegateActionType.Undelegate || action === DelegateActionType.Transfer) {
if (!delegatee || !delegations?.delegateeToAmount[delegatee]) return 0;
return delegations.delegateeToAmount[delegatee].percent;
} else {
return 0;
}
}

const ActionToVerb: Partial<Record<DelegateActionType, string>> = {
[DelegateActionType.Delegate]: 'Delegating',
[DelegateActionType.Transfer]: 'Transferring',
[DelegateActionType.Undelegate]: 'Undelegating',
};

const ActionToTipText: Partial<Record<DelegateActionType, string>> = {
[DelegateActionType.Transfer]: 'Transfers require undelegating and then redelegating.',
};
Loading

0 comments on commit 1efcf1f

Please sign in to comment.