From cb3fb25dd22b858efd4e0d349ada6982509da116 Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 24 Jun 2024 12:08:30 -0700 Subject: [PATCH] feat: add tracking to staking (#710) --- src/constants/analytics.ts | 244 ++++++++++------------ src/views/dialogs/StakingRewardDialog.tsx | 13 +- src/views/forms/StakeForm/index.tsx | 27 ++- src/views/forms/UnstakeForm/index.tsx | 27 ++- 4 files changed, 175 insertions(+), 136 deletions(-) diff --git a/src/constants/analytics.ts b/src/constants/analytics.ts index 701ba8fff..739e80076 100644 --- a/src/constants/analytics.ts +++ b/src/constants/analytics.ts @@ -119,138 +119,122 @@ export enum AnalyticsEvent { // Notification NotificationAction = 'NotificationAction', + + // Staking + StakeTransaction = 'StakeTransaction', + UnstakeTransaction = 'UnstakeTransaction', + StakeInput = 'StakeInput', + UnstakeInput = 'UnstakeInput', + ClaimTransaction = 'ClaimTransaction', } -export type AnalyticsEventData = - // App - T extends AnalyticsEvent.AppStart - ? {} - : T extends AnalyticsEvent.NetworkStatus - ? { - status: (typeof AbacusApiStatus)['name']; - /** Last time indexer node was queried successfully */ - lastSuccessfulIndexerRpcQuery?: number; - /** Time elapsed since indexer node was queried successfully */ - elapsedTime?: number; - blockHeight?: number; - indexerBlockHeight?: number; - trailingBlocks?: number; - } - : // Navigation - T extends AnalyticsEvent.NavigatePage - ? { - path: string; - } - : T extends AnalyticsEvent.NavigateDialog - ? { - type: DialogTypes; - } - : T extends AnalyticsEvent.NavigateDialogClose - ? { - type: DialogTypes; - } - : T extends AnalyticsEvent.NavigateExternal - ? { - link: string; - } - : // Wallet - T extends AnalyticsEvent.ConnectWallet - ? { - walletType: WalletType; - walletConnectionType: WalletConnectionType; - } - : T extends AnalyticsEvent.DisconnectWallet - ? {} - : // Onboarding - T extends AnalyticsEvent.OnboardingStepChanged - ? { - state: OnboardingState; - step?: OnboardingSteps; - } - : T extends AnalyticsEvent.OnboardingAccountDerived - ? { - hasPreviousTransactions: boolean; - } - : // Transfers - T extends AnalyticsEvent.TransferFaucet - ? {} - : T extends AnalyticsEvent.TransferFaucetConfirmed - ? { - /** roundtrip time between user placing an order and confirmation from indexer (client → validator → indexer → client) */ - roundtripMs: number; - /** URL/IP of node the order was sent to */ - validatorUrl: string; - } - : T extends AnalyticsEvent.TransferDeposit - ? { - chainId?: string; - tokenAddress?: string; - tokenSymbol?: string; - slippage?: number; - gasFee?: number; - bridgeFee?: number; - exchangeRate?: number; - estimatedRouteDuration?: number; - toAmount?: number; - toAmountMin?: number; - } - : T extends AnalyticsEvent.TransferWithdraw - ? { - chainId?: string; - tokenAddress?: string; - tokenSymbol?: string; - slippage?: number; - gasFee?: number; - bridgeFee?: number; - exchangeRate?: number; - estimatedRouteDuration?: number; - toAmount?: number; - toAmountMin?: number; - } - : // Trading - T extends AnalyticsEvent.TradeOrderTypeSelected - ? { - type: TradeTypes; - } - : T extends AnalyticsEvent.TradePlaceOrder - ? HumanReadablePlaceOrderPayload & { - isClosePosition: boolean; - } - : T extends AnalyticsEvent.TradePlaceOrderConfirmed - ? { - /** roundtrip time between user placing an order and confirmation from indexer (client → validator → indexer → client) */ - roundtripMs: number; - /** URL/IP of node the order was sent to */ - validatorUrl: string; - } - : T extends AnalyticsEvent.TradeCancelOrder - ? {} - : T extends AnalyticsEvent.TradeCancelOrderConfirmed - ? { - /** roundtrip time between user canceling an order and confirmation from indexer (client → validator → indexer → client) */ - roundtripMs: number; - /** URL/IP of node the order was sent to */ - validatorUrl: string; - } - : // Notifcation - T extends AnalyticsEvent.NotificationAction - ? { - type: string; - id: string; - } - : T extends AnalyticsEvent.TransferNotification - ? { - type: TransferNotificationTypes | undefined; - toAmount: number | undefined; - timeSpent: - | Record - | number - | undefined; - txHash: string; - status: 'new' | 'success' | 'error'; - triggeredAt: number | undefined; - } - : never; +type AnalyticsEventDataMap = { + [AnalyticsEvent.AppStart]: {}; + [AnalyticsEvent.NetworkStatus]: { + status: (typeof AbacusApiStatus)['name']; + lastSuccessfulIndexerRpcQuery?: number; + elapsedTime?: number; + blockHeight?: number; + indexerBlockHeight?: number; + trailingBlocks?: number; + }; + [AnalyticsEvent.NavigatePage]: { path: string }; + [AnalyticsEvent.NavigateDialog]: { type: DialogTypes }; + [AnalyticsEvent.NavigateDialogClose]: { type: DialogTypes }; + [AnalyticsEvent.NavigateExternal]: { link: string }; + [AnalyticsEvent.ConnectWallet]: { + walletType: WalletType; + walletConnectionType: WalletConnectionType; + }; + [AnalyticsEvent.DisconnectWallet]: {}; + [AnalyticsEvent.OnboardingStepChanged]: { + state: OnboardingState; + step?: OnboardingSteps; + }; + [AnalyticsEvent.OnboardingAccountDerived]: { + hasPreviousTransactions: boolean; + }; + [AnalyticsEvent.TransferFaucet]: {}; + [AnalyticsEvent.TransferFaucetConfirmed]: { + roundtripMs: number; + validatorUrl: string; + }; + [AnalyticsEvent.TransferDeposit]: { + chainId?: string; + tokenAddress?: string; + tokenSymbol?: string; + slippage?: number; + gasFee?: number; + bridgeFee?: number; + exchangeRate?: number; + estimatedRouteDuration?: number; + toAmount?: number; + toAmountMin?: number; + }; + [AnalyticsEvent.TransferWithdraw]: { + chainId?: string; + tokenAddress?: string; + tokenSymbol?: string; + slippage?: number; + gasFee?: number; + bridgeFee?: number; + exchangeRate?: number; + estimatedRouteDuration?: number; + toAmount?: number; + toAmountMin?: number; + }; + [AnalyticsEvent.TradeOrderTypeSelected]: { type: TradeTypes }; + [AnalyticsEvent.TradePlaceOrder]: HumanReadablePlaceOrderPayload & { + isClosePosition: boolean; + }; + [AnalyticsEvent.TradePlaceOrderConfirmed]: { + roundtripMs: number; + validatorUrl: string; + }; + [AnalyticsEvent.TradeCancelOrder]: {}; + [AnalyticsEvent.TradeCancelOrderConfirmed]: { + roundtripMs: number; + validatorUrl: string; + }; + [AnalyticsEvent.NotificationAction]: { + type: string; + id: string; + }; + [AnalyticsEvent.TransferNotification]: { + type: TransferNotificationTypes | undefined; + toAmount: number | undefined; + timeSpent: Record | number | undefined; + txHash: string; + status: 'new' | 'success' | 'error'; + triggeredAt: number | undefined; + }; + [AnalyticsEvent.StakeTransaction]: { + txHash?: string; + amount?: number; + validatorAddress?: string; + }; + [AnalyticsEvent.UnstakeTransaction]: { + txHash?: string; + amount?: number; + validatorAddresses?: string[]; + }; + [AnalyticsEvent.ClaimTransaction]: { + txHash?: string; + amount?: string; + }; + [AnalyticsEvent.StakeInput]: { + amount?: number; + validatorAddress?: string; + }; + [AnalyticsEvent.UnstakeInput]: { + amount?: number; + validatorAddress?: string; + }; +}; + +export type AnalyticsEventData = T extends keyof AnalyticsEventDataMap + ? AnalyticsEventDataMap[T] + : never; export const DEFAULT_TRANSACTION_MEMO = 'dYdX Frontend (web)'; export const lastSuccessfulRestRequestByOrigin: Record = {}; diff --git a/src/views/dialogs/StakingRewardDialog.tsx b/src/views/dialogs/StakingRewardDialog.tsx index 115657fe4..407f3063c 100644 --- a/src/views/dialogs/StakingRewardDialog.tsx +++ b/src/views/dialogs/StakingRewardDialog.tsx @@ -6,6 +6,7 @@ import styled, { css } from 'styled-components'; import { formatUnits } from 'viem'; import { AlertType } from '@/constants/alerts'; +import { AnalyticsEvent } from '@/constants/analytics'; import { STRING_KEYS } from '@/constants/localization'; import { NumberSign, SMALL_USD_DECIMALS } from '@/constants/numbers'; @@ -29,8 +30,10 @@ import { getSubaccountEquity } from '@/state/accountSelectors'; import { useAppSelector } from '@/state/appTypes'; import { getChartDotBackground } from '@/state/configsSelectors'; +import { track } from '@/lib/analytics'; import { BigNumberish, MustBigNumber } from '@/lib/numbers'; import { log } from '@/lib/telemetry'; +import { hashFromTx } from '@/lib/txUtils'; type ElementProps = { validators: string[]; @@ -117,7 +120,13 @@ export const StakingRewardDialog = ({ validators, usdcRewards, setIsOpen }: Elem try { setIsLoading(true); setError(undefined); - await withdrawReward(validators); + const tx = await withdrawReward(validators); + const txHash = hashFromTx(tx.hash); + + track(AnalyticsEvent.ClaimTransaction, { + txHash, + amount: usdcRewards.toString(), + }); setIsOpen(false); } catch (err) { log('StakeRewardDialog/withdrawReward', err); @@ -129,7 +138,7 @@ export const StakingRewardDialog = ({ validators, usdcRewards, setIsOpen }: Elem } finally { setIsLoading(false); } - }, [validators, withdrawReward, setIsOpen]); + }, [validators, withdrawReward, setIsOpen, usdcRewards]); return ( <$Dialog isOpen setIsOpen={setIsOpen} hasHeaderBlur={false}> diff --git a/src/views/forms/StakeForm/index.tsx b/src/views/forms/StakeForm/index.tsx index e4f184861..12e251abe 100644 --- a/src/views/forms/StakeForm/index.tsx +++ b/src/views/forms/StakeForm/index.tsx @@ -1,12 +1,14 @@ -import { useCallback, useEffect, useState, type FormEvent } from 'react'; +import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; import BigNumber from 'bignumber.js'; +import { debounce } from 'lodash'; import { type NumberFormatValues } from 'react-number-format'; import styled from 'styled-components'; import { formatUnits } from 'viem'; import { AMOUNT_RESERVED_FOR_GAS_DYDX } from '@/constants/account'; import { AlertType } from '@/constants/alerts'; +import { AnalyticsEvent } from '@/constants/analytics'; import { DialogTypes } from '@/constants/dialogs'; import { STRING_KEYS } from '@/constants/localization'; import { NumberSign } from '@/constants/numbers'; @@ -33,8 +35,10 @@ import { StakeButtonAndReceipt } from '@/views/forms/StakeForm/StakeButtonAndRec import { useAppDispatch } from '@/state/appTypes'; import { forceOpenDialog } from '@/state/dialogs'; +import { track } from '@/lib/analytics'; import { BigNumberish, MustBigNumber } from '@/lib/numbers'; import { log } from '@/lib/telemetry'; +import { hashFromTx } from '@/lib/txUtils'; type StakeFormProps = { onDone?: () => void; @@ -105,8 +109,20 @@ export const StakeForm = ({ onDone, className }: StakeFormProps) => { } }, [getDelegateFee, amountBN, selectedValidator, isAmountValid, chainTokenDecimals]); + const debouncedChangeTrack = useMemo( + () => + debounce((amount?: number, validator?: string) => { + track(AnalyticsEvent.StakeInput, { + amount, + validatorAddress: validator, + }); + }, 1000), + [] + ); + const onChangeAmount = (value?: BigNumber) => { setAmountBN(value); + debouncedChangeTrack(value?.toNumber(), selectedValidator?.operatorAddress); }; const onStake = useCallback(async () => { @@ -115,7 +131,14 @@ export const StakeForm = ({ onDone, className }: StakeFormProps) => { } try { setIsLoading(true); - await delegate(selectedValidator.operatorAddress, amountBN.toNumber()); + const tx = await delegate(selectedValidator.operatorAddress, amountBN.toNumber()); + const txHash = hashFromTx(tx.hash); + + track(AnalyticsEvent.StakeTransaction, { + txHash, + amount: amountBN.toNumber(), + validatorAddress: selectedValidator.operatorAddress, + }); onDone?.(); } catch (err) { log('StakeForm/onStake', err); diff --git a/src/views/forms/UnstakeForm/index.tsx b/src/views/forms/UnstakeForm/index.tsx index 32ada7e7c..98dbe2869 100644 --- a/src/views/forms/UnstakeForm/index.tsx +++ b/src/views/forms/UnstakeForm/index.tsx @@ -1,10 +1,12 @@ import React, { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; +import { debounce } from 'lodash'; import { type NumberFormatValues } from 'react-number-format'; import styled from 'styled-components'; import { formatUnits } from 'viem'; import { AlertType } from '@/constants/alerts'; +import { AnalyticsEvent } from '@/constants/analytics'; import { ButtonAction } from '@/constants/buttons'; import { STRING_KEYS } from '@/constants/localization'; import { NumberSign } from '@/constants/numbers'; @@ -30,8 +32,10 @@ import { WithDetailsReceipt } from '@/components/WithDetailsReceipt'; import { StakeButtonAlert } from '@/views/StakeRewardButtonAndReceipt'; import { UnstakeButtonAndReceipt } from '@/views/forms/UnstakeForm/UnstakeButtonAndReceipt'; +import { track } from '@/lib/analytics'; import { BigNumberish, MustBigNumber } from '@/lib/numbers'; import { log } from '@/lib/telemetry'; +import { hashFromTx } from '@/lib/txUtils'; type UnstakeFormProps = { onDone?: () => void; @@ -121,7 +125,14 @@ export const UnstakeForm = ({ onDone, className }: UnstakeFormProps) => { } try { setIsLoading(true); - await undelegate(amounts); + const tx = await undelegate(amounts); + const txHash = hashFromTx(tx.hash); + + track(AnalyticsEvent.UnstakeTransaction, { + txHash, + amount: totalAmount, + validatorAddresses: Object.keys(amounts), + }); onDone?.(); } catch (err) { log('UnstakeForm/onUnstake', err); @@ -133,10 +144,22 @@ export const UnstakeForm = ({ onDone, className }: UnstakeFormProps) => { } finally { setIsLoading(false); } - }, [isAmountValid, amounts, undelegate, onDone]); + }, [isAmountValid, amounts, undelegate, onDone, totalAmount]); + + const debouncedChangeTrack = useMemo( + () => + debounce((amount: number | undefined, validator: string) => { + track(AnalyticsEvent.UnstakeInput, { + amount, + validatorAddress: validator, + }); + }, 1000), + [] + ); const onChangeAmount = useCallback((validator: string, value: number | undefined) => { setAmounts((a) => ({ ...a, [validator]: value })); + debouncedChangeTrack(value, validator); }, []); const setAllUnstakeAmountsToMax = useCallback(() => {