Skip to content

Commit

Permalink
Partially implement lock form
Browse files Browse the repository at this point in the history
  • Loading branch information
jmrossy committed Jan 1, 2024
1 parent 0fb3a91 commit 4792ebd
Show file tree
Hide file tree
Showing 12 changed files with 420 additions and 85 deletions.
41 changes: 41 additions & 0 deletions src/components/input/AmountField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useFormikContext } from 'formik';
import { useMemo } from 'react';
import { OutlineButton } from 'src/components/buttons/OutlineButton';
import { NumberField } from 'src/components/input/NumberField';
import { formatNumberString } from 'src/components/numbers/Amount';
import { fromWeiRounded } from 'src/utils/amount';

export function AmountField({
maxValueWei,
maxDescription,
}: {
maxValueWei: bigint;
maxDescription: string;
}) {
const { setFieldValue } = useFormikContext();

const availableLocked = useMemo(() => fromWeiRounded(maxValueWei), [maxValueWei]);

const onClickMax = async () => {
await setFieldValue('amount', availableLocked);
};

return (
<div>
<div className="flex items-center justify-between">
<label htmlFor="amount" className="pl-0.5 text-sm">
Amount
</label>
<span className="text-xs">{`${formatNumberString(maxValueWei, 2)} ${maxDescription}`}</span>
</div>
<div className="relative mt-2">
<NumberField name="amount" className="w-full all:py-3" />
<div className="absolute right-1 top-2 z-10">
<OutlineButton onClick={onClickMax} type="button">
Max
</OutlineButton>
</div>
</div>
</div>
);
}
1 change: 1 addition & 0 deletions src/features/account/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CELO } from 'src/config/tokens';
import { formatUnits } from 'viem';
import { useBalance as _useBalance, useContractRead } from 'wagmi';

