From 8beb2738e8026698c66a3c957b9cb0352e7ca4d6 Mon Sep 17 00:00:00 2001 From: 0xdef1cafe <88504456+0xdef1cafe@users.noreply.github.com> Date: Tue, 3 Oct 2023 11:20:41 +1100 Subject: [PATCH] feat: plotly interactive chart thing (#5375) Co-authored-by: reallybeard <89934888+reallybeard@users.noreply.github.com> --- .env.base | 1 + .env.dev | 3 +- package.json | 1 + src/components/FeeExplainer/FeeExplainer.tsx | 369 ++++++++++++++++++ .../FeeExplainer/components/FeeInput.tsx | 69 ++++ src/config.ts | 1 + src/lib/fees/model.test.ts | 28 +- src/lib/fees/model.ts | 39 +- src/pages/Trade/Trade.tsx | 5 +- .../preferencesSlice/preferencesSlice.ts | 2 + src/test/mocks/store.ts | 1 + yarn.lock | 18 + 12 files changed, 514 insertions(+), 23 deletions(-) create mode 100644 src/components/FeeExplainer/FeeExplainer.tsx create mode 100644 src/components/FeeExplainer/components/FeeInput.tsx diff --git a/.env.base b/.env.base index a900fe1c716..b02e21f17ce 100644 --- a/.env.base +++ b/.env.base @@ -24,6 +24,7 @@ REACT_APP_FEATURE_CHATWOOT=false REACT_APP_FEATURE_COINBASE_WALLET=true REACT_APP_FEATURE_ADVANCED_SLIPPAGE=true REACT_APP_FEATURE_WALLET_CONNECT_V2=false +REACT_APP_FEATURE_FOX_DISCOUNTS=false # absolute URL prefix REACT_APP_ABSOLUTE_URL_PREFIX=https://app.shapeshift.com diff --git a/.env.dev b/.env.dev index e379379fb27..6390ddd9516 100644 --- a/.env.dev +++ b/.env.dev @@ -1,6 +1,7 @@ # feature flags REACT_APP_FEATURE_NFT_METADATA=true REACT_APP_FEATURE_WALLET_CONNECT_V2=true +REACT_APP_FEATURE_FOX_DISCOUNTS=true # logging REACT_APP_REDUX_WINDOW=false @@ -46,4 +47,4 @@ REACT_APP_COSMOS_NODE_URL=https://dev-daemon.cosmos.shapeshift.com REACT_APP_THORCHAIN_NODE_URL=https://dev-daemon.thorchain.shapeshift.com # thorchain -REACT_APP_MIDGARD_URL=https://dev-indexer.thorchain.shapeshift.com/v2 \ No newline at end of file +REACT_APP_MIDGARD_URL=https://dev-indexer.thorchain.shapeshift.com/v2 diff --git a/package.json b/package.json index 375566bb7ae..09759e78d4a 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "@snapshot-labs/snapshot.js": "^0.6.1", "@sniptt/monads": "^0.5.10", "@types/bip21": "^2.0.0", + "@types/react-plotly.js": "^2.6.0", "@uniswap/sdk": "^3.0.3", "@unstoppabledomains/resolution": "^8.3.3", "@visx/gradient": "^2.10.0", diff --git a/src/components/FeeExplainer/FeeExplainer.tsx b/src/components/FeeExplainer/FeeExplainer.tsx new file mode 100644 index 00000000000..50ac269631e --- /dev/null +++ b/src/components/FeeExplainer/FeeExplainer.tsx @@ -0,0 +1,369 @@ +import { + Box, + Card, + CardBody, + Flex, + Heading, + Slider, + SliderFilledTrack, + SliderMark, + SliderThumb, + SliderTrack, + Stack, + Text, + useToken, + VStack, +} from '@chakra-ui/react' +import { LinearGradient } from '@visx/gradient' +import { ScaleSVG } from '@visx/responsive' +import type { GlyphProps } from '@visx/xychart' +import { + AnimatedAreaSeries, + AnimatedAxis, + AnimatedGlyphSeries, + Tooltip, + XYChart, +} from '@visx/xychart' +import type { RenderTooltipParams } from '@visx/xychart/lib/components/Tooltip' +import debounce from 'lodash/debounce' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Amount } from 'components/Amount/Amount' +import { RawText } from 'components/Text' +import { bn } from 'lib/bignumber/bignumber' +import { calculateFees } from 'lib/fees/model' +import { FEE_CURVE_MAX_FEE_BPS, FEE_CURVE_NO_FEE_THRESHOLD_USD } from 'lib/fees/parameters' +import { isSome } from 'lib/utils' +import { useGetVotingPowerQuery } from 'state/apis/snapshot/snapshot' +import { selectWalletAccountIds } from 'state/slices/common-selectors' +import { useAppSelector } from 'state/store' + +type FeeChartProps = { + tradeSize: number + foxHolding: number + onHover(hoverTradeSize: number, hoverFoxHolding: number): void +} + +// how many points to generate for the chart, higher is more accurate but slower +const CHART_GRANULARITY = 100 +const CHART_TRADE_SIZE_MAX_USD = 400_000 +const CHART_TRADE_SIZE_MAX_FOX = 1_100_000 // let them go a bit past a million + +// Generate data for tradeSize and foxHolding +const tradeSizeData = [...Array(CHART_GRANULARITY).keys()].map( + i => i * (CHART_TRADE_SIZE_MAX_USD / (CHART_GRANULARITY - 1)), +) + +const accessors = { + xAccessor: ({ x }: { x: number }) => x, + yAccessor: ({ y }: { y: number }) => y, +} + +type ChartData = { + x: number + y: number +} + +const renderTooltip = ({ tooltipData }: RenderTooltipParams) => { + return ( +
+ Trade Size: {tooltipData?.nearestDatum?.datum?.x}
+ Fee (bps): {tooltipData?.nearestDatum?.datum?.y} +
+ ) +} + +const formatMetricSuffix = (num: number) => { + if (Math.abs(num) >= 1e6) return `${(Math.abs(num) / 1e6).toFixed(0)}M` + if (Math.abs(num) >= 1e3) return `${(Math.abs(num) / 1e3).toFixed(0)}K` + return `${Math.abs(num)}` +} + +const foxBlue = '#3761F9' + +const lineProps = { + stroke: foxBlue, +} + +const xScale = { type: 'linear' as const } +const yScale = { type: 'linear' as const, domain: [0, FEE_CURVE_MAX_FEE_BPS] } + +const FeeChart: React.FC = ({ foxHolding, tradeSize }) => { + const width = 450 + const height = 250 + const textColor = useToken('colors', 'text.subtle') + const borderColor = useToken('colors', 'border.base') + const circleBg = useToken('colors', 'blue.500') + const circleStroke = useToken('colors', 'text.base') + + const [debouncedFoxHolding, setDebouncedFoxHolding] = useState(foxHolding) + + const DEBOUNCE_MS = 150 // tune me to make the curve changing shape "feel" right + + // Debounce foxHolding updates + useEffect(() => { + const handleDebounce = debounce(() => setDebouncedFoxHolding(foxHolding), DEBOUNCE_MS) + handleDebounce() + + return handleDebounce.cancel + }, [foxHolding]) + + const data = useMemo(() => { + return tradeSizeData + .map(trade => { + if (trade < FEE_CURVE_NO_FEE_THRESHOLD_USD) return null + const feeBps = calculateFees({ + tradeAmountUsd: bn(trade), + foxHeld: bn(debouncedFoxHolding), + }).feeBps.toNumber() + return { x: trade, y: feeBps } + }) + .filter(isSome) + }, [debouncedFoxHolding]) + + const currentPoint = useMemo(() => { + if (tradeSize < FEE_CURVE_NO_FEE_THRESHOLD_USD) return [] + + const feeBps = calculateFees({ + tradeAmountUsd: bn(tradeSize), + foxHeld: bn(debouncedFoxHolding), + }).feeBps.toNumber() + + return [{ x: tradeSize, y: feeBps }] + }, [tradeSize, debouncedFoxHolding]) + + const tickLabelProps = useCallback( + () => ({ fill: textColor, fontSize: 12, fontWeight: 'medium' }), + [textColor], + ) + + const tickFormat = useCallback((x: number) => `$${formatMetricSuffix(x)}`, []) + + const labelProps = useCallback((fill: string) => ({ fill, fontSize: 12, fontWeight: 'bold' }), []) + + const renderGlyph = useCallback( + ({ x, y }: GlyphProps<{ x: number; y: number }>) => ( + + ), + [circleBg, circleStroke], + ) + + return ( + + + + + + + + + + + + + + ) +} + +type FeeSlidersProps = { + tradeSize: number + setTradeSize: (val: number) => void + foxHolding: number + setFoxHolding: (val: number) => void + currentFoxHoldings: string +} + +const labelStyles = { + fontSize: 'sm', + mt: '2', + ml: '-2.5', + color: 'text.subtle', +} + +const FeeSliders: React.FC = ({ + tradeSize, + setTradeSize, + foxHolding, + setFoxHolding, +}) => { + return ( + + + + Trade Size + + + + + + + + + + + + + + + + + + + + + + + FOX Holding + + + + + + + + + + 250k + + + 500k + + + 750k + + + 1MM + + + + + + ) +} + +type FeeOutputProps = { + tradeSize: number + foxHolding: number +} + +export const FeeOutput: React.FC = ({ tradeSize, foxHolding }) => { + const { feeBps, feeUsd, foxDiscountPercent } = calculateFees({ + tradeAmountUsd: bn(tradeSize), + foxHeld: bn(foxHolding), + }) + return ( + + + + + {feeUsd.lte(0) ? ( + + Free + + ) : ( + + )} + Total Fees + + + + + + FOX Holder Discount + + + + + Fee before discount: ${feeUsd.toFixed(2)} ({feeBps.toFixed(2)} bps) + + + ) +} + +const feeExplainerCardBody = { base: 4, md: 8 } + +export const FeeExplainer = () => { + const [tradeSize, setTradeSize] = useState(0) + const [foxHolding, setFoxHolding] = useState(0) + + const walletAccountIds = useAppSelector(selectWalletAccountIds) + const { data: currentFoxHoldings } = useGetVotingPowerQuery(walletAccountIds) + const onHover = (hoverTradeSize: number, hoverFoxHolding: number) => { + setTradeSize(hoverTradeSize) + setFoxHolding(hoverFoxHolding) + } + + return ( + + + Calculate your FOX Savings + + Something about savings, put good copy in here that doesn't suck. + + FOX voting power {currentFoxHoldings} FOX + + + + + + + + + + ) +} diff --git a/src/components/FeeExplainer/components/FeeInput.tsx b/src/components/FeeExplainer/components/FeeInput.tsx new file mode 100644 index 00000000000..eb3a20853a2 --- /dev/null +++ b/src/components/FeeExplainer/components/FeeInput.tsx @@ -0,0 +1,69 @@ +import type { InputProps } from '@chakra-ui/react' +import { Input } from '@chakra-ui/react' +import { useCallback, useRef } from 'react' +import type { NumberFormatValues } from 'react-number-format' +import NumberFormat from 'react-number-format' +import { useTranslate } from 'react-polyglot' +import { useLocaleFormatter } from 'hooks/useLocaleFormatter/useLocaleFormatter' + +const CryptoInput = (props: InputProps) => { + const translate = useTranslate() + return ( + + ) +} + +type FeeInputProps = { + isFiat?: boolean + onChange?: (value: string, isFiat?: boolean) => void + value?: string | number | null +} + +const numberFormatDisabled = { opacity: 1, cursor: 'not-allowed' } + +export const FeeInput: React.FC = ({ isFiat, onChange, value }) => { + const amountRef = useRef(null) + const { + number: { localeParts }, + } = useLocaleFormatter() + + const handleOnChange = useCallback(() => { + // onChange will send us the formatted value + // To get around this we need to get the value from the onChange using a ref + // Now when the max buttons are clicked the onChange will not fire + onChange?.(amountRef.current ?? '', isFiat) + }, [isFiat, onChange]) + + const handleValueChange = useCallback((values: NumberFormatValues) => { + // This fires anytime value changes including setting it on max click + // Store the value in a ref to send when we actually want the onChange to fire + amountRef.current = values.value + }, []) + return ( + + ) +} diff --git a/src/config.ts b/src/config.ts index bfa806d4c65..6b3527f32e3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -146,6 +146,7 @@ const validators = { REACT_APP_EXPERIMENTAL_CUSTOM_SEND_NONCE: bool({ default: false }), REACT_APP_EXPERIMENTAL_MM_SNAPPY_FINGERS: bool({ default: false }), REACT_APP_SNAP_ID: str(), + REACT_APP_FEATURE_FOX_DISCOUNTS: bool({ default: false }), } function reporter({ errors }: envalid.ReporterOptions) { diff --git a/src/lib/fees/model.test.ts b/src/lib/fees/model.test.ts index 7d405d0954e..bfae6b0d554 100644 --- a/src/lib/fees/model.test.ts +++ b/src/lib/fees/model.test.ts @@ -1,6 +1,6 @@ import { bn } from 'lib/bignumber/bignumber' -import { calculateFeeBps } from './model' +import { calculateFees } from './model' import { FEE_CURVE_FOX_MAX_DISCOUNT_THRESHOLD, FEE_CURVE_MIDPOINT_USD, @@ -11,60 +11,62 @@ describe('calculateFees', () => { it('should return 0 bps for <= no fee threshold', () => { const tradeAmountUsd = bn(FEE_CURVE_NO_FEE_THRESHOLD_USD) const foxHeld = bn(0) - const bps = calculateFeeBps({ + const { feeBps } = calculateFees({ tradeAmountUsd, foxHeld, }) - expect(bps.toNumber()).toEqual(0) + expect(feeBps.toNumber()).toEqual(0) }) it('should return close to max bps for slightly above no fee threshold', () => { const tradeAmountUsd = bn(FEE_CURVE_NO_FEE_THRESHOLD_USD + 0.01) const foxHeld = bn(0) - const bps = calculateFeeBps({ + const { feeBps } = calculateFees({ tradeAmountUsd, foxHeld, }) - expect(bps.toNumber()).toEqual(28.552638258646432) + expect(feeBps.toNumber()).toEqual(28.552638258646432) }) it('should return close to min bps for huge amounts', () => { const tradeAmountUsd = bn(1_000_000) const foxHeld = bn(0) - const bps = calculateFeeBps({ + const { feeBps } = calculateFees({ tradeAmountUsd, foxHeld, }) - expect(bps.toNumber()).toEqual(10.000000011220077) + expect(feeBps.toNumber()).toEqual(10.000000011220077) }) it('should return close to midpoint for midpoint', () => { const tradeAmountUsd = bn(FEE_CURVE_MIDPOINT_USD) const foxHeld = bn(0) - const bps = calculateFeeBps({ + const { feeBps } = calculateFees({ tradeAmountUsd, foxHeld, }) - expect(bps.toNumber()).toEqual(19.5) + expect(feeBps.toNumber()).toEqual(19.5) }) it('should discount fees by 50% holding at midpoint holding half max fox discount limit', () => { const tradeAmountUsd = bn(FEE_CURVE_MIDPOINT_USD) const foxHeld = bn(FEE_CURVE_FOX_MAX_DISCOUNT_THRESHOLD / 2) - const bps = calculateFeeBps({ + const { feeBps, foxDiscountPercent } = calculateFees({ tradeAmountUsd, foxHeld, }) - expect(bps.toNumber()).toEqual(9.75) + expect(feeBps.toNumber()).toEqual(9.75) + expect(foxDiscountPercent).toEqual(bn(50)) }) it('should discount fees 100% holding max fox discount limit', () => { const tradeAmountUsd = bn(Infinity) const foxHeld = bn(FEE_CURVE_FOX_MAX_DISCOUNT_THRESHOLD) - const bps = calculateFeeBps({ + const { feeBps, foxDiscountPercent } = calculateFees({ tradeAmountUsd, foxHeld, }) - expect(bps.toNumber()).toEqual(0) + expect(feeBps.toNumber()).toEqual(0) + expect(foxDiscountPercent).toEqual(bn(100)) }) }) diff --git a/src/lib/fees/model.ts b/src/lib/fees/model.ts index 55192fc8000..461ef7d2e15 100644 --- a/src/lib/fees/model.ts +++ b/src/lib/fees/model.ts @@ -15,17 +15,36 @@ type CalculateFeeBpsArgs = { foxHeld: BigNumber } -type CalculateFeeBps = (args: CalculateFeeBpsArgs) => BigNumber +type CalculateFeeBpsReturn = { + feeBps: BigNumber + feeUsd: BigNumber + feeUsdDiscount: BigNumber + foxDiscountPercent: BigNumber +} +type CalculateFeeBps = (args: CalculateFeeBpsArgs) => CalculateFeeBpsReturn -export const calculateFeeBps: CalculateFeeBps = ({ tradeAmountUsd, foxHeld }): BigNumber => { +export const calculateFees: CalculateFeeBps = ({ tradeAmountUsd, foxHeld }) => { const noFeeThresholdUsd = bn(FEE_CURVE_NO_FEE_THRESHOLD_USD) const maxFeeBps = bn(FEE_CURVE_MAX_FEE_BPS) const minFeeBps = bn(FEE_CURVE_MIN_FEE_BPS) const midpointUsd = bn(FEE_CURVE_MIDPOINT_USD) const feeCurveSteepness = bn(FEE_CURVE_STEEPNESS_K) - if (tradeAmountUsd.lte(noFeeThresholdUsd)) return bn(0) - const sigmoidFee = minFeeBps.plus( + const foxDiscountPercent = BigNumber.minimum( + bn(100), + foxHeld.times(100).div(bn(FEE_CURVE_FOX_MAX_DISCOUNT_THRESHOLD)), + ) + + if (tradeAmountUsd.lte(noFeeThresholdUsd)) { + return { + feeBps: bn(0), + feeUsd: bn(0), + feeUsdDiscount: bn(0), + foxDiscountPercent, + } + } + + const feeBpsBeforeDiscount = minFeeBps.plus( maxFeeBps .minus(minFeeBps) .div( @@ -39,9 +58,13 @@ export const calculateFeeBps: CalculateFeeBps = ({ tradeAmountUsd, foxHeld }): B ), ) - const foxDiscount = foxHeld.div(bn(FEE_CURVE_FOX_MAX_DISCOUNT_THRESHOLD)) - - const feeBps = sigmoidFee.multipliedBy(bn(1).minus(foxDiscount)) + const feeBps = BigNumber.maximum( + feeBpsBeforeDiscount.multipliedBy(bn(1).minus(foxDiscountPercent.div(100))), + bn(0), + ) + const feeUsdBeforeDiscount = tradeAmountUsd.multipliedBy(feeBpsBeforeDiscount.div(bn(10000))) + const feeUsdDiscount = feeUsdBeforeDiscount.multipliedBy(foxDiscountPercent.div(100)) + const feeUsd = feeUsdBeforeDiscount.minus(feeUsdDiscount) - return BigNumber.maximum(feeBps, bn(0)) + return { feeBps, feeUsd, feeUsdDiscount, foxDiscountPercent } } diff --git a/src/pages/Trade/Trade.tsx b/src/pages/Trade/Trade.tsx index 2b68b26f77e..a872b1fff10 100644 --- a/src/pages/Trade/Trade.tsx +++ b/src/pages/Trade/Trade.tsx @@ -1,20 +1,23 @@ import { Container, Stack } from '@chakra-ui/react' import { memo } from 'react' +import { FeeExplainer } from 'components/FeeExplainer/FeeExplainer' import { Main } from 'components/Layout/Main' import { MultiHopTrade } from 'components/MultiHopTrade/MultiHopTrade' +import { useFeatureFlag } from 'hooks/useFeatureFlag/useFeatureFlag' import { RecentTransactions } from 'pages/Dashboard/RecentTransactions' const maxWidth = { base: '100%', lg: 'container.sm' } const padding = { base: 0, md: 8 } export const Trade = memo(() => { + const foxDiscountsEnabled = useFeatureFlag('FoxDiscounts') return (
- + {foxDiscountsEnabled && } diff --git a/src/state/slices/preferencesSlice/preferencesSlice.ts b/src/state/slices/preferencesSlice/preferencesSlice.ts index 8207af5f713..8873daf31a8 100644 --- a/src/state/slices/preferencesSlice/preferencesSlice.ts +++ b/src/state/slices/preferencesSlice/preferencesSlice.ts @@ -44,6 +44,7 @@ export type FeatureFlags = { WalletConnectV2: boolean CustomSendNonce: boolean Snaps: boolean + FoxDiscounts: boolean } export type Flag = keyof FeatureFlags @@ -105,6 +106,7 @@ const initialState: Preferences = { WalletConnectV2: getConfig().REACT_APP_FEATURE_WALLET_CONNECT_V2, CustomSendNonce: getConfig().REACT_APP_EXPERIMENTAL_CUSTOM_SEND_NONCE, Snaps: getConfig().REACT_APP_EXPERIMENTAL_MM_SNAPPY_FINGERS, + FoxDiscounts: getConfig().REACT_APP_FEATURE_FOX_DISCOUNTS, }, selectedLocale: simpleLocale(), balanceThreshold: '0', diff --git a/src/test/mocks/store.ts b/src/test/mocks/store.ts index 70e6a8001ba..490e27e6e2d 100644 --- a/src/test/mocks/store.ts +++ b/src/test/mocks/store.ts @@ -92,6 +92,7 @@ export const mockStore: ReduxState = { WalletConnectV2: false, CustomSendNonce: false, Snaps: false, + FoxDiscounts: false, }, selectedLocale: 'en', balanceThreshold: '0', diff --git a/yarn.lock b/yarn.lock index ba1aed1b319..eea8810b563 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8230,6 +8230,7 @@ __metadata: "@types/react-datepicker": ^4.4.2 "@types/react-dom": ^18.0.5 "@types/react-infinite-scroller": ^1.2.3 + "@types/react-plotly.js": ^2.6.0 "@types/react-redux": ^7.1.24 "@types/react-router-dom": ^5.3.2 "@types/react-table": ^7.7.12 @@ -9738,6 +9739,13 @@ __metadata: languageName: node linkType: hard +"@types/plotly.js@npm:*": + version: 2.12.27 + resolution: "@types/plotly.js@npm:2.12.27" + checksum: f59c27a2b86ccfe680bd0441e84b59d5281dcc74ae9a90f88d51429b8885c2fb9cbf0e7144fc963ad1230a9ac1b7c156e7a6375cde2cf8332159a05e234b66e5 + languageName: node + linkType: hard + "@types/pngjs@npm:^6.0.1": version: 6.0.1 resolution: "@types/pngjs@npm:6.0.1" @@ -9821,6 +9829,16 @@ __metadata: languageName: node linkType: hard +"@types/react-plotly.js@npm:^2.6.0": + version: 2.6.0 + resolution: "@types/react-plotly.js@npm:2.6.0" + dependencies: + "@types/plotly.js": "*" + "@types/react": "*" + checksum: 91ab4eb8f1ab68add2884f01419884ce9746121681e6c9e883c8ce26e9a549108bf61cdeb2008b64bd1886934d4ac106af4c3c61d1d159e13236b4a1858e6ea8 + languageName: node + linkType: hard + "@types/react-redux@npm:^7.1.20, @types/react-redux@npm:^7.1.24": version: 7.1.24 resolution: "@types/react-redux@npm:7.1.24"