From fda9fc94ad70697da5a53f5433ef86871b5b4bca Mon Sep 17 00:00:00 2001 From: asiaznik Date: Tue, 5 Dec 2023 18:35:06 +0100 Subject: [PATCH] feat: apply code spam protection --- .../referrals/apply-code-form.tsx | 59 ++++++++++++++++- .../referrals/hooks/FundsAvailable.graphql | 20 ++++++ .../hooks/__generated__/FundsAvailable.ts | 63 +++++++++++++++++++ .../referrals/hooks/use-funds-available.ts | 46 ++++++++++++++ .../referrals/hooks/use-stake-available.ts | 1 - 5 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 apps/trading/client-pages/referrals/hooks/FundsAvailable.graphql create mode 100644 apps/trading/client-pages/referrals/hooks/__generated__/FundsAvailable.ts create mode 100644 apps/trading/client-pages/referrals/hooks/use-funds-available.ts diff --git a/apps/trading/client-pages/referrals/apply-code-form.tsx b/apps/trading/client-pages/referrals/apply-code-form.tsx index d7df3c50c..34d6acfcd 100644 --- a/apps/trading/client-pages/referrals/apply-code-form.tsx +++ b/apps/trading/client-pages/referrals/apply-code-form.tsx @@ -19,6 +19,9 @@ import { useTransactionEventSubscription } from '@vegaprotocol/web3'; import { Statistics, useStats } from './referral-statistics'; import { useReferralProgram } from './hooks/use-referral-program'; import { useT } from '../../lib/use-t'; +import { useFundsAvailable } from './hooks/use-funds-available'; +import { ViewType, useSidebar } from '../../components/sidebar'; +import { useGetCurrentRouteId } from '../../lib/hooks/use-get-current-route-id'; const RELOAD_DELAY = 3000; @@ -57,10 +60,15 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => { ); const [status, setStatus] = useState< - 'requested' | 'failed' | 'successful' | null + 'requested' | 'no-funds' | 'successful' | null >(null); const txHash = useRef(null); const { isReadOnly, pubKey, sendTx } = useVegaWallet(); + const { isEligible, requiredFunds } = useFundsAvailable(); + + const currentRouteId = useGetCurrentRouteId(); + const setViews = useSidebar((s) => s.setViews); + const { register, handleSubmit, @@ -68,6 +76,7 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => { setValue, setError, watch, + clearErrors, } = useForm(); const [params] = useSearchParams(); @@ -76,6 +85,36 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => { code: validateCode(codeField, t) ? codeField : undefined, }); + /** + * Validates if a connected party can apply a code (min funds span protection) + */ + const validateFundsAvailable = useCallback(() => { + if (requiredFunds && !isEligible) { + const err = t( + 'Require minimum of {{requiredFunds}} to join a referral set to protect the network from spam.', + { replace: { requiredFunds } } + ); + return err; + } + return true; + }, [isEligible, requiredFunds, t]); + + useEffect(() => { + if (codeField) { + const err = validateFundsAvailable(); + if (err !== true) { + setStatus('no-funds'); + setError('code', { + type: 'required', + message: err, + }); + } else { + setStatus(null); + clearErrors('code'); + } + } + }, [clearErrors, codeField, isEligible, setError, validateFundsAvailable]); + /** * Validates the set a user tries to apply to. */ @@ -211,6 +250,18 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => { }; } + if (status === 'no-funds') { + return { + disabled: false, + children: t('Deposit funds'), + type: 'button' as ButtonHTMLAttributes['type'], + onClick: ((event) => { + event.preventDefault(); + setViews({ type: ViewType.Deposit }, currentRouteId); + }) as MouseEventHandler, + }; + } + if (status === 'requested') { return { disabled: true, @@ -257,8 +308,10 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => { {...register('code', { required: t('You have to provide a code to apply it.'), validate: (value) => { - const err = validateCode(value, t); - if (err !== true) return err; + const codeErr = validateCode(value, t); + if (codeErr !== true) return codeErr; + const fundsErr = validateFundsAvailable(); + if (fundsErr !== true) return fundsErr; return validateSet(); }, })} diff --git a/apps/trading/client-pages/referrals/hooks/FundsAvailable.graphql b/apps/trading/client-pages/referrals/hooks/FundsAvailable.graphql new file mode 100644 index 000000000..72ae20061 --- /dev/null +++ b/apps/trading/client-pages/referrals/hooks/FundsAvailable.graphql @@ -0,0 +1,20 @@ +query FundsAvailable($partyId: ID!) { + party(id: $partyId) { + accountsConnection { + edges { + node { + balance + asset { + decimals + symbol + id + } + } + } + } + } + networkParameter(key: "spam.protection.applyReferral.min.funds") { + key + value + } +} diff --git a/apps/trading/client-pages/referrals/hooks/__generated__/FundsAvailable.ts b/apps/trading/client-pages/referrals/hooks/__generated__/FundsAvailable.ts new file mode 100644 index 000000000..6f9dc5560 --- /dev/null +++ b/apps/trading/client-pages/referrals/hooks/__generated__/FundsAvailable.ts @@ -0,0 +1,63 @@ +import * as Types from '@vegaprotocol/types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type FundsAvailableQueryVariables = Types.Exact<{ + partyId: Types.Scalars['ID']; +}>; + + +export type FundsAvailableQuery = { __typename?: 'Query', party?: { __typename?: 'Party', accountsConnection?: { __typename?: 'AccountsConnection', edges?: Array<{ __typename?: 'AccountEdge', node: { __typename?: 'AccountBalance', balance: string, asset: { __typename?: 'Asset', decimals: number, symbol: string, id: string } } } | null> | null } | null } | null, networkParameter?: { __typename?: 'NetworkParameter', key: string, value: string } | null }; + + +export const FundsAvailableDocument = gql` + query FundsAvailable($partyId: ID!) { + party(id: $partyId) { + accountsConnection { + edges { + node { + balance + asset { + decimals + symbol + id + } + } + } + } + } + networkParameter(key: "spam.protection.applyReferral.min.funds") { + key + value + } +} + `; + +/** + * __useFundsAvailableQuery__ + * + * To run a query within a React component, call `useFundsAvailableQuery` and pass it any options that fit your needs. + * When your component renders, `useFundsAvailableQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useFundsAvailableQuery({ + * variables: { + * partyId: // value for 'partyId' + * }, + * }); + */ +export function useFundsAvailableQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(FundsAvailableDocument, options); + } +export function useFundsAvailableLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(FundsAvailableDocument, options); + } +export type FundsAvailableQueryHookResult = ReturnType; +export type FundsAvailableLazyQueryHookResult = ReturnType; +export type FundsAvailableQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/apps/trading/client-pages/referrals/hooks/use-funds-available.ts b/apps/trading/client-pages/referrals/hooks/use-funds-available.ts new file mode 100644 index 000000000..293bded04 --- /dev/null +++ b/apps/trading/client-pages/referrals/hooks/use-funds-available.ts @@ -0,0 +1,46 @@ +import { useVegaWallet } from '@vegaprotocol/wallet'; +import { useFundsAvailableQuery } from './__generated__/FundsAvailable'; +import compact from 'lodash/compact'; +import sum from 'lodash/sum'; + +/** + * Gets the funds for given public key and required min for + * the referral program. + * + * (Uses currently connected public key if left empty) + */ +export const useFundsAvailable = (pubKey?: string) => { + const { pubKey: currentPubKey } = useVegaWallet(); + const partyId = pubKey || currentPubKey; + const { data, stopPolling } = useFundsAvailableQuery({ + variables: { partyId: partyId || '' }, + skip: !partyId, + fetchPolicy: 'network-only', + errorPolicy: 'ignore', + pollInterval: 5000, + }); + + const fundsAvailable = data + ? compact(data.party?.accountsConnection?.edges?.map((e) => e?.node)) + : undefined; + const requiredFunds = data + ? BigInt(data.networkParameter?.value || '0') + : undefined; + + const sumOfFunds = sum( + fundsAvailable?.filter((fa) => fa.balance).map((fa) => BigInt(fa.balance)) + ); + + if (requiredFunds && sumOfFunds >= requiredFunds) { + stopPolling(); + } + + return { + fundsAvailable, + requiredFunds, + isEligible: + fundsAvailable != null && + requiredFunds != null && + sumOfFunds >= requiredFunds, + }; +}; diff --git a/apps/trading/client-pages/referrals/hooks/use-stake-available.ts b/apps/trading/client-pages/referrals/hooks/use-stake-available.ts index 9706c80ba..6c500697a 100644 --- a/apps/trading/client-pages/referrals/hooks/use-stake-available.ts +++ b/apps/trading/client-pages/referrals/hooks/use-stake-available.ts @@ -13,7 +13,6 @@ export const useStakeAvailable = (pubKey?: string) => { const { data } = useStakeAvailableQuery({ variables: { partyId: partyId || '' }, skip: !partyId, - // TODO: remove when network params available errorPolicy: 'ignore', });