Skip to content

Commit

Permalink
Disable delegation for validators and their signers (#151)
Browse files Browse the repository at this point in the history
  • Loading branch information
shazarre authored Jan 23, 2025
1 parent 74570e5 commit 2ba5d08
Show file tree
Hide file tree
Showing 15 changed files with 880 additions and 37 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0",
"@viem/anvil": "^0.0.10",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"critters": "^0.0.25",
"daisyui": "^4.9.0",
Expand Down
4 changes: 3 additions & 1 deletion src/config/wagmi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export const wagmiConfig = createConfig({
chains: [config.chain],
connectors,
transports: {
[celo.id]: fallback([http(config.chain.rpcUrls.default.http[0]), http(infuraRpcUrl)]),
[celo.id]: fallback([http(config.chain.rpcUrls.default.http[0]), http(infuraRpcUrl)], {
rank: true,
}),
[celoAlfajores.id]: http(config.chain.rpcUrls.default.http[0]),
},
});
Expand Down
2 changes: 1 addition & 1 deletion src/features/account/AccountRegisterForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function AccountRegisterForm({
};

return (
<div className="flex flex-1 flex-col justify-between">
<div className="flex flex-1 flex-col justify-between" data-testid="register-form">
<div className="flex flex-col items-center space-y-4 py-16">
<div className="bounce-and-spin flex items-center justify-center">
<Image className="" src={CeloCube} alt="Loading..." width={80} height={80} />
Expand Down
79 changes: 69 additions & 10 deletions src/features/account/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { accountsABI, lockedGoldABI } from '@celo/abis';
import { validatorsABI } from '@celo/abis-12';
import { useEffect, useState } from 'react';
import { useToastError } from 'src/components/notifications/useToastError';
import { BALANCE_REFRESH_INTERVAL, ZERO_ADDRESS } from 'src/config/consts';
import { Addresses } from 'src/config/contracts';
import { isCel2 } from 'src/utils/is-cel2';
import { isNullish } from 'src/utils/typeof';
import { ReadContractErrorType } from 'viem';
import { useBalance as _useBalance, usePublicClient, useReadContract } from 'wagmi';

export function useBalance(address?: Address) {
Expand Down Expand Up @@ -45,33 +47,90 @@ export function useLockedBalance(address?: Address) {
};
}

// Note, this retrieves the address' info from the Accounts contract
// It has nothing to do with wallets or backend services
export function useAccountDetails(address?: Address) {
export function useVoteSigner(address?: Address, isRegistered?: boolean) {
const {
data: isRegistered,
data: voteSigner,
isError,
isLoading,
error,
refetch,
} = useReadContract({
address: Addresses.Accounts,
abi: accountsABI,
functionName: 'voteSignerToAccount',
args: [address || ZERO_ADDRESS],
query: {
enabled: isRegistered === false,
// The contract will revert if given address is both:
// - not registered
// - not a vote signer
// and hence we don't need to retry in this case, otherwise
// we'll retry up to 3 times
retry: (failureCount: number, error: ReadContractErrorType) => {
if (error.message.includes('reverted')) {
return false;
}

return failureCount < 3;
},
},
});

return {
voteSigner,
isError,
isLoading,
refetch,
};
}

// Note, this retrieves the address' info from the Accounts contract
// It has nothing to do with wallets or backend services
export function useAccountDetails(address?: Address) {
const isAccountResult = useReadContract({
address: Addresses.Accounts,
abi: accountsABI,
functionName: 'isAccount',
args: [address || ZERO_ADDRESS],
query: { enabled: !!address },
});

const isValidatorResult = useReadContract({
address: Addresses.Validators,
abi: validatorsABI,
functionName: 'isValidator',
args: [address || ZERO_ADDRESS],
query: { enabled: !!address },
});

const isValidatorGroupResult = useReadContract({
address: Addresses.Validators,
abi: validatorsABI,
functionName: 'isValidatorGroup',
args: [address || ZERO_ADDRESS],
query: { enabled: !!address },
});

// Note, more reads can be added here if more info is needed, such
// as name, metadataUrl, walletAddress, voteSignerToAccount, etc.

useToastError(error, 'Error fetching account registration status');
useToastError(
isAccountResult.error || isValidatorResult.error || isValidatorGroupResult.error,
'Error fetching account details',
);

return {
isRegistered,
isError,
isLoading,
refetch,
isRegistered: isAccountResult.data,
isValidator: isValidatorResult.data,
isValidatorGroup: isValidatorGroupResult.data,
isError: isAccountResult.isError || isValidatorResult.isError || isValidatorGroupResult.isError,
isLoading:
isAccountResult.isLoading || isValidatorResult.isLoading || isValidatorGroupResult.isLoading,
refetch: () =>
Promise.all([
isAccountResult.refetch(),
isValidatorResult.refetch(),
isValidatorGroupResult.refetch(),
]),
};
}

Expand Down
151 changes: 151 additions & 0 deletions src/features/delegation/DelegationForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { render, waitFor } from '@testing-library/react';
import * as hooks from 'src/features/account/hooks';
import { DelegationForm } from 'src/features/delegation/DelegationForm';
import * as useDelegatees from 'src/features/delegation/hooks/useDelegatees';
import * as useDelegationBalances from 'src/features/delegation/hooks/useDelegationBalances';
import * as useWriteContractWithReceipt from 'src/features/transactions/useWriteContractWithReceipt';
import { TEST_ADDRESSES } from 'src/test/anvil/constants';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as wagmi from 'wagmi';

vi.mock('wagmi', async (importActual) => ({
...(await importActual()),
}));

describe('<DelegationForm />', () => {
beforeEach(async () => {
vi.spyOn(wagmi, 'useAccount').mockReturnValue({
isLoading: false,
address: TEST_ADDRESSES[0],
} as any);
vi.spyOn(wagmi, 'useReadContract').mockRejectedValue({ data: undefined });
vi.spyOn(useWriteContractWithReceipt, 'useWriteContractWithReceipt').mockReturnValue({} as any);
});

afterEach(() => {
vi.clearAllMocks();
});

it('shows delegate button for a registered account', async () => {
setupHooks({ isRegistered: true });

const form = render(<DelegationForm onConfirmed={() => {}} />);

await waitFor(async () => await form.findByTestId('delegate-form-submit'));

expect(form.queryByTestId('delegate-form-submit')?.getAttribute('disabled')).toBeNull();
expect(form.queryByTestId('delegate-form-warning')).toBeFalsy();
});

it('does not show delegate button for a vote signer for a validator', async () => {
setupHooks({
voteSignerForAddress: TEST_ADDRESSES[1],
isValidator: true,
});

const form = render(<DelegationForm onConfirmed={() => {}} />);

await waitFor(async () => await form.findByTestId('delegate-form-submit'));

expect(form.queryByTestId('delegate-form-submit')?.getAttribute('disabled')).toEqual('');
expect(form.queryByTestId('delegate-form-warning')).toBeTruthy();
});

it('does not show delegate button for a vote signer for a validator group', async () => {
setupHooks({
voteSignerForAddress: TEST_ADDRESSES[1],
isValidatorGroup: true,
});

const form = render(<DelegationForm onConfirmed={() => {}} />);

await waitFor(async () => await form.findByTestId('delegate-form-submit'));

expect(form.queryByTestId('delegate-form-submit')?.getAttribute('disabled')).toEqual('');
expect(form.queryByTestId('delegate-form-warning')).toBeTruthy();
});

it('does not show delegate button for a validator', async () => {
setupHooks({ isRegistered: true, isValidator: true });

const form = render(<DelegationForm onConfirmed={() => {}} />);

await waitFor(async () => await form.findByTestId('delegate-form-submit'));

expect(form.queryByTestId('delegate-form-submit')?.getAttribute('disabled')).toEqual('');
expect(form.queryByTestId('delegate-form-warning')).toBeTruthy();
});

it('does not show delegate button for a validator group', async () => {
setupHooks({ isRegistered: true, isValidatorGroup: true });

const form = render(<DelegationForm onConfirmed={() => {}} />);

await waitFor(async () => await form.findByTestId('delegate-form-submit'));

expect(form.queryByTestId('delegate-form-submit')?.getAttribute('disabled')).toEqual('');
expect(form.queryByTestId('delegate-form-warning')).toBeTruthy();
});
});

type SetupHooksOptions = {
isRegistered?: boolean;
isValidator?: boolean;
isValidatorGroup?: boolean;
lockedGoldBalance?: bigint;
voteSignerForAddress?: string;
};

// Mocks all necessary hooks for the DelegationForm component
const setupHooks = (options?: SetupHooksOptions) => {
if (options?.voteSignerForAddress && options?.isRegistered === true) {
throw new Error('Cannot provide voteSignerForAddress for not-registered account');
}

vi.spyOn(hooks, 'useBalance').mockReturnValue({
balance: 0n,
isError: false,
isLoading: false,
} as any);

vi.spyOn(useDelegatees, 'useDelegatees').mockReturnValue({} as any);
vi.spyOn(useDelegationBalances, 'useDelegationBalances').mockReturnValue({} as any);

if (options?.voteSignerForAddress) {
// First call is for the address itself
vi.spyOn(hooks, 'useAccountDetails')
.mockReturnValueOnce({
isError: false,
isLoading: false,
isRegistered: false,
isValidator: false,
isValidatorGroup: false,
} as any)
// Second for the address the address is signing for
.mockReturnValueOnce({
isError: false,
isLoading: false,
isRegistered: true,
isValidator: options?.isValidator === true || false,
isValidatorGroup: options?.isValidatorGroup === true || false,
} as any);
} else {
vi.spyOn(hooks, 'useAccountDetails').mockReturnValue({
isError: false,
isLoading: false,
isRegistered: options?.isRegistered === true || false,
isValidator: options?.isValidator === true || false,
isValidatorGroup: options?.isValidatorGroup === true || false,
} as any);
}

vi.spyOn(hooks, 'useLockedBalance').mockReturnValue({
lockedBalance: options?.lockedGoldBalance || 0n,
isLoading: false,
} as any);

vi.spyOn(hooks, 'useVoteSigner').mockReturnValue({
isLoading: false,
voteSigner: options?.isRegistered === true ? TEST_ADDRESSES[0] : options?.voteSignerForAddress,
} as any);
};
50 changes: 39 additions & 11 deletions src/features/delegation/DelegationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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 { useAccountDetails, useVoteSigner } from 'src/features/account/hooks';
import { getDelegateTxPlan } from 'src/features/delegation/delegatePlan';
import { useDelegatees } from 'src/features/delegation/hooks/useDelegatees';
import { useDelegationBalances } from 'src/features/delegation/hooks/useDelegationBalances';
Expand Down Expand Up @@ -42,7 +43,11 @@ export function DelegationForm({
}) {
const { address } = useAccount();
const { addressToDelegatee } = useDelegatees();
const { delegations, refetch } = useDelegationBalances(address);
const { isValidator, isValidatorGroup, isRegistered } = useAccountDetails(address);
const { voteSigner } = useVoteSigner(address, isRegistered);
const { delegations, refetch } = useDelegationBalances(address, voteSigner);
const { isValidator: isVoteSignerForValidator, isValidatorGroup: isVoteSignerForValidatorGroup } =
useAccountDetails(voteSigner);

const { getNextTx, txPlanIndex, numTxs, isPlanStarted, onTxSuccess } =
useTransactionPlan<DelegateFormValues>({
Expand All @@ -65,6 +70,11 @@ export function DelegationForm({

const { writeContract, isLoading } = useWriteContractWithReceipt('delegation', onTxSuccess);
const isInputDisabled = isLoading || isPlanStarted;
const canDelegate =
!isValidator &&
!isValidatorGroup &&
!isVoteSignerForValidator &&
!isVoteSignerForValidatorGroup;

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

Expand All @@ -86,7 +96,10 @@ export function DelegationForm({
validateOnBlur={false}
>
{({ values }) => (
<Form className="mt-4 flex flex-1 flex-col justify-between">
<Form
className="mt-4 flex flex-1 flex-col justify-between space-y-3"
data-testid="delegate-form"
>
<div
className={values.action === DelegateActionType.Transfer ? 'space-y-3' : 'space-y-5'}
>
Expand All @@ -113,15 +126,30 @@ export function DelegationForm({
)}
<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>

{
<MultiTxFormSubmitButton
txIndex={txPlanIndex}
numTxs={numTxs}
isLoading={isLoading}
loadingText={ActionToVerb[values.action]}
tipText={ActionToTipText[values.action]}
disabled={!canDelegate}
data-testid="delegate-form-submit"
>
{`${toTitleCase(values.action)}`}
</MultiTxFormSubmitButton>
}

{!canDelegate && (
<p
className={'min-w-[18rem] max-w-sm text-xs text-red-600'}
data-testid="delegate-form-warning"
>
Validators and validator groups (as well as their signers) cannot delegate their
voting power.
</p>
)}
</Form>
)}
</Formik>
Expand Down
1 change: 0 additions & 1 deletion src/features/delegation/components/DelegateButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { objLength } from 'src/utils/objects';

export function DelegateButton({ delegatee }: { delegatee: Delegatee }) {
const { proposalToVotes } = useDelegateeHistory(delegatee.address);

const showTxModal = useTransactionModal(TransactionFlowType.Delegate, {
delegatee: delegatee.address,
});
Expand Down
Loading

0 comments on commit 2ba5d08

Please sign in to comment.