diff --git a/src/components/input/AmountField.tsx b/src/components/input/AmountField.tsx new file mode 100644 index 0000000..379f432 --- /dev/null +++ b/src/components/input/AmountField.tsx @@ -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 ( +
+
+ + {`${formatNumberString(maxValueWei, 2)} ${maxDescription}`} +
+
+ +
+ + Max + +
+
+
+ ); +} diff --git a/src/features/account/hooks.ts b/src/features/account/hooks.ts index df76bd8..214982e 100644 --- a/src/features/account/hooks.ts +++ b/src/features/account/hooks.ts @@ -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, diff --git a/src/features/governance/useVotingStatus.ts b/src/features/governance/useVotingStatus.ts new file mode 100644 index 0000000..a78874c --- /dev/null +++ b/src/features/governance/useVotingStatus.ts @@ -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, + }; +} diff --git a/src/features/locking/LockForm.tsx b/src/features/locking/LockForm.tsx new file mode 100644 index 0000000..0e6d559 --- /dev/null +++ b/src/features/locking/LockForm.tsx @@ -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 ( + + initialValues={{ + ...initialValues, + type: defaultType || initialValues.type, + }} + onSubmit={onSubmit} + validate={validate} + validateOnChange={false} + validateOnBlur={false} + > + {({ values }) => ( +
+

Stake with a validator

+ + + {toTitleCase(values.type)} + + )} + + ); +} + +function LockAmountField({ + lockedBalances, + walletBalance, + type, +}: { + lockedBalances?: LockedBalances; + walletBalance?: bigint; + type: LockActionType; +}) { + const maxAmountWei = useMemo( + () => getMaxAmount(type, lockedBalances, walletBalance), + [lockedBalances, walletBalance, type], + ); + return ; +} + +function ActionTypeField({ defaultType }: { defaultType?: LockActionType }) { + const [field, , helpers] = useField('type'); + + useEffect(() => { + helpers.setValue(defaultType || LockActionType.Lock).catch((e) => logger.error(e)); + }, [defaultType, helpers]); + + return ( +
+ +
+ +
+
+ ); +} + +function validateForm( + values: LockFormValues, + lockedBalances: LockedBalances, + walletBalance: bigint, + stakeBalances: StakingBalances, + isVoting: boolean, +): FormikErrors { + 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}`); + } +} diff --git a/src/features/locking/locking.ts b/src/features/locking/locking.ts new file mode 100644 index 0000000..ef50bc6 --- /dev/null +++ b/src/features/locking/locking.ts @@ -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); +// } diff --git a/src/features/locking/types.ts b/src/features/locking/types.ts index 2f6dac6..832ec3d 100644 --- a/src/features/locking/types.ts +++ b/src/features/locking/types.ts @@ -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; diff --git a/src/features/locking/fetchLockedStatus.ts b/src/features/locking/useLockedStatus.ts similarity index 63% rename from src/features/locking/fetchLockedStatus.ts rename to src/features/locking/useLockedStatus.ts index 4507f55..8de2d3b 100644 --- a/src/features/locking/fetchLockedStatus.ts +++ b/src/features/locking/useLockedStatus.ts @@ -1,11 +1,36 @@ import { lockedGoldABI } from '@celo/abis'; +import { useQuery } from '@tanstack/react-query'; +import { useToastError } from 'src/components/notifications/useToastError'; import { Addresses } from 'src/config/contracts'; import { LockedStatus, PendingWithdrawal } from 'src/features/locking/types'; -import { PublicClient } from 'wagmi'; +import { logger } from 'src/utils/logger'; +import { PublicClient, usePublicClient } from 'wagmi'; -type PendingWithdrawalsRaw = readonly [readonly bigint[], readonly bigint[]]; // values and times +export function useLockedStatus(address?: Address) { + const publicClient = usePublicClient(); -export async function fetchLockedStatus( + const { isLoading, isError, error, data } = useQuery({ + queryKey: ['useLockedStatus', publicClient, address], + queryFn: async () => { + if (!address) return null; + logger.debug('Fetching locked status balance and withdrawals'); + return fetchLockedStatus(publicClient, address); + }, + gcTime: 10 * 60 * 1000, // 10 minutes + staleTime: 1 * 60 * 1000, // 1 minute + }); + + useToastError(error, 'Error fetching locked balances and withdrawals'); + + return { + isLoading, + isError, + lockedBalances: data?.balances, + pendingWithdrawals: data?.pendingWithdrawals, + }; +} + +async function fetchLockedStatus( publicClient: PublicClient, address: Address, ): Promise { @@ -29,7 +54,8 @@ export async function fetchLockedStatus( throw new Error('Error fetching locked balances or pending withdrawals'); } const totalLocked = totalLockedResp.result; - const pendingWithdrawalsRaw: PendingWithdrawalsRaw = pendingWithdrawalsResp.result; + // [values, times] + const pendingWithdrawalsRaw = pendingWithdrawalsResp.result; let pendingBlocked = 0n; let pendingFree = 0n; diff --git a/src/features/locking/utils.ts b/src/features/locking/utils.ts index 8b3f574..3be6631 100644 --- a/src/features/locking/utils.ts +++ b/src/features/locking/utils.ts @@ -1,26 +1,40 @@ -import { LockedBalances } from 'src/features/locking/types'; +import type { LockedBalances } from 'src/features/locking/types'; +import type { StakingBalances } from 'src/features/staking/types'; +import { isNullish } from 'src/utils/typeof'; -export function getTotalCelo(lockedBalance: LockedBalances, walletBalance: bigint) { +export function getTotalCelo(lockedBalance?: LockedBalances, walletBalance?: bigint) { + if (isNullish(lockedBalance) || isNullish(walletBalance)) return 0n; const { locked, pendingBlocked, pendingFree } = lockedBalance; return walletBalance + locked + pendingBlocked + pendingFree; } -export function getTotalUnlockedCelo(lockedBalance: LockedBalances, walletBalance: bigint) { +export function getTotalUnlockedCelo(lockedBalance?: LockedBalances, walletBalance?: bigint) { + if (isNullish(lockedBalance) || isNullish(walletBalance)) return 0n; const { pendingBlocked, pendingFree } = lockedBalance; return walletBalance + pendingBlocked + pendingFree; } -export function getTotalLockedCelo(balances: LockedBalances) { - const { locked, pendingBlocked, pendingFree } = balances; +export function getTotalLockedCelo(lockedBalance?: LockedBalances) { + if (isNullish(lockedBalance)) return 0n; + const { locked, pendingBlocked, pendingFree } = lockedBalance; return locked + pendingBlocked + pendingFree; } -export function getTotalPendingCelo(balances: LockedBalances) { - const { pendingBlocked, pendingFree } = balances; +export function getTotalPendingCelo(lockedBalance?: LockedBalances) { + if (isNullish(lockedBalance)) return 0n; + const { pendingBlocked, pendingFree } = lockedBalance; return pendingBlocked + pendingFree; } -export function hasPendingCelo(balances: LockedBalances) { - const { pendingBlocked, pendingFree } = balances; - return pendingBlocked > 0 || pendingFree > 0; +export function hasPendingCelo(lockedBalance?: LockedBalances) { + if (isNullish(lockedBalance)) return 0n; + const { pendingBlocked, pendingFree } = lockedBalance; + return pendingBlocked > 0n || pendingFree > 0n; +} + +export function getTotalNonvotingLocked( + lockedBalances: LockedBalances, + stakingBalaces: StakingBalances, +) { + return lockedBalances.locked - stakingBalaces.total; } diff --git a/src/features/staking/StakeForm.tsx b/src/features/staking/StakeForm.tsx index 1fad032..302ff48 100644 --- a/src/features/staking/StakeForm.tsx +++ b/src/features/staking/StakeForm.tsx @@ -1,15 +1,12 @@ -import { Form, Formik, useField, useFormikContext } from 'formik'; -import { useEffect, useMemo } from 'react'; -import { OutlineButton } from 'src/components/buttons/OutlineButton'; +import { Form, Formik, useField } from 'formik'; +import { useEffect } from 'react'; import { SolidButton } from 'src/components/buttons/SolidButton'; -import { NumberField } from 'src/components/input/NumberField'; -import { formatNumberString } from 'src/components/numbers/Amount'; +import { AmountField } from 'src/components/input/AmountField'; import { ZERO_ADDRESS } from 'src/config/consts'; import { useLockedBalance } from 'src/features/account/hooks'; import { useStakingBalances } from 'src/features/staking/useStakingBalances'; import { ValidatorGroup } from 'src/features/validators/types'; import { useValidatorGroups } from 'src/features/validators/useValidatorGroups'; -import { fromWeiRounded } from 'src/utils/amount'; import { logger } from 'src/utils/logger'; import { bigIntMax } from 'src/utils/math'; import { useAccount } from 'wagmi'; @@ -56,7 +53,7 @@ export function StakeForm({ defaultGroup }: { defaultGroup?: Address }) { >

Stake with a validator

- + Stake @@ -64,35 +61,8 @@ export function StakeForm({ defaultGroup }: { defaultGroup?: Address }) { ); } -function AmountField({ availableLockedWei }: { availableLockedWei: bigint }) { - const { setFieldValue } = useFormikContext(); - - const availableLocked = useMemo(() => fromWeiRounded(availableLockedWei), [availableLockedWei]); - - const onClickMax = async () => { - await setFieldValue('amount', availableLocked); - }; - - return ( -
-
- - - {`${formatNumberString(availableLocked, 2)} Locked CELO available`} - -
-
- -
- - Max - -
-
-
- ); +function StakeAmountField({ availableLockedWei }: { availableLockedWei: bigint }) { + return ; } function GroupField({ diff --git a/src/features/staking/types.ts b/src/features/staking/types.ts index a15f596..0243db5 100644 --- a/src/features/staking/types.ts +++ b/src/features/staking/types.ts @@ -7,18 +7,6 @@ export enum StakeActionType { Revoke = 'revoke', } -export function stakeActionLabel(type: StakeActionType, activeTense = false) { - if (type === StakeActionType.Vote) { - return activeTense ? 'Voting' : 'Vote'; - } else if (type === StakeActionType.Activate) { - return activeTense ? 'Activating' : 'Activate'; - } else if (type === StakeActionType.Revoke) { - return activeTense ? 'Revoking' : 'Revoke'; - } else { - throw new Error(`Invalid lock action type: ${type}`); - } -} - export enum StakeEventType { Activate = 'activate', Revoke = 'revoke', // Revoke of active votes (i.e. not pending) diff --git a/src/features/staking/utils.ts b/src/features/staking/utils.ts deleted file mode 100644 index 223f5b7..0000000 --- a/src/features/staking/utils.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { LockedBalances } from 'src/features/locking/types'; -import { GroupToStake } from 'src/features/staking/types'; - -export function getTotalNonvotingLocked( - { locked: totalLocked }: LockedBalances, - stakes: GroupToStake, -) { - const totalVoted = Object.values(stakes).reduce((sum, v) => sum + v.active + v.pending, 0n); - return totalLocked - totalVoted; -} diff --git a/src/features/transactions/types.ts b/src/features/transactions/types.ts index db1a238..aa365c9 100644 --- a/src/features/transactions/types.ts +++ b/src/features/transactions/types.ts @@ -1,3 +1,4 @@ +// TODO combine some of these like lock/unlock/withdraw ? export enum TxModalType { Lock = 'lock', Unlock = 'unlock',