From 2a552fd5484d9f38182dce081c343203d4239154 Mon Sep 17 00:00:00 2001 From: Bill Date: Thu, 3 Oct 2024 10:00:27 -0700 Subject: [PATCH] feat: implement Affiliates protocol interactions (#1074) --- package.json | 4 +- pnpm-lock.yaml | 18 ++--- src/constants/localStorage.ts | 1 - src/hooks/useAffiliatesInfo.ts | 40 +++++++---- src/hooks/useDydxClient.tsx | 16 +++++ src/hooks/useReferralAddress.ts | 4 +- src/hooks/useReferredBy.ts | 36 ++++++++++ src/hooks/useRegisterAffiliate.ts | 36 ++++++++++ src/hooks/useSubaccount.tsx | 81 +++++++++++++++++++++- src/state/_store.ts | 4 +- src/state/affiliates.ts | 25 +++++++ src/state/affiliatesSelector.ts | 6 ++ src/views/dialogs/ReferralDialog.tsx | 31 +++++---- src/views/dialogs/ShareAffiliateDialog.tsx | 16 +++-- src/views/menus/AccountMenu.tsx | 17 +++++ 15 files changed, 280 insertions(+), 55 deletions(-) create mode 100644 src/hooks/useReferredBy.ts create mode 100644 src/hooks/useRegisterAffiliate.ts create mode 100644 src/state/affiliates.ts create mode 100644 src/state/affiliatesSelector.ts diff --git a/package.json b/package.json index aa55c36de..285dc10ac 100644 --- a/package.json +++ b/package.json @@ -57,9 +57,9 @@ "@cosmjs/tendermint-rpc": "^0.32.1", "@datadog/browser-logs": "^5.23.3", "@dydxprotocol/v4-abacus": "1.12.12", - "@dydxprotocol/v4-client-js": "1.6.1", + "@dydxprotocol/v4-client-js": "1.10.0", "@dydxprotocol/v4-localization": "^1.1.213", - "@dydxprotocol/v4-proto": "^6.0.1", + "@dydxprotocol/v4-proto": "^7.0.0-dev.0", "@emotion/is-prop-valid": "^1.3.0", "@ethersproject/providers": "^5.7.2", "@hugocxl/react-to-image": "^0.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d71364340..1e8569037 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,14 +33,14 @@ dependencies: specifier: 1.12.12 version: 1.12.12 '@dydxprotocol/v4-client-js': - specifier: 1.6.1 - version: 1.6.1 + specifier: 1.10.0 + version: 1.10.0 '@dydxprotocol/v4-localization': specifier: ^1.1.213 version: 1.1.213 '@dydxprotocol/v4-proto': - specifier: ^6.0.1 - version: 6.0.1 + specifier: ^7.0.0-dev.0 + version: 7.0.0-dev.0 '@emotion/is-prop-valid': specifier: ^1.3.0 version: 1.3.0 @@ -3032,8 +3032,8 @@ packages: format-util: 1.0.5 dev: false - /@dydxprotocol/v4-client-js@1.6.1: - resolution: {integrity: sha512-snAF4oXnd1QDu7ClT0RGuU7t35PenLvbRP4+SG5uuWQ6s5UH7bcQdtsun0UNbi5l7rRv6txIc5wmcLInVKezdg==} + /@dydxprotocol/v4-client-js@1.10.0: + resolution: {integrity: sha512-qvZqj8htZOg+pTPA3ixTIa9ChdhALHU5K1VXRU5/d5fziFrt5iDsieSsVmPj9PE8I9VpWFBG7e/HXybtFM2usw==} dependencies: '@cosmjs/amino': 0.32.3 '@cosmjs/encoding': 0.32.3 @@ -3065,12 +3065,6 @@ packages: resolution: {integrity: sha512-ikXFptZ2uRiTaS14wms5/nkbrXFnZHzusJSvIUUcu7Dmth33doFxLaSZEW/IhFe+G5C7EOLk5Yg5xUDOCLaktg==} dev: false - /@dydxprotocol/v4-proto@6.0.1: - resolution: {integrity: sha512-+L4mT8AM5MlrnT7a817uepSSKCpvMmE6LV5xQKnH18GxLs7JOv5nQTnONbmi6roz12R7Vi1CntRZuz7Lg6l5Sw==} - dependencies: - protobufjs: 6.11.4 - dev: false - /@dydxprotocol/v4-proto@7.0.0-dev.0: resolution: {integrity: sha512-yQ3xMW8GmKCCwtzXF1E/TMYvPYDPRmAR2T/AFXKlE2YF/P/yQMrz/IySzX4Z+wyAMI+G4Sr+AML7V8ehvAcjog==} dependencies: diff --git a/src/constants/localStorage.ts b/src/constants/localStorage.ts index e0ba31d43..0d9371f4a 100644 --- a/src/constants/localStorage.ts +++ b/src/constants/localStorage.ts @@ -8,7 +8,6 @@ export enum LocalStorageKey { EvmDerivedAddresses = 'dydx.EvmDerivedAddresses', KeplrCompliance = 'dydx.KeplrCompliance', SolDerivedAddresses = 'dydx.SolDerivedAddresses', - LatestReferrer = 'dydx.LatestReferrer', // Gas SelectedGasDenom = 'dydx.SelectedGasDenom', diff --git a/src/hooks/useAffiliatesInfo.ts b/src/hooks/useAffiliatesInfo.ts index 8888ac7db..eaae40baf 100644 --- a/src/hooks/useAffiliatesInfo.ts +++ b/src/hooks/useAffiliatesInfo.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; -import { useAccounts } from './useAccounts'; +import { log } from '@/lib/telemetry'; + import { useDydxClient } from './useDydxClient'; type AffiliatesMetadata = { @@ -9,31 +10,40 @@ type AffiliatesMetadata = { isAffiliate: boolean; }; -export const useAffiliatesInfo = () => { - const { dydxAddress } = useAccounts(); - const { compositeClient } = useDydxClient(); +export const useAffiliatesInfo = (dydxAddress?: string) => { + const { compositeClient, getAffiliateInfo } = useDydxClient(); const queryFn = async () => { if (!compositeClient || !dydxAddress) { return undefined; } const endpoint = `${compositeClient.indexerClient.config.restEndpoint}/v4/affiliates/metadata`; - const response = await fetch(`${endpoint}?address=${encodeURIComponent(dydxAddress)}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - const data = await response.json(); - return data as AffiliatesMetadata | undefined; + + try { + const response = await fetch(`${endpoint}?address=${encodeURIComponent(dydxAddress)}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + const affiliateInfo = await getAffiliateInfo(dydxAddress); + + const data: AffiliatesMetadata | undefined = await response.json(); + const isEligible = + Boolean(data?.isVolumeEligible) || Boolean(affiliateInfo?.isWhitelisted) || true; + + return { metadata: data, affiliateInfo, isEligible }; + } catch (error) { + log('useAffiliatesInfo', error, { endpoint }); + throw error; + } }; - const { data, isFetched } = useQuery({ + const query = useQuery({ queryKey: ['affiliatesMetadata', dydxAddress], queryFn, enabled: Boolean(compositeClient && dydxAddress), }); - return { data, isFetched }; + return query; }; diff --git a/src/hooks/useDydxClient.tsx b/src/hooks/useDydxClient.tsx index ccfd4f4db..63757ccc3 100644 --- a/src/hooks/useDydxClient.tsx +++ b/src/hooks/useDydxClient.tsx @@ -488,6 +488,20 @@ const useDydxClientContext = () => { [compositeClient] ); + const getAffiliateInfo = useCallback( + async (address: string) => { + return compositeClient?.validatorClient.get.getAffiliateInfo(address); + }, + [compositeClient] + ); + + const getReferredBy = useCallback( + async (address: string) => { + return compositeClient?.validatorClient.get.getReferredBy(address); + }, + [compositeClient] + ); + return { // Client initialization connect: setNetworkConfig, @@ -518,6 +532,8 @@ const useDydxClientContext = () => { getWithdrawalCapacityByDenom, getValidators, getAccountBalance, + getAffiliateInfo, + getReferredBy, // vault methods getMegavaultHistoricalPnl, diff --git a/src/hooks/useReferralAddress.ts b/src/hooks/useReferralAddress.ts index ff6731424..ada4525a8 100644 --- a/src/hooks/useReferralAddress.ts +++ b/src/hooks/useReferralAddress.ts @@ -21,11 +21,11 @@ export const useReferralAddress = (refCode: string) => { return data?.address as string | undefined; }; - const { data, isFetched } = useQuery({ + const query = useQuery({ queryKey: ['referralAddress', refCode], queryFn, enabled: Boolean(compositeClient && refCode), }); - return { data, isFetched }; + return query; }; diff --git a/src/hooks/useReferredBy.ts b/src/hooks/useReferredBy.ts new file mode 100644 index 000000000..21f71cc5f --- /dev/null +++ b/src/hooks/useReferredBy.ts @@ -0,0 +1,36 @@ +import { useQuery } from '@tanstack/react-query'; + +import { DydxAddress } from '@/constants/wallets'; + +import { log } from '@/lib/telemetry'; + +import { useAccounts } from './useAccounts'; +import { useDydxClient } from './useDydxClient'; + +export const useReferredBy = () => { + const { dydxAddress } = useAccounts(); + const { getReferredBy, compositeClient } = useDydxClient(); + + const queryFn = async ({ queryKey }: { queryKey: (string | DydxAddress | undefined)[] }) => { + const [, address] = queryKey; + if (!address) { + return undefined; + } + try { + const affliateAddress = await getReferredBy(address); + + return affliateAddress?.affiliateAddress; + } catch (error) { + log('useReferredBy', error); + return undefined; + } + }; + + const { data, isFetched } = useQuery({ + queryKey: ['referredBy', dydxAddress], + queryFn, + enabled: Boolean(compositeClient && dydxAddress), + }); + + return { data, isFetched }; +}; diff --git a/src/hooks/useRegisterAffiliate.ts b/src/hooks/useRegisterAffiliate.ts new file mode 100644 index 000000000..fdbd27567 --- /dev/null +++ b/src/hooks/useRegisterAffiliate.ts @@ -0,0 +1,36 @@ +import { useQuery } from '@tanstack/react-query'; + +import { DydxAddress } from '@/constants/wallets'; + +import { log } from '@/lib/telemetry'; + +import { useAccounts } from './useAccounts'; +import { useDydxClient } from './useDydxClient'; + +export const useReferredBy = () => { + const { dydxAddress } = useAccounts(); + const { getReferredBy } = useDydxClient(); + + const queryFn = async ({ queryKey }: { queryKey: (string | DydxAddress | undefined)[] }) => { + const [, address] = queryKey; + if (!address) { + return undefined; + } + try { + const affliateAddress = await getReferredBy(address); + + return affliateAddress?.affiliateAddress; + } catch (error) { + log('useReferredBy', error); + return undefined; + } + }; + + const { data, isFetched } = useQuery({ + queryKey: ['referredBy', dydxAddress], + queryFn, + enabled: Boolean(dydxAddress), + }); + + return { data, isFetched }; +}; diff --git a/src/hooks/useSubaccount.tsx b/src/hooks/useSubaccount.tsx index 89f6468ea..718d45bd6 100644 --- a/src/hooks/useSubaccount.tsx +++ b/src/hooks/useSubaccount.tsx @@ -10,6 +10,7 @@ import { type GovAddNewMarketParams, type LocalWallet, } from '@dydxprotocol/v4-client-js'; +import { useMutation } from '@tanstack/react-query'; import Long from 'long'; import { shallowEqual } from 'react-redux'; import { formatUnits, parseUnits } from 'viem'; @@ -32,6 +33,8 @@ import { DydxAddress, WalletType } from '@/constants/wallets'; import { clearSubaccountState } from '@/state/account'; import { getBalances, getSubaccountOrders } from '@/state/accountSelectors'; +import { removeLatestReferrer } from '@/state/affiliates'; +import { getLatestReferrer } from '@/state/affiliatesSelector'; import { useAppDispatch, useAppSelector } from '@/state/appTypes'; import { openDialog } from '@/state/dialogs'; import { @@ -55,6 +58,7 @@ import { hashFromTx } from '@/lib/txUtils'; import { useAccounts } from './useAccounts'; import { useDydxClient } from './useDydxClient'; import { useGovernanceVariables } from './useGovernanceVariables'; +import { useReferredBy } from './useReferredBy'; import { useTokenConfigs } from './useTokenConfigs'; type SubaccountContextType = ReturnType; @@ -286,7 +290,6 @@ const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: LocalWall const balanceAmount = parseFloat(balance.amount); const shouldDeposit = balanceAmount - AMOUNT_RESERVED_FOR_GAS_USDC > 0; const shouldWithdraw = balanceAmount - AMOUNT_USDC_BEFORE_REBALANCE <= 0; - if (shouldDeposit) { await depositToSubaccount({ amount: balanceAmount - AMOUNT_RESERVED_FOR_GAS_USDC, @@ -909,6 +912,79 @@ const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: LocalWall [localDydxWallet, compositeClient] ); + const registerAffiliate = useCallback( + async (affiliate: string) => { + if (!compositeClient) { + throw new Error('client not initialized'); + } + if (!subaccountClient?.wallet?.address) { + throw new Error('wallet not initialized'); + } + if (affiliate === subaccountClient?.wallet?.address) { + throw new Error('affiliate can not be the same as referree'); + } + try { + const response = await compositeClient?.validatorClient.post.registerAffiliate( + subaccountClient, + affiliate + ); + return response; + } catch (error) { + log('useSubaccount/registerAffiliate', error); + throw error; + } + }, + [subaccountClient, compositeClient] + ); + + const latestReferrer = useAppSelector(getLatestReferrer); + const { data: referredBy, isFetched: isReferredByFetched } = useReferredBy(); + + const { mutateAsync: registerAffiliateMutate, isPending: isRegisterAffiliatePending } = + useMutation({ + mutationFn: async (affiliate: string) => { + const tx = await registerAffiliate(affiliate); + dispatch(removeLatestReferrer()); + return tx; + }, + }); + + useEffect(() => { + if (!subaccountClient) return; + + if (dydxAddress === latestReferrer) { + dispatch(removeLatestReferrer()); + return; + } + if ( + latestReferrer && + dydxAddress && + usdcCoinBalance && + parseFloat(usdcCoinBalance.amount) > AMOUNT_USDC_BEFORE_REBALANCE && + isReferredByFetched && + !referredBy && + !isRegisterAffiliatePending + ) { + registerAffiliateMutate(latestReferrer); + } + }, [ + latestReferrer, + dydxAddress, + registerAffiliateMutate, + usdcCoinBalance, + subaccountClient, + isReferredByFetched, + referredBy, + dispatch, + isRegisterAffiliatePending, + ]); + + useEffect(() => { + if (referredBy && latestReferrer) { + dispatch(removeLatestReferrer()); + } + }, [referredBy, dispatch, latestReferrer]); + const getVaultAccountInfo = useCallback(async () => { if (!compositeClient?.validatorClient) { throw new Error('client not initialized'); @@ -983,6 +1059,9 @@ const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: LocalWall withdrawReward, getWithdrawRewardFee, + // affiliates + registerAffiliate, + // vaults getVaultAccountInfo, depositToMegavault, diff --git a/src/state/_store.ts b/src/state/_store.ts index 419834b2a..570ea895b 100644 --- a/src/state/_store.ts +++ b/src/state/_store.ts @@ -5,6 +5,7 @@ import storage from 'redux-persist/lib/storage'; import abacusStateManager from '@/lib/abacus'; import { accountSlice } from './account'; +import { affiliatesSlice } from './affiliates'; import { appSlice } from './app'; import appMiddleware from './appMiddleware'; import { assetsSlice } from './assets'; @@ -24,6 +25,7 @@ import { walletSlice } from './wallet'; const reducers = { account: accountSlice.reducer, + affiliates: affiliatesSlice.reducer, app: appSlice.reducer, assets: assetsSlice.reducer, configs: configsSlice.reducer, @@ -45,7 +47,7 @@ const persistConfig = { key: 'root', version: 1, storage, - whitelist: ['tradingView', 'wallet'], + whitelist: ['tradingView', 'wallet', 'affiliates'], migrate: customCreateMigrate({ debug: process.env.NODE_ENV !== 'production' }), }; diff --git a/src/state/affiliates.ts b/src/state/affiliates.ts new file mode 100644 index 000000000..ea8fa5b21 --- /dev/null +++ b/src/state/affiliates.ts @@ -0,0 +1,25 @@ +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; + +export interface AffiliatesState { + latestReferrer: string | undefined; +} + +const initialState: AffiliatesState = { + latestReferrer: undefined, +}; + +export const affiliatesSlice = createSlice({ + name: 'Affiliates', + initialState, + reducers: { + updateLatestReferrer: (state, action: PayloadAction) => { + state.latestReferrer = action.payload; + }, + removeLatestReferrer: (state) => { + state.latestReferrer = undefined; + }, + }, +}); + +export const { updateLatestReferrer, removeLatestReferrer } = affiliatesSlice.actions; diff --git a/src/state/affiliatesSelector.ts b/src/state/affiliatesSelector.ts new file mode 100644 index 000000000..86aca39c8 --- /dev/null +++ b/src/state/affiliatesSelector.ts @@ -0,0 +1,6 @@ +import { RootState } from './_store'; + +/** + * @returns saved latestReferrer for Affiliates + */ +export const getLatestReferrer = (state: RootState) => state.affiliates.latestReferrer; diff --git a/src/views/dialogs/ReferralDialog.tsx b/src/views/dialogs/ReferralDialog.tsx index 946c782d6..bf57ca206 100644 --- a/src/views/dialogs/ReferralDialog.tsx +++ b/src/views/dialogs/ReferralDialog.tsx @@ -5,17 +5,19 @@ import { styled } from 'twin.macro'; import { AFFILIATES_FEE_DISCOUNT } from '@/constants/affiliates'; import { ButtonSize } from '@/constants/buttons'; import { DialogProps, ReferralDialogProps } from '@/constants/dialogs'; -import { LocalStorageKey } from '@/constants/localStorage'; import { STRING_KEYS } from '@/constants/localization'; import { useAccounts } from '@/hooks/useAccounts'; -import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { useAffiliatesInfo } from '@/hooks/useAffiliatesInfo'; import { useReferralAddress } from '@/hooks/useReferralAddress'; import { useStringGetter } from '@/hooks/useStringGetter'; import { Dialog } from '@/components/Dialog'; import { Link } from '@/components/Link'; +import { updateLatestReferrer } from '@/state/affiliates'; +import { useAppDispatch } from '@/state/appTypes'; + import { truncateAddress } from '@/lib/wallet'; import { OnboardingTriggerButton } from './OnboardingTriggerButton'; @@ -37,18 +39,19 @@ const CONTENT_SECTIONS = [ export const ReferralDialog = ({ setIsOpen, refCode }: DialogProps) => { const stringGetter = useStringGetter(); + const dispatch = useAppDispatch(); const { dydxAddress } = useAccounts(); - const { data: referralAddress, isFetched } = useReferralAddress(refCode); - const [, saveLastestReferrer] = useLocalStorage({ - key: LocalStorageKey.LatestReferrer, - defaultValue: undefined, - }); + const { data: referralAddress, isSuccess } = useReferralAddress(refCode); + const { data: affiliatesInfo, isSuccess: isAffiliatesInfoSuccess } = + useAffiliatesInfo(referralAddress); useEffect(() => { if (referralAddress) { - saveLastestReferrer(referralAddress); + dispatch(updateLatestReferrer(referralAddress)); } - }, [referralAddress, saveLastestReferrer]); + }, [referralAddress, dispatch]); + + const isEligible = referralAddress && affiliatesInfo?.isEligible; return ( {stringGetter({ - key: referralAddress ? STRING_KEYS.YOUR_FRIEND : STRING_KEYS.WELCOME_DYDX, + key: isEligible ? STRING_KEYS.YOUR_FRIEND : STRING_KEYS.WELCOME_DYDX, })} {truncateAddress(referralAddress)} } description={stringGetter({ - key: referralAddress ? STRING_KEYS.INVITED_YOU : STRING_KEYS.THE_PRO_TRADING_PLATFORM, + key: isEligible ? STRING_KEYS.INVITED_YOU : STRING_KEYS.THE_PRO_TRADING_PLATFORM, })} slotHeaderAbove={ - isFetched ? ( + isSuccess && isAffiliatesInfoSuccess ? ( <$HeaderAbove tw="flex flex-row items-center gap-1"> - {referralAddress ? ( + {isEligible ? ( hedgie ) : (
@@ -81,7 +84,7 @@ export const ReferralDialog = ({ setIsOpen, refCode }: DialogProps <$Triangle />
- {referralAddress ? ( + {isEligible ? ( {stringGetter({ key: STRING_KEYS.REFER_FOR_DISCOUNTS_FIRST_ORDER, diff --git a/src/views/dialogs/ShareAffiliateDialog.tsx b/src/views/dialogs/ShareAffiliateDialog.tsx index 28a6e1494..9c3610613 100644 --- a/src/views/dialogs/ShareAffiliateDialog.tsx +++ b/src/views/dialogs/ShareAffiliateDialog.tsx @@ -7,6 +7,7 @@ import { DialogProps, ShareAffiliateDialogProps } from '@/constants/dialogs'; import { STRING_KEYS } from '@/constants/localization'; import { ColorToken } from '@/constants/styles/base'; +import { useAccounts } from '@/hooks/useAccounts'; import { useAffiliatesInfo } from '@/hooks/useAffiliatesInfo'; import { useStringGetter } from '@/hooks/useStringGetter'; import { useURLConfigs } from '@/hooks/useURLConfigs'; @@ -32,7 +33,8 @@ const copyBlobToClipboard = async (blob: Blob | null) => { export const ShareAffiliateDialog = ({ setIsOpen }: DialogProps) => { const stringGetter = useStringGetter(); const { affiliateProgram } = useURLConfigs(); - const { data } = useAffiliatesInfo(); + const { dydxAddress } = useAccounts(); + const { data } = useAffiliatesInfo(dydxAddress as string); const [{ isLoading: isCopying }, , ref] = useToBlob({ quality: 1.0, @@ -56,7 +58,7 @@ export const ShareAffiliateDialog = ({ setIsOpen }: DialogProps
- {data?.isVolumeEligible + {data?.isEligible ? stringGetter({ key: STRING_KEYS.AFFILIATE_LINK }) : stringGetter({ key: STRING_KEYS.AFFILIATE_LINK_REQUIREMENT, @@ -92,7 +94,7 @@ export const ShareAffiliateDialog = ({ setIsOpen }: DialogProps
- {data?.isVolumeEligible + {data?.isEligible ? affiliatesUrl : stringGetter({ key: STRING_KEYS.YOUVE_TRADED, @@ -103,7 +105,7 @@ export const ShareAffiliateDialog = ({ setIsOpen }: DialogProps
- {data?.isVolumeEligible && ( + {data?.isEligible && ( {stringGetter({ key: STRING_KEYS.COPY_LINK })} @@ -144,7 +146,7 @@ export const ShareAffiliateDialog = ({ setIsOpen }: DialogProps - {data?.isVolumeEligible && ( + {data?.isEligible && (