diff --git a/features/withdrawals/claim/claim-form-context/claim-form-context.tsx b/features/withdrawals/claim/claim-form-context/claim-form-context.tsx new file mode 100644 index 000000000..009095c9d --- /dev/null +++ b/features/withdrawals/claim/claim-form-context/claim-form-context.tsx @@ -0,0 +1,120 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import invariant from 'tiny-invariant'; +import { ClaimFormInputType, ClaimFormValidationContext } from './types'; +import { claimFormValidationResolver } from './validation'; +import { useClaim } from 'features/withdrawals/hooks'; +import { useMaxSelectedCount } from './use-max-selected-count'; +import { + generateDefaultValues, + useGetDefaultValues, +} from './use-default-values'; +import { ClaimFormHelperState, useHelperState } from './use-helper-state'; +import { useClaimData } from 'features/withdrawals/contexts/claim-data-context'; +import { useTransactionModal } from 'features/withdrawals/contexts/transaction-modal-context'; + +type ClaimFormDataContextValueType = { + onSubmit: NonNullable['onSubmit']>; +} & ClaimFormHelperState; + +const claimFormDataContext = + createContext(null); +claimFormDataContext.displayName = 'claimFormDataContext'; + +export const useClaimFormData = () => { + const contextData = useContext(claimFormDataContext); + invariant(contextData); + return contextData; +}; + +export const ClaimFormProvider: React.FC = ({ children }) => { + const { dispatchModalState } = useTransactionModal(); + const { data } = useClaimData(); + + const [shouldReset, setShouldReset] = useState(false); + const { maxSelectedRequestCount, defaultSelectedRequestCount } = + useMaxSelectedCount(); + const { getDefaultValues } = useGetDefaultValues(defaultSelectedRequestCount); + + const formObject = useForm({ + defaultValues: getDefaultValues, + resolver: claimFormValidationResolver, + context: { maxSelectedRequestCount }, + mode: 'onChange', + reValidateMode: 'onChange', + }); + const { watch, reset, handleSubmit, setValue, getValues, formState } = + formObject; + const helperState = useHelperState(watch, maxSelectedRequestCount); + + const claim = useClaim(); + const onSubmit = useMemo( + () => + handleSubmit(async ({ selectedTokens }) => { + const success = await claim(selectedTokens); + if (success) setShouldReset(true); + }), + [handleSubmit, claim], + ); + + useEffect(() => { + dispatchModalState({ type: 'set_on_retry', callback: onSubmit }); + }, [dispatchModalState, onSubmit]); + + const { isSubmitting } = formState; + + // handles reset and data update + useEffect(() => { + // no updates while submitting + if (!data || isSubmitting) return; + + // reset state after submit + if (shouldReset) { + reset(generateDefaultValues(data, defaultSelectedRequestCount)); + setShouldReset(false); + return; + } + + // for regular updates generate new list but keep user input + const oldValues = getValues('requests'); + const checkedIds = new Set( + oldValues.filter((req) => req.checked).map((req) => req.token_id), + ); + const newRequests = generateDefaultValues( + data, + defaultSelectedRequestCount, + ).requests.map((request) => ({ + ...request, + checked: checkedIds.has(request.token_id), + })); + + setValue('requests', newRequests, { + shouldValidate: true, + shouldDirty: false, + }); + }, [ + data, + getValues, + setValue, + shouldReset, + reset, + isSubmitting, + defaultSelectedRequestCount, + ]); + + const claimFormDataContextValue = useMemo(() => { + return { + onSubmit, + ...helperState, + }; + }, [helperState, onSubmit]); + + return ( + + + {useMemo(() => children, [children])} + + + ); +}; diff --git a/features/withdrawals/claim/claim-form-context/index.ts b/features/withdrawals/claim/claim-form-context/index.ts new file mode 100644 index 000000000..2b43efe33 --- /dev/null +++ b/features/withdrawals/claim/claim-form-context/index.ts @@ -0,0 +1,2 @@ +export * from './claim-form-context'; +export * from './types'; diff --git a/features/withdrawals/claim/claim-form-context/types.ts b/features/withdrawals/claim/claim-form-context/types.ts new file mode 100644 index 000000000..3d31ae05c --- /dev/null +++ b/features/withdrawals/claim/claim-form-context/types.ts @@ -0,0 +1,17 @@ +import { + RequestStatusClaimable, + RequestStatusesUnion, +} from 'features/withdrawals/types/request-status'; + +export type ClaimFormValidationContext = { + maxSelectedRequestCount: number; +}; + +export type ClaimFormInputType = { + requests: { + checked: boolean; + token_id: string; + status: RequestStatusesUnion; + }[]; + selectedTokens: RequestStatusClaimable[]; +}; diff --git a/features/withdrawals/claim/claim-form-context/use-default-values.ts b/features/withdrawals/claim/claim-form-context/use-default-values.ts new file mode 100644 index 000000000..2f9fa8c78 --- /dev/null +++ b/features/withdrawals/claim/claim-form-context/use-default-values.ts @@ -0,0 +1,50 @@ +import { useClaimData } from 'features/withdrawals/contexts/claim-data-context'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useAwaiter } from 'shared/hooks/use-awaiter'; +import { ClaimFormInputType } from './types'; +import { WithdrawalRequests } from 'features/withdrawals/hooks'; + +export const generateDefaultValues = ( + data: WithdrawalRequests, + defaultSelectedRequestCount: number, +): ClaimFormInputType => { + const requests = [ + ...data.sortedClaimableRequests.map((request, index) => ({ + token_id: request.stringId, + checked: index < defaultSelectedRequestCount, + status: request, + })), + ...data.pendingRequests.map((request) => ({ + token_id: request.stringId, + checked: false, + status: request, + })), + ]; + + return { requests, selectedTokens: [] }; +}; + +/// provides values & defaultValues props to useForm +/// values keep requests list updated if added/removed and smartly merged with existing state +/// defaultValues provides async function, used by form to wait for data to load +export const useGetDefaultValues = (defaultSelectedRequestCount: number) => { + const { data, error } = useClaimData(); + const values: ClaimFormInputType | undefined = useMemo(() => { + if (!data) return undefined; + return generateDefaultValues(data, defaultSelectedRequestCount); + }, [defaultSelectedRequestCount, data]); + + const { awaiter, resolver } = useAwaiter(values); + useEffect(() => { + if (error && !resolver.isResolved) { + resolver.resolve({ requests: [], selectedTokens: [] }); + } + }, [resolver, error]); + + const getDefaultValues = useCallback( + () => (values ? Promise.resolve(values) : awaiter), + [awaiter, values], + ); + + return { getDefaultValues }; +}; diff --git a/features/withdrawals/claim/claim-form-context/use-helper-state.ts b/features/withdrawals/claim/claim-form-context/use-helper-state.ts new file mode 100644 index 000000000..c1392bff0 --- /dev/null +++ b/features/withdrawals/claim/claim-form-context/use-helper-state.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; +import { type UseFormWatch } from 'react-hook-form'; +import { Zero } from '@ethersproject/constants'; +import { type BigNumber } from 'ethers'; + +import { RequestStatusClaimable } from 'features/withdrawals/types/request-status'; + +import { ClaimFormInputType } from './types'; + +export type ClaimFormHelperState = { + selectedRequests: RequestStatusClaimable[]; + ethToClaim: BigNumber; + canSelectMore: boolean; + requestsCount: number; +}; + +const initState = (): ClaimFormHelperState => ({ + selectedRequests: [], + canSelectMore: true, + ethToClaim: Zero, + requestsCount: 0, +}); + +/// subscribes to form updates and provides derived helper state for UI +export const useHelperState = ( + watch: UseFormWatch, + maxSelectedRequestCount: number, +) => { + const [helperState, setHelperState] = + useState(initState); + + useEffect(() => { + const subscription = watch(({ requests }) => { + const selectedRequests = + requests + ?.filter((r) => r?.checked) + .map((r) => r?.status as RequestStatusClaimable) ?? []; + + const ethToClaim = selectedRequests.reduce((ethSum, request) => { + return ethSum.add(request?.claimableEth ?? Zero); + }, Zero); + + setHelperState({ + selectedRequests, + canSelectMore: selectedRequests.length < maxSelectedRequestCount, + requestsCount: requests?.length ?? 0, + ethToClaim, + }); + }); + return () => subscription.unsubscribe(); + }, [watch, maxSelectedRequestCount]); + + return helperState; +}; diff --git a/features/withdrawals/claim/claim-form-context/use-max-selected-count.ts b/features/withdrawals/claim/claim-form-context/use-max-selected-count.ts new file mode 100644 index 000000000..caab735ad --- /dev/null +++ b/features/withdrawals/claim/claim-form-context/use-max-selected-count.ts @@ -0,0 +1,22 @@ +import { useIsLedgerLive } from 'shared/hooks/useIsLedgerLive'; + +import { + DEFAULT_CLAIM_REQUEST_SELECTED, + MAX_REQUESTS_COUNT, + MAX_REQUESTS_COUNT_LEDGER_LIMIT, +} from 'features/withdrawals/withdrawals-constants'; + +export const useMaxSelectedCount = () => { + const isLedgerLive = useIsLedgerLive(); + const maxSelectedRequestCount = isLedgerLive + ? MAX_REQUESTS_COUNT_LEDGER_LIMIT + : MAX_REQUESTS_COUNT; + const defaultSelectedRequestCount = Math.min( + DEFAULT_CLAIM_REQUEST_SELECTED, + maxSelectedRequestCount, + ); + return { + maxSelectedRequestCount, + defaultSelectedRequestCount, + }; +}; diff --git a/features/withdrawals/claim/claim-form-context/validation.ts b/features/withdrawals/claim/claim-form-context/validation.ts new file mode 100644 index 000000000..91f7f6c17 --- /dev/null +++ b/features/withdrawals/claim/claim-form-context/validation.ts @@ -0,0 +1,40 @@ +import { Resolver } from 'react-hook-form'; +import { + ValidationError, + handleResolverValidationError, +} from 'shared/hook-form/validation-error'; +import invariant from 'tiny-invariant'; +import { ClaimFormValidationContext, ClaimFormInputType } from './types'; +import { RequestStatusClaimable } from 'features/withdrawals/types/request-status'; + +export const claimFormValidationResolver: Resolver< + ClaimFormInputType, + ClaimFormValidationContext +> = async ({ requests }, context) => { + invariant(context); + try { + const { maxSelectedRequestCount } = context; + const selectedTokens = requests + .filter((r) => r.checked) + .map((r) => r.status as RequestStatusClaimable); + + if (selectedTokens.length === 0) + throw new ValidationError('requests', 'No requests selected for claim'); + + if (selectedTokens.length > maxSelectedRequestCount) + throw new ValidationError( + 'requests', + `Cannot claim more than ${maxSelectedRequestCount} requests at once`, + ); + + return { + values: { + requests, + selectedTokens, + }, + errors: {}, + }; + } catch (error) { + return handleResolverValidationError(error, 'ClaimForm', 'selectedTokens'); + } +}; diff --git a/features/withdrawals/claim/claim.tsx b/features/withdrawals/claim/claim.tsx new file mode 100644 index 000000000..21f22aa62 --- /dev/null +++ b/features/withdrawals/claim/claim.tsx @@ -0,0 +1,20 @@ +import { TransactionModalProvider } from 'features/withdrawals/contexts/transaction-modal-context'; +import { ClaimFaq } from 'features/withdrawals/withdrawals-faq/claim-faq'; + +import { ClaimForm } from './form'; +import { TxClaimModal } from './tx-modal'; +import { ClaimWallet } from './wallet'; +import { ClaimFormProvider } from './claim-form-context'; + +export const Claim = () => { + return ( + + + + + + + + + ); +}; diff --git a/features/withdrawals/claim/form/claim-form-footer-sticky.tsx b/features/withdrawals/claim/form/claim-form-footer-sticky.tsx index 3871bec29..2909ac19e 100644 --- a/features/withdrawals/claim/form/claim-form-footer-sticky.tsx +++ b/features/withdrawals/claim/form/claim-form-footer-sticky.tsx @@ -15,7 +15,7 @@ import { getScreenSize } from 'utils/getScreenSize'; import { REQUESTS_LIST_MIN_HEIGHT, REQUESTS_LIST_ITEM_SIZE, -} from '../requests-list/styles'; +} from './requests-list/styles'; // Adding 2/3 of item size to make next item slightly visible // so user can understand that there is scrollable list diff --git a/features/withdrawals/claim/form/claim-form.tsx b/features/withdrawals/claim/form/claim-form.tsx deleted file mode 100644 index 6bdfe0686..000000000 --- a/features/withdrawals/claim/form/claim-form.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useCallback, useRef, useState } from 'react'; -import { useWeb3 } from 'reef-knot/web3-react'; -import { BigNumber } from 'ethers'; - -import { FormatToken } from 'shared/formatters'; -import { Connect } from 'shared/wallet'; - -import { BunkerInfo } from './bunker-info'; -import { useClaim } from 'features/withdrawals/hooks'; -import { useClaimTxPrice } from 'features/withdrawals/hooks/useWithdrawTxPrice'; -import { useTransactionModal } from 'features/withdrawals/contexts/transaction-modal-context'; -import { useClaimData } from 'features/withdrawals/contexts/claim-data-context'; - -import { Button, DataTableRow } from '@lidofinance/lido-ui'; -import { RequestsList } from '../requests-list/requests-list'; -import { ClaimFormBody } from './styles'; -import { ClaimFormFooterSticky } from './claim-form-footer-sticky'; -import { useWithdrawals } from 'features/withdrawals/contexts/withdrawals-context'; - -export const ClaimForm = () => { - const refRequests = useRef(null); - - const { active } = useWeb3(); - const { dispatchModalState } = useTransactionModal(); - const { ethToClaim, claimSelection } = useClaimData(); - const { isBunker } = useWithdrawals(); - const { requests, loading: isLoading } = useClaimData(); - const isEmpty = !isLoading && requests.length === 0; - - const [isSubmitting, setIsSubmitting] = useState(false); - const { claimTxPriceInUsd, loading: claimTxPriceLoading } = useClaimTxPrice(); - const claimMutation = useClaim(); - - const claim = useCallback(() => { - // fix (re)start point - const startTx = async () => { - setIsSubmitting(true); - try { - await claimMutation(claimSelection.sortedSelectedRequests); - } finally { - setIsSubmitting(false); - } - }; - // send it to state - dispatchModalState({ type: 'set_starTx_callback', callback: startTx }); - // start flow - return startTx(); - }, [ - dispatchModalState, - claimMutation, - claimSelection.sortedSelectedRequests, - ]); - - const claimButtonAmount = ethToClaim?.lte(BigNumber.from(0)) ? null : ( - - ); - - return ( - <> - - {isBunker && } -
- -
-
- - {active ? ( - - ) : ( - - )} - - ${claimTxPriceInUsd?.toFixed(2)} - - - - ); -}; diff --git a/features/withdrawals/claim/form/form.tsx b/features/withdrawals/claim/form/form.tsx new file mode 100644 index 000000000..4af754637 --- /dev/null +++ b/features/withdrawals/claim/form/form.tsx @@ -0,0 +1,40 @@ +import { useRef } from 'react'; + +import { BunkerInfo } from './bunker-info'; + +import { RequestsList } from './requests-list/requests-list'; +import { ClaimFormBody } from './styles'; +import { ClaimFormFooterSticky } from './claim-form-footer-sticky'; +import { useWithdrawals } from 'features/withdrawals/contexts/withdrawals-context'; +import { SubmitButton } from './submit-button'; +import { ClaimFormInputType, useClaimFormData } from '../claim-form-context'; +import { useFormState } from 'react-hook-form'; +import { TransactionInfo } from './transaction-info'; + +export const ClaimForm = () => { + const refRequests = useRef(null); + const { isBunker } = useWithdrawals(); + const { isLoading } = useFormState(); + const { onSubmit, requestsCount } = useClaimFormData(); + + const isEmpty = requestsCount === 0; + + return ( +
+ + {isBunker && } +
+ +
+
+ + + + +
+ ); +}; diff --git a/features/withdrawals/claim/form/index.ts b/features/withdrawals/claim/form/index.ts index b6c442c84..a21330268 100644 --- a/features/withdrawals/claim/form/index.ts +++ b/features/withdrawals/claim/form/index.ts @@ -1 +1 @@ -export * from './claim-form'; +export { ClaimForm } from './form'; diff --git a/features/withdrawals/claim/requests-list/index.ts b/features/withdrawals/claim/form/requests-list/index.ts similarity index 100% rename from features/withdrawals/claim/requests-list/index.ts rename to features/withdrawals/claim/form/requests-list/index.ts diff --git a/features/withdrawals/claim/requests-list/request-item-status.tsx b/features/withdrawals/claim/form/requests-list/request-item-status.tsx similarity index 100% rename from features/withdrawals/claim/requests-list/request-item-status.tsx rename to features/withdrawals/claim/form/requests-list/request-item-status.tsx diff --git a/features/withdrawals/claim/form/requests-list/request-item.tsx b/features/withdrawals/claim/form/requests-list/request-item.tsx new file mode 100644 index 000000000..c291ae7cc --- /dev/null +++ b/features/withdrawals/claim/form/requests-list/request-item.tsx @@ -0,0 +1,68 @@ +import { forwardRef } from 'react'; +import { useWeb3 } from 'reef-knot/web3-react'; +import { useFormState, useWatch } from 'react-hook-form'; + +import { Checkbox, External } from '@lidofinance/lido-ui'; +import { FormatToken } from 'shared/formatters'; + +import { RequestStatus } from './request-item-status'; +import { useClaimFormData, ClaimFormInputType } from '../../claim-form-context'; + +import { getNFTUrl } from 'utils'; +import { RequestStyled, LinkStyled } from './styles'; + +type RequestItemProps = { + token_id: string; + name: `requests.${number}.checked`; + index: number; +} & React.ComponentProps<'input'>; + +export const RequestItem = forwardRef( + ({ token_id, name, disabled, index, ...props }, ref) => { + const { chainId } = useWeb3(); + const { isSubmitting } = useFormState(); + const { canSelectMore } = useClaimFormData(); + const { checked, status } = useWatch< + ClaimFormInputType, + `requests.${number}` + >({ + name: `requests.${index}`, + }); + + const isDisabled = + disabled || + !status.isFinalized || + (!canSelectMore && !checked) || + isSubmitting; + + const isClaimable = 'claimableEth' in status; + + const amountValue = isClaimable + ? status.claimableEth + : status.amountOfStETH; + const symbol = isClaimable ? 'ETH' : 'stETH'; + + const label = ( + + ); + + return ( + + + + + + + + ); + }, +); diff --git a/features/withdrawals/claim/requests-list/requests-empty.tsx b/features/withdrawals/claim/form/requests-list/requests-empty.tsx similarity index 100% rename from features/withdrawals/claim/requests-list/requests-empty.tsx rename to features/withdrawals/claim/form/requests-list/requests-empty.tsx diff --git a/features/withdrawals/claim/form/requests-list/requests-list.tsx b/features/withdrawals/claim/form/requests-list/requests-list.tsx new file mode 100644 index 000000000..deb8f1af2 --- /dev/null +++ b/features/withdrawals/claim/form/requests-list/requests-list.tsx @@ -0,0 +1,35 @@ +import { RequestItem } from './request-item'; +import { RequestsEmpty } from './requests-empty'; +import { Wrapper } from './styles'; +import { RequestsLoader } from './requests-loader'; +import { useFieldArray, useFormContext, useFormState } from 'react-hook-form'; +import { ClaimFormInputType } from '../../claim-form-context'; + +export const RequestsList: React.FC = () => { + const { isLoading } = useFormState(); + const { register } = useFormContext(); + const { fields } = useFieldArray({ + name: 'requests', + }); + + if (isLoading) { + return ; + } + + if (fields.length === 0) { + return ; + } + + return ( + + {fields.map(({ token_id, id }, index) => ( + + ))} + + ); +}; diff --git a/features/withdrawals/claim/requests-list/requests-loader.tsx b/features/withdrawals/claim/form/requests-list/requests-loader.tsx similarity index 100% rename from features/withdrawals/claim/requests-list/requests-loader.tsx rename to features/withdrawals/claim/form/requests-list/requests-loader.tsx diff --git a/features/withdrawals/claim/requests-list/styles.ts b/features/withdrawals/claim/form/requests-list/styles.ts similarity index 100% rename from features/withdrawals/claim/requests-list/styles.ts rename to features/withdrawals/claim/form/requests-list/styles.ts diff --git a/features/withdrawals/claim/form/submit-button.tsx b/features/withdrawals/claim/form/submit-button.tsx new file mode 100644 index 000000000..23f1952ed --- /dev/null +++ b/features/withdrawals/claim/form/submit-button.tsx @@ -0,0 +1,34 @@ +import { Connect } from 'shared/wallet'; +import { Button } from '@lidofinance/lido-ui'; +import { useWeb3 } from 'reef-knot/web3-react'; +import { FormatToken } from 'shared/formatters/format-token'; +import { ClaimFormInputType, useClaimFormData } from '../claim-form-context'; +import { Zero } from '@ethersproject/constants'; +import { useFormState } from 'react-hook-form'; + +export const SubmitButton = () => { + const { active } = useWeb3(); + const { isSubmitting, isValidating, errors } = + useFormState(); + const { ethToClaim } = useClaimFormData(); + const { selectedRequests } = useClaimFormData(); + + if (!active) return ; + + const claimButtonAmount = ethToClaim.lte(Zero) ? null : ( + + ); + + const disabled = Boolean(errors.requests) || selectedRequests.length === 0; + + return ( + + ); +}; diff --git a/features/withdrawals/claim/form/transaction-info.tsx b/features/withdrawals/claim/form/transaction-info.tsx new file mode 100644 index 000000000..0a59efe44 --- /dev/null +++ b/features/withdrawals/claim/form/transaction-info.tsx @@ -0,0 +1,14 @@ +import { DataTableRow } from '@lidofinance/lido-ui'; +import { useClaimFormData } from '../claim-form-context'; +import { useClaimTxPrice } from 'features/withdrawals/hooks/useWithdrawTxPrice'; + +export const TransactionInfo = () => { + const { selectedRequests } = useClaimFormData(); + const { claimTxPriceInUsd, loading: claimTxPriceLoading } = + useClaimTxPrice(selectedRequests); + return ( + + ${claimTxPriceInUsd?.toFixed(2)} + + ); +}; diff --git a/features/withdrawals/claim/index.ts b/features/withdrawals/claim/index.ts index fff834278..00b242580 100644 --- a/features/withdrawals/claim/index.ts +++ b/features/withdrawals/claim/index.ts @@ -1,2 +1 @@ -export { ClaimWallet } from './wallet'; -export { ClaimForm } from './form'; +export { Claim } from './claim'; diff --git a/features/withdrawals/claim/requests-list/request-item.tsx b/features/withdrawals/claim/requests-list/request-item.tsx deleted file mode 100644 index 95e526052..000000000 --- a/features/withdrawals/claim/requests-list/request-item.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useCallback } from 'react'; -import { useWeb3 } from 'reef-knot/web3-react'; -import { useClaimData } from 'features/withdrawals/contexts/claim-data-context'; - -import { Checkbox, External } from '@lidofinance/lido-ui'; -import { FormatToken } from 'shared/formatters'; -import { RequestStyled, LinkStyled } from './styles'; - -import { getNFTUrl } from 'utils'; -import type { RequestStatusesUnion } from 'features/withdrawals/types/request-status'; - -import { RequestStatus } from './request-item-status'; - -type RequestItemProps = { - request: RequestStatusesUnion; -}; - -export const RequestItem: React.FC = ({ request }) => { - const { chainId } = useWeb3(); - const { claimSelection } = useClaimData(); - const { - isSelected: getIsSelected, - canSelectMore, - setSelected, - } = claimSelection; - const { isFinalized, stringId: tokenId } = request; - - const isSelected = getIsSelected(request.stringId); - const isDisabled = !isFinalized || (!isSelected && !canSelectMore); - - const amountValue = - 'claimableEth' in request ? request.claimableEth : request.amountOfStETH; - const symbol = 'claimableEth' in request ? 'ETH' : 'stETH'; - const label = ( - - ); - // const expectedEth = 'expectedEth' in request ? request.expectedEth : undefined - - const handleSelect = useCallback( - (e: React.ChangeEvent) => - setSelected(tokenId, e.currentTarget.checked), - [setSelected, tokenId], - ); - - return ( - - - {/* TODO: uncomment this when the design will be finalized*/} - {/* {!isFinalized && expectedEth && ( - <> -  ( - ) - - )} */} - - - - - - ); -}; diff --git a/features/withdrawals/claim/requests-list/requests-list.tsx b/features/withdrawals/claim/requests-list/requests-list.tsx deleted file mode 100644 index 8d92c17d1..000000000 --- a/features/withdrawals/claim/requests-list/requests-list.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { RequestItem } from './request-item'; -import { RequestsEmpty } from './requests-empty'; -import { Wrapper } from './styles'; -import { RequestsLoader } from './requests-loader'; -import { RequestStatusesUnion } from 'features/withdrawals/types/request-status'; - -type RequestsListProps = { - isLoading: boolean; - isEmpty: boolean; - requests: RequestStatusesUnion[]; -}; - -export const RequestsList: React.FC = ({ - isLoading, - isEmpty, - requests, -}) => { - if (isLoading) { - return ; - } - - if (isEmpty) { - return ; - } - - return ( - - {requests.map((request) => ( - - ))} - - ); -}; diff --git a/features/withdrawals/claim/tx-modal/tx-claim-modal.tsx b/features/withdrawals/claim/tx-modal/tx-claim-modal.tsx index b3851169b..64b1dcf4b 100644 --- a/features/withdrawals/claim/tx-modal/tx-claim-modal.tsx +++ b/features/withdrawals/claim/tx-modal/tx-claim-modal.tsx @@ -23,7 +23,7 @@ export const TxClaimModal = () => { requestAmount, txHash, errorText, - startTx, + onRetry, dispatchModalState, } = useTransactionModal(); @@ -70,12 +70,7 @@ export const TxClaimModal = () => { return ; case TX_STAGE.FAIL: return ( - { - startTx && startTx(); - }} - /> + ); default: return null; @@ -84,7 +79,7 @@ export const TxClaimModal = () => { errorText, pendingTitle, signTitle, - startTx, + onRetry, successTitle, txHash, txStage, diff --git a/features/withdrawals/claim/wallet/wallet-availale-amount.tsx b/features/withdrawals/claim/wallet/wallet-availale-amount.tsx index 14b14e44c..8c47a0528 100644 --- a/features/withdrawals/claim/wallet/wallet-availale-amount.tsx +++ b/features/withdrawals/claim/wallet/wallet-availale-amount.tsx @@ -3,12 +3,12 @@ import { FormatToken } from 'shared/formatters'; import { useClaimData } from 'features/withdrawals/contexts/claim-data-context'; export const WalletAvailableAmount = () => { - const { withdrawalRequestsData, loading } = useClaimData(); + const { data, initialLoading } = useClaimData(); const availableAmount = ( ); @@ -17,7 +17,7 @@ export const WalletAvailableAmount = () => { ); diff --git a/features/withdrawals/claim/wallet/wallet-pending-amount.tsx b/features/withdrawals/claim/wallet/wallet-pending-amount.tsx index 57ae6535a..c02d3eaad 100644 --- a/features/withdrawals/claim/wallet/wallet-pending-amount.tsx +++ b/features/withdrawals/claim/wallet/wallet-pending-amount.tsx @@ -3,12 +3,12 @@ import { FormatToken } from 'shared/formatters'; import { useClaimData } from 'features/withdrawals/contexts/claim-data-context'; export const WalletPendingAmount = () => { - const { withdrawalRequestsData, loading } = useClaimData(); + const { data, initialLoading } = useClaimData(); const pendingAmount = ( ); @@ -17,7 +17,7 @@ export const WalletPendingAmount = () => { ); diff --git a/features/withdrawals/claim/wallet/wallet.tsx b/features/withdrawals/claim/wallet/wallet.tsx index 1e054e312..9308f19c6 100644 --- a/features/withdrawals/claim/wallet/wallet.tsx +++ b/features/withdrawals/claim/wallet/wallet.tsx @@ -17,19 +17,17 @@ export const WalletComponent = () => { const { account } = useSDK(); return ( - <> - - - - - - - - - - - - + + + + + + + + + + + ); }; diff --git a/features/withdrawals/contexts/claim-data-context/index.tsx b/features/withdrawals/contexts/claim-data-context/index.tsx index a8ea0be7c..c1e10663a 100644 --- a/features/withdrawals/contexts/claim-data-context/index.tsx +++ b/features/withdrawals/contexts/claim-data-context/index.tsx @@ -1,63 +1,19 @@ -import { FC, createContext, useMemo, useContext } from 'react'; -import { BigNumber } from 'ethers'; +import { FC, createContext, useContext } from 'react'; import { useWithdrawalRequests } from 'features/withdrawals/hooks'; -import { RequestStatusesUnion } from 'features/withdrawals/types/request-status'; -import { useClaimSelection } from './useClaimSelection'; import invariant from 'tiny-invariant'; const claimDataContext = createContext(null); claimDataContext.displayName = 'ClaimDataContext'; -export type ClaimDataValue = { - ethToClaim: BigNumber; - requests: RequestStatusesUnion[]; - claimSelection: ReturnType; - withdrawalRequestsData: ReturnType['data']; - loading: ReturnType['initialLoading']; - refetching: ReturnType['loading']; - update: ReturnType['update']; -}; +export type ClaimDataValue = ReturnType; export const ClaimDataProvider: FC = ({ children }) => { const withdrawRequests = useWithdrawalRequests(); - const claimSelection = useClaimSelection( - withdrawRequests.data?.sortedClaimableRequests ?? null, - ); - - const ethToClaim = useMemo(() => { - return claimSelection.sortedSelectedRequests.reduce( - (eth, r) => eth.add(r.claimableEth), - BigNumber.from(0), - ); - }, [claimSelection.sortedSelectedRequests]); - - const requests = useMemo(() => { - return [ - ...(withdrawRequests.data?.sortedClaimableRequests ?? []), - ...(withdrawRequests.data?.pendingRequests ?? []), - ]; - }, [withdrawRequests.data]); - - const value: ClaimDataValue = useMemo(() => { - return { - withdrawalRequestsData: withdrawRequests.data, - get loading() { - return withdrawRequests.initialLoading; - }, - get refetching() { - return withdrawRequests.loading; - }, - update: withdrawRequests.update, - claimSelection, - requests, - ethToClaim, - }; - }, [claimSelection, withdrawRequests, ethToClaim, requests]); return ( - + {children} ); diff --git a/features/withdrawals/contexts/claim-data-context/useClaimSelection.ts b/features/withdrawals/contexts/claim-data-context/useClaimSelection.ts deleted file mode 100644 index dd5cad370..000000000 --- a/features/withdrawals/contexts/claim-data-context/useClaimSelection.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { type RequestStatusClaimable } from 'features/withdrawals/types/request-status'; -import { - MAX_REQUESTS_COUNT, - DEFAULT_CLAIM_REQUEST_SELECTED, - MAX_REQUESTS_COUNT_LEDGER_LIMIT, -} from 'features/withdrawals/withdrawals-constants'; - -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useIsLedgerLive } from 'shared/hooks/useIsLedgerLive'; - -export const useClaimSelection = ( - claimableRequests: RequestStatusClaimable[] | null, -) => { - const isLedgerLive = useIsLedgerLive(); - const maxRequestCount = isLedgerLive - ? MAX_REQUESTS_COUNT_LEDGER_LIMIT - : MAX_REQUESTS_COUNT; - const [state, setSelectionState] = useState<{ - selection_set: Set; - }>({ selection_set: new Set() }); - - const claimableIdToIndex = useMemo(() => { - return ( - claimableRequests?.reduce((map, cur, i) => { - map[cur.stringId] = i; - return map; - }, {} as { [key: string]: number }) ?? {} - ); - }, [claimableRequests]); - - // it's ok to rebuild array because we cap selected at MAX_REQUEST_PER_TX - const sortedSelectedRequests = useMemo(() => { - if (!claimableRequests) return []; - return Array.from(state.selection_set.keys()) - .map((id) => claimableRequests[claimableIdToIndex[id]]) - .filter((r) => r) - .sort((aReq, bReq) => (aReq.id.gt(bReq.id) ? 1 : -1)); - }, [claimableRequests, claimableIdToIndex, state]); - - // because we get count from sortedSelectedRequests, we don't count stale ids from removed reqs - const selectedCount = sortedSelectedRequests.length; - - // stablish setters - const setSelected = useCallback( - (key: string, value: boolean) => { - setSelectionState((old) => { - if (value && selectedCount >= maxRequestCount) return old; - if (value) old.selection_set.add(key); - else old.selection_set.delete(key); - return { selection_set: old.selection_set }; - }); - }, - [selectedCount, maxRequestCount], - ); - - const setSelectedMany = useCallback( - (keys: string[]) => { - const freeSpace = maxRequestCount - selectedCount; - if (freeSpace <= 0) return; - setSelectionState((old) => { - keys.slice(0, freeSpace).forEach((k) => old.selection_set.add(k)); - return { selection_set: old.selection_set }; - }); - }, - [maxRequestCount, selectedCount], - ); - - const setUnselectedMany = useCallback((keys: string[]) => { - setSelectionState((old) => { - keys.forEach((k) => old.selection_set.delete(k)); - return { selection_set: old.selection_set }; - }); - }, []); - - // getters - const isSelected = useCallback( - (key: string) => - Boolean(state.selection_set.has(key) && key in claimableIdToIndex), - [state, claimableIdToIndex], - ); - - // populate state on claimableRequests - const isEmptyData = !claimableRequests; - useEffect(() => { - if (isEmptyData) { - setSelectionState({ selection_set: new Set() }); - } else { - setSelectedMany( - claimableRequests - .slice(0, DEFAULT_CLAIM_REQUEST_SELECTED) - .map((r) => r.stringId), - ); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isEmptyData]); - - return { - isSelected, - setSelected, - setSelectedMany, - setUnselectedMany, - sortedSelectedRequests, - selectedCount, - canSelectMore: selectedCount < maxRequestCount, - }; -}; diff --git a/features/withdrawals/contexts/transaction-modal-context.tsx b/features/withdrawals/contexts/transaction-modal-context.tsx index 6359285f8..35e7a3f2b 100644 --- a/features/withdrawals/contexts/transaction-modal-context.tsx +++ b/features/withdrawals/contexts/transaction-modal-context.tsx @@ -19,7 +19,7 @@ type TransactionModalContextValue = TransactionModalState & { type TransactionModalState = { isModalOpen: boolean; txStage: TX_STAGE; - startTx: (() => void) | null; + onRetry: (() => void) | null; onOkBunker: (() => void) | null; onCloseBunker: (() => void) | null; errorText: string | null; @@ -33,7 +33,7 @@ type TransactionModalAction = type: 'reset'; } | { - type: 'set_starTx_callback'; + type: 'set_on_retry'; callback: () => void; } | { @@ -88,15 +88,15 @@ const TransactionModalReducer = ( requestAmount: null, token: null, txHash: null, - // keep old (re)start callback if have one - startTx: state.startTx, + // keep old restart callback if have one + onRetry: state.onRetry, onCloseBunker: null, onOkBunker: null, }; - case 'set_starTx_callback': + case 'set_on_retry': return { ...state, - startTx: action.callback, + onRetry: action.callback, }; case 'close_modal': return { @@ -111,7 +111,6 @@ const TransactionModalReducer = ( isModalOpen: true, }; case 'bunker': - invariant(state.startTx, 'state must already have start tx callback'); return { ...state, isModalOpen: true, @@ -128,7 +127,7 @@ const TransactionModalReducer = ( requestAmount: action.requestAmount, token: action.token, // keep (re)start callback - startTx: state.startTx, + onRetry: state.onRetry, onCloseBunker: state.onCloseBunker, onOkBunker: state.onOkBunker, }; @@ -173,7 +172,7 @@ const TransactionModalReducer = ( const initTxModalState = (): TransactionModalState => ({ isModalOpen: false, txStage: TX_STAGE.NONE, - startTx: null, + onRetry: null, errorText: null, txHash: null, requestAmount: null, diff --git a/features/withdrawals/hooks/contract/useClaim.ts b/features/withdrawals/hooks/contract/useClaim.ts index ceb83dee5..8e8f974a6 100644 --- a/features/withdrawals/hooks/contract/useClaim.ts +++ b/features/withdrawals/hooks/contract/useClaim.ts @@ -17,7 +17,7 @@ export const useClaim = () => { const { account } = useWeb3(); const { providerWeb3 } = useSDK(); const { contractWeb3 } = useWithdrawalsContract(); - const { update } = useClaimData(); + const { optimisticClaimRequests } = useClaimData(); const { dispatchModalState } = useTransactionModal(); return useCallback( @@ -83,17 +83,26 @@ export const useClaim = () => { await runWithTransactionLogger('Claim block confirmation', async () => transaction.wait(), ); + // we only update if we wait for tx + await optimisticClaimRequests(sortedRequests); } - await update(); + dispatchModalState({ type: isMultisig ? 'success_multisig' : 'success', }); + return true; } catch (error) { const errorMessage = getErrorMessage(error); dispatchModalState({ type: 'error', errorText: errorMessage }); + return false; } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [contractWeb3, account, providerWeb3, dispatchModalState, update], + [ + contractWeb3, + account, + providerWeb3, + dispatchModalState, + optimisticClaimRequests, + ], ); }; diff --git a/features/withdrawals/hooks/contract/useRequest.ts b/features/withdrawals/hooks/contract/useRequest.ts index db2e904d1..a52673ff4 100644 --- a/features/withdrawals/hooks/contract/useRequest.ts +++ b/features/withdrawals/hooks/contract/useRequest.ts @@ -330,72 +330,65 @@ export const useWithdrawalRequest = ({ const isTokenLocked = isApprovalFlow && needsApprove; const request = useCallback( - ( + async ( requests: BigNumber[] | null, amount: BigNumber | null, token: TokensWithdrawable, ) => { // define and set retry point - const startCallback = async () => { - try { - invariant( - requests && request.length > 0, - 'cannot submit empty requests', - ); - invariant(amount, 'cannot submit empty amount'); - if (isBunker) { - const bunkerDialogResult = await new Promise((resolve) => { - dispatchModalState({ - type: 'bunker', - onCloseBunker: () => resolve(false), - onOkBunker: () => resolve(true), - }); + try { + invariant( + requests && request.length > 0, + 'cannot submit empty requests', + ); + invariant(amount, 'cannot submit empty amount'); + if (isBunker) { + const bunkerDialogResult = await new Promise((resolve) => { + dispatchModalState({ + type: 'bunker', + onCloseBunker: () => resolve(false), + onOkBunker: () => resolve(true), }); - if (!bunkerDialogResult) return { success: false }; - } - // get right method - const method = getRequestMethod(isApprovalFlow, token); - // start flow - dispatchModalState({ - type: 'start', - flow: isApprovalFlow - ? needsApprove - ? TX_STAGE.APPROVE - : TX_STAGE.SIGN - : TX_STAGE.PERMIT, - requestAmount: amount, - token, }); + if (!bunkerDialogResult) return { success: false }; + } + // get right method + const method = getRequestMethod(isApprovalFlow, token); + // start flow + dispatchModalState({ + type: 'start', + flow: isApprovalFlow + ? needsApprove + ? TX_STAGE.APPROVE + : TX_STAGE.SIGN + : TX_STAGE.PERMIT, + requestAmount: amount, + token, + }); - // each flow switches needed signing stages - if (isApprovalFlow) { - if (needsApprove) { - await approve(); - // multisig does not move to next tx - if (!isMultisig) await method({ requests }); - } else { - await method({ requests }); - } + // each flow switches needed signing stages + if (isApprovalFlow) { + if (needsApprove) { + await approve(); + // multisig does not move to next tx + if (!isMultisig) await method({ requests }); } else { - const signature = await gatherPermitSignature(amount); - await method({ signature, requests }); + await method({ requests }); } - // end flow - dispatchModalState({ - type: isMultisig ? 'success_multisig' : 'success', - }); - return { success: true }; - } catch (error) { - const errorMessage = getErrorMessage(error); - dispatchModalState({ type: 'error', errorText: errorMessage }); - return { success: false, error: error }; + } else { + const signature = await gatherPermitSignature(amount); + await method({ signature, requests }); } - }; - dispatchModalState({ - type: 'set_starTx_callback', - callback: startCallback, - }); - return startCallback(); + // end flow + dispatchModalState({ + type: isMultisig ? 'success_multisig' : 'success', + }); + return { success: true }; + } catch (error) { + const errorMessage = getErrorMessage(error); + dispatchModalState({ type: 'error', errorText: errorMessage }); + return { success: false, error: error }; + } }, [ approve, diff --git a/features/withdrawals/hooks/contract/useUnfinalizedSteth.ts b/features/withdrawals/hooks/contract/useUnfinalizedSteth.ts index 9928dfd60..444db5003 100644 --- a/features/withdrawals/hooks/contract/useUnfinalizedSteth.ts +++ b/features/withdrawals/hooks/contract/useUnfinalizedSteth.ts @@ -1,6 +1,7 @@ import { useContractSWR } from '@lido-sdk/react'; import { useWithdrawalsContract } from './useWithdrawalsContract'; +import { STRATEGY_LAZY } from 'utils/swrStrategies'; export const useUnfinalizedStETH = () => { const { contractRpc } = useWithdrawalsContract(); @@ -8,5 +9,6 @@ export const useUnfinalizedStETH = () => { return useContractSWR({ contract: contractRpc, method: 'unfinalizedStETH', + config: STRATEGY_LAZY, }); }; diff --git a/features/withdrawals/hooks/contract/useWithdrawalsData.ts b/features/withdrawals/hooks/contract/useWithdrawalsData.ts index a8e9261df..ff1cd92d7 100644 --- a/features/withdrawals/hooks/contract/useWithdrawalsData.ts +++ b/features/withdrawals/hooks/contract/useWithdrawalsData.ts @@ -1,3 +1,5 @@ +import { useCallback } from 'react'; +import { Zero } from '@ethersproject/constants'; import { BigNumber } from 'ethers'; import { useLidoSWR } from '@lido-sdk/react'; // import { useLidoShareRate } from 'features/withdrawals/hooks/contract/useLidoShareRate'; @@ -11,13 +13,18 @@ import { } from 'features/withdrawals/types/request-status'; import { MAX_SHOWN_REQUEST_PER_TYPE } from 'features/withdrawals/withdrawals-constants'; import { STRATEGY_LAZY } from 'utils/swrStrategies'; + // import { calcExpectedRequestEth } from 'features/withdrawals/utils/calc-expected-request-eth'; +export type WithdrawalRequests = NonNullable< + ReturnType['data'] +>; + export const useWithdrawalRequests = () => { const { contractRpc, account, chainId } = useWithdrawalsContract(); // const { data: currentShareRate } = useLidoShareRate(); - return useLidoSWR( + const swr = useLidoSWR( // TODO: use this fragment for expected eth calculation // currentShareRate // ? ['swr:withdrawals-requests', account, chainId, currentShareRate] @@ -61,7 +68,6 @@ export const useWithdrawalRequests = () => { request.amountOfStETH, ); } - return req; }); @@ -70,23 +76,6 @@ export const useWithdrawalRequests = () => { isClamped ||= pendingRequests.splice(MAX_SHOWN_REQUEST_PER_TYPE).length > 0; - /* Stress test - let id = BigNumber.from(pendingRequests[pendingRequests.length - 1].id); - for (let index = pendingRequests.length; index < 100000; index++) { - id = id.add(1); - pendingRequests.push({ - amountOfShares: BigNumber.from(10), - amountOfStETH: BigNumber.from('10000000000000000'), - id, - isClaimed: false, - isFinalized: false, - stringId: id.toString(), - owner: account, - timestamp: BigNumber.from('10000000000000000'), - }); - } - */ - const _sortedClaimableRequests = claimableRequests.sort((aReq, bReq) => aReq.id.gt(bReq.id) ? 1 : -1, ); @@ -127,4 +116,39 @@ export const useWithdrawalRequests = () => { }, STRATEGY_LAZY, ); + const oldData = swr.data; + const mutate = swr.mutate; + const optimisticClaimRequests = useCallback( + async (requests: RequestStatusClaimable[]) => { + if (!oldData) return undefined; + const { steth, eth } = requests.reduce( + (acc, request) => { + return { + steth: acc.steth.add(request.amountOfStETH), + eth: acc.eth.add(request.claimableEth), + }; + }, + { steth: Zero, eth: Zero }, + ); + const optimisticData = { + ...oldData, + sortedClaimableRequests: oldData.sortedClaimableRequests.filter( + (r) => requests.includes(r), // this works because they are same object refs + ), + readyCount: oldData.readyCount - requests.length, + claimedCount: oldData.claimedCount + requests.length, + claimableAmountOfStETH: oldData.claimableAmountOfStETH.sub(steth), + claimableAmountOfETH: oldData.claimableAmountOfETH.sub(eth), + }; + return mutate(optimisticData, true); + }, + [oldData, mutate], + ); + + const revalidate = useCallback( + () => mutate(oldData, true), + [oldData, mutate], + ); + + return { ...swr, optimisticClaimRequests, revalidate }; }; diff --git a/features/withdrawals/hooks/useWithdrawTxPrice.ts b/features/withdrawals/hooks/useWithdrawTxPrice.ts index 667bc8078..8378d5723 100644 --- a/features/withdrawals/hooks/useWithdrawTxPrice.ts +++ b/features/withdrawals/hooks/useWithdrawTxPrice.ts @@ -17,12 +17,12 @@ import { TOKENS } from '@lido-sdk/constants'; import { useWithdrawalsContract } from './contract/useWithdrawalsContract'; import { useTxCostInUsd } from 'shared/hooks/txCost'; -import { useClaimData } from 'features/withdrawals/contexts/claim-data-context'; import { useDebouncedValue } from 'shared/hooks/useDebouncedValue'; import { encodeURLQuery } from 'utils/encodeURLQuery'; import { BigNumber } from 'ethers'; import invariant from 'tiny-invariant'; import { STRATEGY_LAZY } from 'utils/swrStrategies'; +import { RequestStatusClaimable } from '../types/request-status'; type UseRequestTxPriceOptions = { requestCount?: number; @@ -112,16 +112,12 @@ export const useRequestTxPrice = ({ }; }; -export const useClaimTxPrice = () => { +export const useClaimTxPrice = (requests: RequestStatusClaimable[]) => { const { contractRpc } = useWithdrawalsContract(); - const { claimSelection } = useClaimData(); const { account, chainId } = useWeb3(); - const requestCount = claimSelection.selectedCount || 1; - const debouncedSortedSelectedRequests = useDebouncedValue( - claimSelection.sortedSelectedRequests, - 2000, - ); + const requestCount = requests.length || 1; + const debouncedSortedSelectedRequests = useDebouncedValue(requests, 2000); const { data: gasLimitResult, initialLoading: isEstimateLoading } = useLidoSWR( [ @@ -171,7 +167,7 @@ export const useClaimTxPrice = () => { loading: isEstimateLoading || !price || - debouncedSortedSelectedRequests !== claimSelection.sortedSelectedRequests, + debouncedSortedSelectedRequests !== requests, claimGasLimit: gasLimit, claimTxPriceInUsd: price, }; diff --git a/features/withdrawals/request/request-form-context/request-form-context.tsx b/features/withdrawals/request/request-form-context/request-form-context.tsx index 21004505e..5f002b32e 100644 --- a/features/withdrawals/request/request-form-context/request-form-context.tsx +++ b/features/withdrawals/request/request-form-context/request-form-context.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, createContext, useContext } from 'react'; +import { useMemo, useState, createContext, useContext, useEffect } from 'react'; import { FormProvider, useForm, useWatch } from 'react-hook-form'; import invariant from 'tiny-invariant'; import { TOKENS } from '@lido-sdk/constants'; @@ -14,6 +14,7 @@ import { RequestFormValidationContextType, ValidationResults, } from './types'; +import { useTransactionModal } from 'features/withdrawals/contexts/transaction-modal-context'; // // data context @@ -46,6 +47,7 @@ export const useValidationResults = () => { // Joint provider for form state, data, intermediate validation results // export const RequestFormProvider: React.FC = ({ children }) => { + const { dispatchModalState } = useTransactionModal(); const [intermediateValidationResults, setIntermediateValidationResults] = useState({ requests: null }); @@ -100,6 +102,10 @@ export const RequestFormProvider: React.FC = ({ children }) => { [reset, handleSubmit, request, onSuccessRequest], ); + useEffect(() => { + dispatchModalState({ type: 'set_on_retry', callback: onSubmit }); + }, [dispatchModalState, onSubmit]); + const value = useMemo(() => { return { ...requestFormData, diff --git a/features/withdrawals/request/request-form-context/use-request-form-data-context-value.ts b/features/withdrawals/request/request-form-context/use-request-form-data-context-value.ts index 658b7e5cb..b0c320173 100644 --- a/features/withdrawals/request/request-form-context/use-request-form-data-context-value.ts +++ b/features/withdrawals/request/request-form-context/use-request-form-data-context-value.ts @@ -13,7 +13,7 @@ import { STRATEGY_LAZY } from 'utils/swrStrategies'; // Provides all data fetching for form to function export const useRequestFormDataContextValue = () => { - const { update: withdrawalRequestsDataUpdate } = useClaimData(); + const { revalidate: revalidateClaimData } = useClaimData(); // useTotalSupply is bugged and switches to undefined for 1 render const stethTotalSupply = useContractSWR({ contract: useSTETHContractRPC(), @@ -47,15 +47,10 @@ export const useRequestFormDataContextValue = () => { return Promise.all([ stethUpdate(), wstethUpdate(), - withdrawalRequestsDataUpdate(), + revalidateClaimData(), unfinalizedStETHUpdate(), ]); - }, [ - stethUpdate, - unfinalizedStETHUpdate, - withdrawalRequestsDataUpdate, - wstethUpdate, - ]); + }, [stethUpdate, unfinalizedStETHUpdate, revalidateClaimData, wstethUpdate]); return useMemo( () => ({ diff --git a/features/withdrawals/request/request-form-context/validators.ts b/features/withdrawals/request/request-form-context/validators.ts index e13454fbb..67f7b4cbe 100644 --- a/features/withdrawals/request/request-form-context/validators.ts +++ b/features/withdrawals/request/request-form-context/validators.ts @@ -5,7 +5,6 @@ import { BigNumber } from 'ethers'; import invariant from 'tiny-invariant'; import { Resolver } from 'react-hook-form'; -import { getTokenDisplayName } from 'utils/getTokenDisplayName'; import { TokensWithdrawable } from 'features/withdrawals/types/tokens-withdrawable'; import { RequestFormValidationContextType, @@ -14,6 +13,9 @@ import { } from '.'; import { VALIDATION_CONTEXT_TIMEOUT } from 'features/withdrawals/withdrawals-constants'; +import { ValidationError } from 'shared/hook-form/validation-error'; +import { getTokenDisplayName } from 'utils/getTokenDisplayName'; + // helpers that should be shared when adding next hook-form export const withTimeout = (toWait: Promise, timeout: number) => @@ -24,23 +26,6 @@ export const withTimeout = (toWait: Promise, timeout: number) => ), ]); -export class ValidationError extends Error { - field: string; - type: string; - payload: Record; - constructor( - field: string, - msg: string, - type?: string, - payload?: Record, - ) { - super(msg); - this.field = field; - this.type = type ?? 'validate'; - this.payload = payload ?? {}; - } -} - export type TvlErrorPayload = { balanceDiffSteth: BigNumber; }; diff --git a/features/withdrawals/request/tx-modal/tx-request-modal.tsx b/features/withdrawals/request/tx-modal/tx-request-modal.tsx index 1314be2af..11e1cbbe6 100644 --- a/features/withdrawals/request/tx-modal/tx-request-modal.tsx +++ b/features/withdrawals/request/tx-modal/tx-request-modal.tsx @@ -19,7 +19,7 @@ import { TxRequestStageSuccess } from './tx-request-stage-success'; export const TxRequestModal = () => { const { dispatchModalState, - startTx, + onRetry, requestAmount, token, txHash, @@ -72,13 +72,7 @@ export const TxRequestModal = () => { return ; case TX_STAGE.FAIL: return ( - { - dispatchModalState({ type: 'reset' }); - startTx && startTx(); - }} - /> + ); case TX_STAGE.BUNKER: return ( @@ -99,7 +93,7 @@ export const TxRequestModal = () => { onCloseBunker, onOkBunker, requestAmount, - startTx, + onRetry, token, txHash, txStage, diff --git a/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-fail.tsx b/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-fail.tsx index 774e9194b..21942685d 100644 --- a/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-fail.tsx +++ b/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-fail.tsx @@ -23,7 +23,8 @@ export const TxStageFail: FC = (props) => { title="Transaction Error" description={failedText ?? 'Something went wrong'} footerHint={ - failedText !== ErrorMessage.NOT_ENOUGH_ETHER && ( + failedText !== ErrorMessage.NOT_ENOUGH_ETHER && + onClick && ( Retry ) } diff --git a/features/withdrawals/shared/wallet-my-requests/wallet-my-requests.tsx b/features/withdrawals/shared/wallet-my-requests/wallet-my-requests.tsx index d9758b6f3..ea70aa407 100644 --- a/features/withdrawals/shared/wallet-my-requests/wallet-my-requests.tsx +++ b/features/withdrawals/shared/wallet-my-requests/wallet-my-requests.tsx @@ -8,10 +8,9 @@ import { DATA_UNAVAILABLE } from 'config'; import { RequestCounterStyled } from './styles'; export const WalletMyRequests: FC = ({ children }) => { - const { withdrawalRequestsData, loading } = useClaimData(); + const { data, initialLoading } = useClaimData(); const { readyCount = DATA_UNAVAILABLE, pendingCount = DATA_UNAVAILABLE } = - withdrawalRequestsData || {}; - + data || {}; const title = <>My requests {children}; const requestsContent = ( @@ -40,7 +39,7 @@ export const WalletMyRequests: FC = ({ children }) => { ); diff --git a/features/withdrawals/withdrawals-tabs.tsx b/features/withdrawals/withdrawals-tabs.tsx index aeb5e92dc..b2ed25cbe 100644 --- a/features/withdrawals/withdrawals-tabs.tsx +++ b/features/withdrawals/withdrawals-tabs.tsx @@ -1,12 +1,9 @@ import { Switch } from 'shared/components'; -import { ClaimFaq } from 'features/withdrawals/withdrawals-faq/claim-faq'; -import { TransactionModalProvider } from './contexts/transaction-modal-context'; import { ClaimDataProvider } from './contexts/claim-data-context'; import { useWithdrawals } from './contexts/withdrawals-context'; -import { ClaimForm, ClaimWallet } from './claim'; -import { TxClaimModal } from './claim/tx-modal/tx-claim-modal'; +import { Claim } from './claim'; import { Request } from './request'; @@ -31,16 +28,7 @@ export const WithdrawalsTabs = () => { return ( - {isClaimTab ? ( - - - - - - - ) : ( - - )} + {isClaimTab ? : } ); }; diff --git a/package.json b/package.json index ee1d98f9b..29d382910 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@lidofinance/api-rpc": "^0.28.0", "@lidofinance/eth-api-providers": "^0.28.0", "@lidofinance/eth-providers": "^0.28.0", - "@lidofinance/lido-ui": "^3.7.4", + "@lidofinance/lido-ui": "^3.8.1", "@lidofinance/lido-ui-blocks": "2.10.2", "@lidofinance/next-api-wrapper": "^0.28.0", "@lidofinance/next-cache-files-middleware": "^0.28.0", @@ -67,7 +67,7 @@ "react": "17.0.2", "react-device-detect": "^1.17.0", "react-dom": "17.0.2", - "react-hook-form": "^7.45.1", + "react-hook-form": "^7.45.2", "react-is": "^17.0.2", "react-transition-group": "^4.4.2", "reef-knot": "^1.7.1", diff --git a/pages/api/csp-report.ts b/pages/api/csp-report.ts index afd2249f9..3aa53d1b3 100644 --- a/pages/api/csp-report.ts +++ b/pages/api/csp-report.ts @@ -1,13 +1,22 @@ -import { NextApiRequest, NextApiResponse } from 'next'; +import { wrapRequest as wrapNextRequest } from '@lidofinance/next-api-wrapper'; +import { defaultErrorHandler, rateLimit } from 'utilsApi'; +import { API } from 'types'; + +const cspReport: API = async (req, res) => { + let violation = {}; + + if (typeof req.body == 'object') { + violation = req.body; + } else if (typeof req.body === 'string') { + violation = JSON.parse(req.body); + } -export default function cspReport( - req: NextApiRequest, - res: NextApiResponse, -): void { console.warn({ type: 'CSP Violation', - ...JSON.parse(req.body), + ...violation, }); res.status(200).send({ status: 'ok' }); -} +}; + +export default wrapNextRequest([rateLimit, defaultErrorHandler])(cspReport); diff --git a/pages/api/eth-apr.ts b/pages/api/eth-apr.ts index 45dd33415..b9dcb979c 100644 --- a/pages/api/eth-apr.ts +++ b/pages/api/eth-apr.ts @@ -15,7 +15,7 @@ const cache = new Cache(); // Proxy for third-party API. // Returns eth annual percentage rate // TODO: delete after viewing grafana -const ethApr: API = async (req, res) => { +const ethApr: API = async (_, res) => { const cachedEthApr = cache.get(CACHE_ETH_APR_KEY); if (cachedEthApr) { diff --git a/pages/api/sma-steth-apr.ts b/pages/api/sma-steth-apr.ts index 46961822c..9a71bbf27 100644 --- a/pages/api/sma-steth-apr.ts +++ b/pages/api/sma-steth-apr.ts @@ -17,7 +17,7 @@ import Metrics from 'utilsApi/metrics'; const cache = new Cache(); // TODO: deprecated, will be delete after check grafana dashboards -const smaStethApr: API = async (req, res) => { +const smaStethApr: API = async (_, res) => { const cachedStethApr = cache.get(CACHE_SMA_STETH_APR_KEY); if (cachedStethApr) { diff --git a/shared/hook-form/validation-error.ts b/shared/hook-form/validation-error.ts new file mode 100644 index 000000000..46b910159 --- /dev/null +++ b/shared/hook-form/validation-error.ts @@ -0,0 +1,47 @@ +export class ValidationError extends Error { + field: string; + type: string; + payload: Record; + constructor( + field: string, + msg: string, + type?: string, + payload?: Record, + ) { + super(msg); + this.field = field; + this.type = type ?? 'validate'; + this.payload = payload ?? {}; + } +} + +export const handleResolverValidationError = ( + error: unknown, + formName: string, + fallbackErrorField: string, +) => { + if (error instanceof ValidationError) { + return { + values: {}, + errors: { + [error.field]: { + message: error.message, + type: error.type, + payload: error.payload, + }, + }, + }; + } + console.warn(`[${formName}] Unhandled validation error in resolver`, error); + return { + values: {}, + errors: { + // for general errors we use 'requests' field + // cause non-fields get ignored and form is still considerate valid + [fallbackErrorField]: { + type: 'validate', + message: 'unknown validation error', + }, + }, + }; +}; diff --git a/shared/hooks/useDebouncedValue.ts b/shared/hooks/useDebouncedValue.ts index a8d6cd70d..0399b0f92 100644 --- a/shared/hooks/useDebouncedValue.ts +++ b/shared/hooks/useDebouncedValue.ts @@ -6,7 +6,7 @@ export const useDebouncedValue = (value: T, delay: number) => { const deb = useMemo(() => debounce((_v) => s(_v), delay), [delay]); deb(value); useEffect(() => { - () => { + return () => { deb.flush(); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/test/consts.ts b/test/consts.ts index a05948895..70b5c1138 100644 --- a/test/consts.ts +++ b/test/consts.ts @@ -12,13 +12,160 @@ export interface PostRequest { schema: object; } +const FLOAT_REGEX = /^\d+(\.\d+)?$/; + +const LIDO_STATS_SCHEMA = { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + address: { + type: 'string', + }, + decimals: { + type: 'string', + }, + name: { + type: 'string', + }, + symbol: { + type: 'string', + }, + totalSupply: { + type: 'string', + }, + transfersCount: { + type: 'integer', + }, + txsCount: { + type: 'integer', + }, + lastUpdated: { + type: 'integer', + }, + issuancesCount: { + type: 'integer', + }, + holdersCount: { + type: 'integer', + }, + website: { + type: 'string', + }, + image: { + type: 'string', + }, + ethTransfersCount: { + type: 'integer', + }, + price: { + type: 'object', + properties: { + rate: { + type: 'number', + }, + diff: { + type: 'number', + }, + diff7d: { + type: 'number', + }, + ts: { + type: 'integer', + }, + marketCapUsd: { + type: 'number', + }, + availableSupply: { + type: 'number', + }, + volume24h: { + type: 'number', + }, + volDiff1: { + type: 'number', + }, + volDiff7: { + type: 'number', + }, + volDiff30: { + type: 'number', + }, + diff30d: { + type: 'number', + }, + bid: { + type: 'number', + }, + currency: { + type: 'string', + }, + }, + required: [ + 'rate', + 'diff', + 'diff7d', + 'ts', + 'marketCapUsd', + 'availableSupply', + 'volume24h', + 'volDiff1', + 'volDiff7', + 'volDiff30', + 'diff30d', + 'bid', + 'currency', + ], + additionalProperties: false, + }, + publicTags: { + type: 'array', + items: [ + { + type: 'string', + }, + ], + }, + owner: { + type: 'string', + }, + countOps: { + type: 'integer', + }, + }, + required: [ + 'address', + 'decimals', + 'name', + 'symbol', + 'totalSupply', + 'transfersCount', + 'txsCount', + 'lastUpdated', + 'issuancesCount', + 'holdersCount', + 'website', + 'image', + 'price', + 'publicTags', + 'owner', + 'countOps', + ], + additionalProperties: false, + }, + }, + required: ['data'], + additionalProperties: false, +}; + export const GET_REQUESTS: GetRequest[] = [ { uri: '/api/oneinch-rate', schema: { type: 'object', properties: { - rate: { type: 'number' }, + rate: { type: 'number', min: 0 }, }, required: ['rate'], additionalProperties: false, @@ -45,308 +192,19 @@ export const GET_REQUESTS: GetRequest[] = [ }, { uri: '/api/eth-apr', - schema: { type: 'string' }, + schema: { type: 'string', pattern: FLOAT_REGEX }, }, { uri: '/api/totalsupply', - schema: { type: 'string' }, + schema: { type: 'string', pattern: FLOAT_REGEX }, }, { uri: '/api/lidostats', - schema: { - type: 'object', - properties: { - data: { - type: 'object', - properties: { - address: { - type: 'string', - }, - decimals: { - type: 'string', - }, - name: { - type: 'string', - }, - symbol: { - type: 'string', - }, - totalSupply: { - type: 'string', - }, - transfersCount: { - type: 'integer', - }, - txsCount: { - type: 'integer', - }, - lastUpdated: { - type: 'integer', - }, - issuancesCount: { - type: 'integer', - }, - holdersCount: { - type: 'integer', - }, - website: { - type: 'string', - }, - image: { - type: 'string', - }, - ethTransfersCount: { - type: 'integer', - }, - price: { - type: 'object', - properties: { - rate: { - type: 'number', - }, - diff: { - type: 'number', - }, - diff7d: { - type: 'number', - }, - ts: { - type: 'integer', - }, - marketCapUsd: { - type: 'number', - }, - availableSupply: { - type: 'number', - }, - volume24h: { - type: 'number', - }, - volDiff1: { - type: 'number', - }, - volDiff7: { - type: 'number', - }, - volDiff30: { - type: 'number', - }, - diff30d: { - type: 'number', - }, - bid: { - type: 'number', - }, - currency: { - type: 'string', - }, - }, - required: [ - 'rate', - 'diff', - 'diff7d', - 'ts', - 'marketCapUsd', - 'availableSupply', - 'volume24h', - 'volDiff1', - 'volDiff7', - 'volDiff30', - 'diff30d', - 'bid', - 'currency', - ], - additionalProperties: false, - }, - publicTags: { - type: 'array', - items: [ - { - type: 'string', - }, - { - type: 'string', - }, - ], - }, - owner: { - type: 'string', - }, - countOps: { - type: 'integer', - }, - }, - required: [ - 'address', - 'decimals', - 'name', - 'symbol', - 'totalSupply', - 'transfersCount', - 'txsCount', - 'lastUpdated', - 'issuancesCount', - 'holdersCount', - 'website', - 'image', - 'price', - 'publicTags', - 'owner', - 'countOps', - ], - additionalProperties: false, - }, - }, - required: ['data'], - additionalProperties: false, - }, + schema: LIDO_STATS_SCHEMA, }, { uri: '/api/ldo-stats', - schema: { - type: 'object', - properties: { - data: { - type: 'object', - properties: { - address: { - type: 'string', - }, - decimals: { - type: 'string', - }, - name: { - type: 'string', - }, - symbol: { - type: 'string', - }, - totalSupply: { - type: 'string', - }, - transfersCount: { - type: 'integer', - }, - txsCount: { - type: 'integer', - }, - lastUpdated: { - type: 'integer', - }, - issuancesCount: { - type: 'integer', - }, - holdersCount: { - type: 'integer', - }, - website: { - type: 'string', - }, - image: { - type: 'string', - }, - ethTransfersCount: { - type: 'integer', - }, - price: { - type: 'object', - properties: { - rate: { - type: 'number', - }, - diff: { - type: 'number', - }, - diff7d: { - type: 'number', - }, - ts: { - type: 'integer', - }, - marketCapUsd: { - type: 'number', - }, - availableSupply: { - type: 'number', - }, - volume24h: { - type: 'number', - }, - volDiff1: { - type: 'number', - }, - volDiff7: { - type: 'number', - }, - volDiff30: { - type: 'number', - }, - diff30d: { - type: 'number', - }, - bid: { - type: 'number', - }, - currency: { - type: 'string', - }, - }, - required: [ - 'rate', - 'diff', - 'diff7d', - 'ts', - 'marketCapUsd', - 'availableSupply', - 'volume24h', - 'volDiff1', - 'volDiff7', - 'volDiff30', - 'diff30d', - 'bid', - 'currency', - ], - additionalProperties: false, - }, - publicTags: { - type: 'array', - items: [ - { - type: 'string', - }, - ], - }, - owner: { - type: 'string', - }, - countOps: { - type: 'integer', - }, - }, - required: [ - 'address', - 'decimals', - 'name', - 'symbol', - 'totalSupply', - 'transfersCount', - 'txsCount', - 'lastUpdated', - 'issuancesCount', - 'holdersCount', - 'website', - 'image', - 'price', - 'publicTags', - 'owner', - 'countOps', - ], - additionalProperties: false, - }, - }, - required: ['data'], - additionalProperties: false, - }, + schema: LIDO_STATS_SCHEMA, }, { uri: '/api/eth-price', @@ -355,6 +213,7 @@ export const GET_REQUESTS: GetRequest[] = [ properties: { price: { type: 'number', + min: 0, }, }, required: ['price'], @@ -407,6 +266,13 @@ export const GET_REQUESTS: GetRequest[] = [ }, }, }, + { + uri: '/api/sma-steth-apr', + schema: { + type: 'string', + pattern: FLOAT_REGEX, + }, + }, ]; export const POST_REQUESTS: PostRequest[] = [ @@ -424,4 +290,29 @@ export const POST_REQUESTS: PostRequest[] = [ additionalProperties: false, }, }, + { + uri: `api/csp-report`, + body: { + 'csp-report': { + 'blocked-uri': 'http://example.com/css/style.css', + disposition: 'report', + 'document-uri': 'http://example.com/signup.html', + 'effective-directive': 'style-src-elem', + 'original-policy': + "default-src 'none'; style-src cdn.example.com; report-to /_/csp-reports", + referrer: '', + 'status-code': 200, + 'violated-directive': 'style-src-elem', + }, + }, + schema: { + type: 'object', + properties: { + status: { + type: 'string', + const: 'ok', + }, + }, + }, + }, ]; diff --git a/utils/getFeeData.ts b/utils/getFeeData.ts index d7c29ea43..6353365a7 100644 --- a/utils/getFeeData.ts +++ b/utils/getFeeData.ts @@ -18,7 +18,7 @@ const getFeeHistory = ( percentile: number[], ) => { return provider.send('eth_feeHistory', [ - blockCount.toString(16), + '0x' + blockCount.toString(16), latestBlock, percentile, ]) as Promise<{ diff --git a/yarn.lock b/yarn.lock index 0bd6ebed7..a31122a65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2602,10 +2602,10 @@ dependencies: "@lidofinance/blocks-connect-wallet-modal" "2.11.2" -"@lidofinance/lido-ui@^3.7.4": - version "3.7.4" - resolved "https://registry.yarnpkg.com/@lidofinance/lido-ui/-/lido-ui-3.7.4.tgz#d708ced17b6e520bb309fcf97960579c9acbd881" - integrity sha512-yiGX4hrZDYeXWtLHeXa5ukCDiuc4x6sXuEe9g91Ah0CZyzhehmXLJP/+ILCmQM4IS64Z21FgKtMCtGJRYtVqCg== +"@lidofinance/lido-ui@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@lidofinance/lido-ui/-/lido-ui-3.8.1.tgz#21af8db3e27d12f08d032fcbedf00ea8904f9199" + integrity sha512-RalApHbilFGOePeRtRrcokMUQyH/YDcWu8njlZizNPxEDLJxMW8Y7i/rWtO05UESjEyvto2azTCDuPVuIhZHCw== dependencies: "@styled-system/should-forward-prop" "5.1.5" "@swc/helpers" "^0.4.11" @@ -9239,10 +9239,10 @@ react-dom@17.0.2, react-dom@^17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" -react-hook-form@^7.45.1: - version "7.45.1" - resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.45.1.tgz#e352c7f4dbc7540f0756abbb4dcfd1122fecc9bb" - integrity sha512-6dWoFJwycbuFfw/iKMcl+RdAOAOHDiF11KWYhNDRN/OkUt+Di5qsZHwA0OwsVnu9y135gkHpTw9DJA+WzCeR9w== +react-hook-form@^7.45.2: + version "7.45.4" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.45.4.tgz#73d228b704026ae95d7e5f7b207a681b173ec62a" + integrity sha512-HGDV1JOOBPZj10LB3+OZgfDBTn+IeEsNOKiq/cxbQAIbKaiJUe/KV8DBUzsx0Gx/7IG/orWqRRm736JwOfUSWQ== react-is@^16.13.1, react-is@^16.7.0: version "16.13.1"