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}`}
+
+
+
+ );
+}
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 }) => (
+
+ )}
+
+ );
+}
+
+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 }) {
>
@@ -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`}
-
-
-
-
- );
+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',