// Defaults to CELO if tokenAddress is not provided
export function useBalance(address?: Address, tokenAddress?: Address) {
const { data, isError, isLoading, error } = _useBalance({
address: address,
Expand Down
24 changes: 24 additions & 0 deletions src/features/governance/useVotingStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { governanceABI } from '@celo/abis';
import { useToastError } from 'src/components/notifications/useToastError';
import { ZERO_ADDRESS } from 'src/config/consts';
import { Addresses } from 'src/config/contracts';
import { useContractRead } from 'wagmi';

export function useIsGovernanceVoting(address?: Address) {
const { data, isError, isLoading, error } = useContractRead({
address: Addresses.Governance,
abi: governanceABI,
functionName: 'isVoting',
args: [address || ZERO_ADDRESS],
enabled: !!address,
staleTime: 1 * 60 * 1000, // 1 minute
});

useToastError(error, 'Error fetching voting status');

return {
isVoting: data,
isError,
isLoading,
};
}
174 changes: 174 additions & 0 deletions src/features/locking/LockForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { Form, Formik, FormikErrors, useField } from 'formik';
import { useEffect, useMemo } from 'react';
import { SolidButton } from 'src/components/buttons/SolidButton';
import { AmountField } from 'src/components/input/AmountField';
import { useBalance } from 'src/features/account/hooks';
import { useIsGovernanceVoting } from 'src/features/governance/useVotingStatus';
import { LockActionType, LockedBalances } from 'src/features/locking/types';
import { useLockedStatus } from 'src/features/locking/useLockedStatus';
import {
getTotalNonvotingLocked,
getTotalPendingCelo,
getTotalUnlockedCelo,
} from 'src/features/locking/utils';
import { StakingBalances } from 'src/features/staking/types';
import { useStakingBalances } from 'src/features/staking/useStakingBalances';
import { toWei } from 'src/utils/amount';
import { logger } from 'src/utils/logger';
import { toTitleCase } from 'src/utils/strings';
import { isNullish } from 'src/utils/typeof';
import { useAccount } from 'wagmi';

interface LockFormValues {
amount: number;
type: LockActionType;
}

const initialValues: LockFormValues = {
amount: 0,
type: LockActionType.Lock,
};

export function LockForm({ defaultType }: { defaultType?: LockActionType }) {
const { address } = useAccount();
const { balance: walletBalance } = useBalance(address);
const { lockedBalances } = useLockedStatus(address);
const { stakeBalances } = useStakingBalances(address);
const { isVoting } = useIsGovernanceVoting(address);

const onSubmit = (values: LockFormValues) => {
alert(values);
};

const validate = (values: LockFormValues) => {
if (!walletBalance || !lockedBalances || !stakeBalances || isNullish(isVoting)) {
return { amount: 'Form not ready' };
}
return validateForm(values, lockedBalances, walletBalance.value, stakeBalances, isVoting);
};

return (
<Formik<LockFormValues>
initialValues={{
...initialValues,
type: defaultType || initialValues.type,
}}
onSubmit={onSubmit}
validate={validate}
validateOnChange={false}
validateOnBlur={false}
>
{({ values }) => (
<Form className="mt-2 flex w-full flex-col items-stretch space-y-4">
<h2 className="font-serif text-2xl">Stake with a validator</h2>
<ActionTypeField defaultType={defaultType} />
<LockAmountField
lockedBalances={lockedBalances}
walletBalance={walletBalance?.value}
type={values.type}
/>
<SolidButton type="submit">{toTitleCase(values.type)}</SolidButton>
</Form>
)}
</Formik>
);
}

function LockAmountField({
lockedBalances,
walletBalance,
type,
}: {
lockedBalances?: LockedBalances;
walletBalance?: bigint;
type: LockActionType;
}) {
const maxAmountWei = useMemo(
() => getMaxAmount(type, lockedBalances, walletBalance),
[lockedBalances, walletBalance, type],
);
return <AmountField maxValueWei={maxAmountWei} maxDescription="Locked CELO available" />;
}

function ActionTypeField({ defaultType }: { defaultType?: LockActionType }) {
const [field, , helpers] = useField<LockActionType>('type');

useEffect(() => {
helpers.setValue(defaultType || LockActionType.Lock).catch((e) => logger.error(e));
}, [defaultType, helpers]);

return (
<div>
<label htmlFor="type" className="pl-0.5 text-sm">
type
</label>
<div>
<select name="type" className="w-full" value={field.value}>
{Object.values(LockActionType).map((type) => (
<option key={type} value={type}>
{toTitleCase(type)}
</option>
))}
</select>
</div>
</div>
);
}

function validateForm(
values: LockFormValues,
lockedBalances: LockedBalances,
walletBalance: bigint,
stakeBalances: StakingBalances,
isVoting: boolean,
): FormikErrors<LockFormValues> {
const { amount, type } = values;

// TODO implement toWeiAdjusted() and use it here
const amountWei = toWei(amount);

const maxAmountWei = getMaxAmount(type, lockedBalances, walletBalance);
if (amountWei > maxAmountWei) {
const errorMsg =
type === LockActionType.Withdraw ? 'No pending available to withdraw' : 'Amount exceeds max';
return { amount: errorMsg };
}

// Special case handling for locking whole balance
if (type === LockActionType.Lock) {
const remainingAfterPending = amountWei - getTotalPendingCelo(lockedBalances);
if (remainingAfterPending >= walletBalance) {
return { amount: 'Cannot lock entire balance' };
}
}

// Ensure user isn't trying to unlock CELO used for staking
if (type === LockActionType.Unlock) {
if (isVoting) {
return { amount: 'Locked funds have voted for governance' };
}

const nonVotingLocked = getTotalNonvotingLocked(lockedBalances, stakeBalances);
if (amountWei > nonVotingLocked) {
return { amount: 'Locked funds in use for staking' };
}
}

return {};
}

function getMaxAmount(
type: LockActionType,
lockedBalances?: LockedBalances,
walletBalance?: bigint,
) {
if (type === LockActionType.Lock) {
return getTotalUnlockedCelo(lockedBalances, walletBalance);
} else if (type === LockActionType.Unlock) {
return lockedBalances?.locked || 0n;
} else if (type === LockActionType.Withdraw) {
return lockedBalances?.pendingFree || 0n;
} else {
throw new Error(`Invalid lock type: ${type}`);
}
}
111 changes: 111 additions & 0 deletions src/features/locking/locking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {
LockActionType,
LockTokenParams,
LockTokenTxPlan,
PendingWithdrawal,
} from 'src/features/locking/types';
import { bigIntMin } from 'src/utils/math';

// Lock token operations can require varying numbers of txs in specific order
// This determines the ideal tx types and order
export function getLockActionTxPlan(
params: LockTokenParams,
pendingWithdrawals: PendingWithdrawal[],
): LockTokenTxPlan {
const { type, amountWei } = params;

if (type === LockActionType.Unlock) {
// If only all three cases were this simple :)
return [{ type, amountWei }];
} else if (type === LockActionType.Lock) {
const txs: LockTokenTxPlan = [];
// Need relock from the pendings in reverse order
// due to the way the storage is managed in the contract
let amountRemaining = amountWei;
const pwSorted = [...pendingWithdrawals].sort((a, b) => b.index - a.index);
for (const p of pwSorted) {
if (amountRemaining <= 0) break;
const txAmount = bigIntMin(amountRemaining, p.value);
txs.push({
type: LockActionType.Relock,
amountWei: txAmount,
pendingWithdrawal: p,
});
amountRemaining -= txAmount;
}
// If pending relocks didn't cover it
if (amountRemaining > 0) {
txs.push({ type: LockActionType.Lock, amountWei: amountRemaining });
}
return txs;
} else if (type === LockActionType.Withdraw) {
const txs: LockTokenTxPlan = [];
const now = Date.now();
// Withdraw all available pendings
for (const p of pendingWithdrawals) {
if (p.timestamp <= now)
txs.push({
type: LockActionType.Withdraw,
amountWei: p.value,
pendingWithdrawal: p,
});
}
return txs;
} else {
throw new Error(`Invalid lock token action type: ${type}`);
}
}

// async function createLockCeloTx(
// txPlanItem: LockTokenTxPlanItem,
// feeEstimate: FeeEstimate,
// nonce: number,
// ) {
// const lockedGold = getContract(CeloContract.LockedGold);
// const tx = await lockedGold.populateTransaction.lock();
// tx.value = BigNumber.from(txPlanItem.amountWei);
// tx.nonce = nonce;
// logger.info('Signing lock celo tx');
// return signTransaction(tx, feeEstimate);
// }

// async function createRelockCeloTx(
// txPlanItem: LockTokenTxPlanItem,
// feeEstimate: FeeEstimate,
// nonce: number,
// ) {
// const { amountWei, pendingWithdrawal } = txPlanItem;
// if (!pendingWithdrawal) throw new Error('Pending withdrawal missing from relock tx');
// const lockedGold = getContract(CeloContract.LockedGold);
// const tx = await lockedGold.populateTransaction.relock(pendingWithdrawal.index, amountWei);
// tx.nonce = nonce;
// logger.info('Signing relock celo tx');
// return signTransaction(tx, feeEstimate);
// }

// async function createUnlockCeloTx(
// txPlanItem: LockTokenTxPlanItem,
// feeEstimate: FeeEstimate,
// nonce: number,
// ) {
// const { amountWei } = txPlanItem;
// const lockedGold = getContract(CeloContract.LockedGold);
// const tx = await lockedGold.populateTransaction.unlock(amountWei);
// tx.nonce = nonce;
// logger.info('Signing unlock celo tx');
// return signTransaction(tx, feeEstimate);
// }

// async function createWithdrawCeloTx(
// txPlanItem: LockTokenTxPlanItem,
// feeEstimate: FeeEstimate,
// nonce: number,
// ) {
// const { pendingWithdrawal } = txPlanItem;
// if (!pendingWithdrawal) throw new Error('Pending withdrawal missing from withdraw tx');
// const lockedGold = getContract(CeloContract.LockedGold);
// const tx = await lockedGold.populateTransaction.withdraw(pendingWithdrawal.index);
// tx.nonce = nonce;
// logger.info('Signing withdraw celo tx');
// return signTransaction(tx, feeEstimate);
// }
21 changes: 8 additions & 13 deletions src/features/locking/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,16 @@ export enum LockActionType {
Lock = 'lock',
Unlock = 'unlock',
Withdraw = 'withdraw',
Relock = 'relock',
}

export function lockActionLabel(type: LockActionType, activeTense = false) {
if (type === LockActionType.Lock) {
return activeTense ? 'Locking' : 'Lock';
} else if (type === LockActionType.Unlock) {
return activeTense ? 'Unlocking' : 'Unlock';
} else if (type === LockActionType.Withdraw) {
return activeTense ? 'Withdrawing' : 'Withdraw';
} else {
throw new Error(`Invalid lock action type: ${type}`);
}
export interface LockTokenParams {
type: LockActionType;
amountWei: bigint;
}

export interface LockTokenParams {
weiAmount: bigint;
action: LockActionType;
interface LockTokenTxPlanItem extends LockTokenParams {
pendingWithdrawal?: PendingWithdrawal;
}

export type LockTokenTxPlan = Array<LockTokenTxPlanItem>;
Loading

0 comments on commit 4792ebd

Please sign in to comment.