From 63706f70cd3d0048d6583d15652e2df321a4865a Mon Sep 17 00:00:00 2001 From: WRadoslaw <92513933+WRadoslaw@users.noreply.github.com> Date: Mon, 13 Nov 2023 13:28:45 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=91=20Buy=20sell=20amm=20(#5135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update schema and fragment * Add market type for CrtStatusWidget * Update fragment * Update transaction * Change on modal * Create button for modal and place it in widget * Linter * Interate modal UI with orion data * Create sell token button and place it in widget * Small logic improvements * Clean up * Initial correction of buy amm formulas * Further fixes on token calculations * Add templates for amm modals * Rework sell amm modal * Adjust buy amm modal * Build fix --- .../__generated__/creatorTokens.generated.tsx | 1 + .../__generated__/fragments.generated.tsx | 2 + .../atlas/src/api/queries/fragments.graphql | 1 + .../CrtPreviewLayout/CrtPreviewLayout.tsx | 2 + .../AmmModalFormTemplate.tsx | 80 +++++ .../AmmModalSummaryTemplate.tsx | 31 ++ .../_crt/AmmModalTemplates/index.ts | 1 + .../BuyFromMarketButton.tsx | 20 ++ .../BuyMarketTokenModal.tsx | 230 ++++++++++++-- .../steps/BuyMarketTokenConditions.tsx | 4 +- .../steps/BuyMarketTokenForm.tsx | 155 ---------- .../CrtStatusWidget/CrtStatusWidget.styles.ts | 5 - .../_crt/CrtStatusWidget/CrtStatusWidget.tsx | 90 +++++- .../SellOnMarketButton/SellOnMarketButton.tsx | 20 ++ .../_crt/SellTokenModal/SellTokenModal.tsx | 281 ++++++++++-------- .../_nft/NftTile/NftTileDetails.tsx | 14 +- .../atlas/src/joystream-lib/extrinsics.ts | 21 +- packages/atlas/src/utils/crts.ts | 32 ++ .../CrtDashboard/tabs/CrtDashboardMainTab.tsx | 3 + .../ChannelViewTabs/ChannelToken.tsx | 2 + 20 files changed, 657 insertions(+), 338 deletions(-) create mode 100644 packages/atlas/src/components/_crt/AmmModalTemplates/AmmModalFormTemplate.tsx create mode 100644 packages/atlas/src/components/_crt/AmmModalTemplates/AmmModalSummaryTemplate.tsx create mode 100644 packages/atlas/src/components/_crt/AmmModalTemplates/index.ts create mode 100644 packages/atlas/src/components/_crt/BuyFromMarketButton/BuyFromMarketButton.tsx delete mode 100644 packages/atlas/src/components/_crt/BuyMarketTokenModal/steps/BuyMarketTokenForm.tsx create mode 100644 packages/atlas/src/components/_crt/SellOnMarketButton/SellOnMarketButton.tsx create mode 100644 packages/atlas/src/utils/crts.ts diff --git a/packages/atlas/src/api/queries/__generated__/creatorTokens.generated.tsx b/packages/atlas/src/api/queries/__generated__/creatorTokens.generated.tsx index 4b65f1da11..c92a29eee3 100644 --- a/packages/atlas/src/api/queries/__generated__/creatorTokens.generated.tsx +++ b/packages/atlas/src/api/queries/__generated__/creatorTokens.generated.tsx @@ -112,6 +112,7 @@ export type GetFullCreatorTokenQuery = { ammInitPrice: string burnedByAmm: string mintedByAmm: string + ammSlopeParameter: string }> sales: Array<{ __typename?: 'Sale' diff --git a/packages/atlas/src/api/queries/__generated__/fragments.generated.tsx b/packages/atlas/src/api/queries/__generated__/fragments.generated.tsx index 9e5c7dd19e..f85e37bd4f 100644 --- a/packages/atlas/src/api/queries/__generated__/fragments.generated.tsx +++ b/packages/atlas/src/api/queries/__generated__/fragments.generated.tsx @@ -2821,6 +2821,7 @@ export type FullCreatorTokenFragment = { ammInitPrice: string burnedByAmm: string mintedByAmm: string + ammSlopeParameter: string }> sales: Array<{ __typename?: 'Sale' @@ -3485,6 +3486,7 @@ export const FullCreatorTokenFragmentDoc = gql` ammInitPrice burnedByAmm mintedByAmm + ammSlopeParameter } sales { id diff --git a/packages/atlas/src/api/queries/fragments.graphql b/packages/atlas/src/api/queries/fragments.graphql index 050b27ced5..f37443b7a0 100644 --- a/packages/atlas/src/api/queries/fragments.graphql +++ b/packages/atlas/src/api/queries/fragments.graphql @@ -521,6 +521,7 @@ fragment FullCreatorToken on CreatorToken { ammInitPrice burnedByAmm mintedByAmm + ammSlopeParameter } sales { id diff --git a/packages/atlas/src/components/CrtPreviewLayout/CrtPreviewLayout.tsx b/packages/atlas/src/components/CrtPreviewLayout/CrtPreviewLayout.tsx index 267979a309..bb727ef910 100644 --- a/packages/atlas/src/components/CrtPreviewLayout/CrtPreviewLayout.tsx +++ b/packages/atlas/src/components/CrtPreviewLayout/CrtPreviewLayout.tsx @@ -140,6 +140,8 @@ export const CrtPreviewLayout = ({ revenue={token.annualCreatorRewardPermill} revenueShare={token.annualCreatorRewardPermill} transactionVolume={token.annualCreatorRewardPermill} + tokenId={token.id} + status="inactive" /> {data ? ( diff --git a/packages/atlas/src/components/_crt/AmmModalTemplates/AmmModalFormTemplate.tsx b/packages/atlas/src/components/_crt/AmmModalTemplates/AmmModalFormTemplate.tsx new file mode 100644 index 0000000000..c6ce23695c --- /dev/null +++ b/packages/atlas/src/components/_crt/AmmModalTemplates/AmmModalFormTemplate.tsx @@ -0,0 +1,80 @@ +import { ReactNode } from 'react' +import { Control, Controller, Validate } from 'react-hook-form' + +import { FlexBox } from '@/components/FlexBox' +import { Information } from '@/components/Information' +import { Text } from '@/components/Text' +import { TextButton } from '@/components/_buttons/Button' +import { FormField } from '@/components/_inputs/FormField' +import { TokenInput } from '@/components/_inputs/TokenInput' +import { useTokenPrice } from '@/providers/joystream' + +type AmmModalFormTemplateProps = { + validation?: Validate + maxValue?: number + pricePerUnit?: number + control: Control<{ tokens: number }> + details?: { + title: string + content: ReactNode + tooltipText?: string + }[] + error?: string +} + +export const AmmModalFormTemplate = ({ + validation, + maxValue, + pricePerUnit, + details, + control, + error, +}: AmmModalFormTemplateProps) => { + const { convertTokensToUSD } = useTokenPrice() + + return ( + + ( + + + {pricePerUnit ? ( + + ${convertTokensToUSD((field.value || 0) * pricePerUnit)?.toFixed(2)} + + ) : null} + {maxValue ? field.onChange(maxValue)}>Max : null} + + } + /> + + )} + /> + + {details?.map((row, i) => ( + + + + {row.title} + + {row.tooltipText ? : null} + + {row.content} + + ))} + + + ) +} diff --git a/packages/atlas/src/components/_crt/AmmModalTemplates/AmmModalSummaryTemplate.tsx b/packages/atlas/src/components/_crt/AmmModalTemplates/AmmModalSummaryTemplate.tsx new file mode 100644 index 0000000000..020e6c96b3 --- /dev/null +++ b/packages/atlas/src/components/_crt/AmmModalTemplates/AmmModalSummaryTemplate.tsx @@ -0,0 +1,31 @@ +import { ReactNode } from 'react' + +import { FlexBox } from '@/components/FlexBox' +import { Information } from '@/components/Information' +import { Text } from '@/components/Text' + +type AmmModalSummaryTemplateProps = { + details?: { + title: string + content: ReactNode + tooltipText?: string + }[] +} + +export const AmmModalSummaryTemplate = ({ details }: AmmModalSummaryTemplateProps) => { + return ( + + {details?.map((row, i) => ( + + + + {row.title} + + {row.tooltipText ? : null} + + {row.content} + + ))} + + ) +} diff --git a/packages/atlas/src/components/_crt/AmmModalTemplates/index.ts b/packages/atlas/src/components/_crt/AmmModalTemplates/index.ts new file mode 100644 index 0000000000..cd5fff0f11 --- /dev/null +++ b/packages/atlas/src/components/_crt/AmmModalTemplates/index.ts @@ -0,0 +1 @@ +export * from './AmmModalFormTemplate' diff --git a/packages/atlas/src/components/_crt/BuyFromMarketButton/BuyFromMarketButton.tsx b/packages/atlas/src/components/_crt/BuyFromMarketButton/BuyFromMarketButton.tsx new file mode 100644 index 0000000000..2edf119832 --- /dev/null +++ b/packages/atlas/src/components/_crt/BuyFromMarketButton/BuyFromMarketButton.tsx @@ -0,0 +1,20 @@ +import { useState } from 'react' + +import { Button } from '@/components/_buttons/Button' +import { BuyMarketTokenModal } from '@/components/_crt/BuyMarketTokenModal' + +type BuyFromMarketButtonProps = { + tokenId: string +} + +export const BuyFromMarketButton = ({ tokenId }: BuyFromMarketButtonProps) => { + const [showModal, setShowModal] = useState(false) + return ( + <> + setShowModal(false)} /> + + + ) +} diff --git a/packages/atlas/src/components/_crt/BuyMarketTokenModal/BuyMarketTokenModal.tsx b/packages/atlas/src/components/_crt/BuyMarketTokenModal/BuyMarketTokenModal.tsx index 1cba963587..917ceb754b 100644 --- a/packages/atlas/src/components/_crt/BuyMarketTokenModal/BuyMarketTokenModal.tsx +++ b/packages/atlas/src/components/_crt/BuyMarketTokenModal/BuyMarketTokenModal.tsx @@ -1,34 +1,54 @@ -import { useCallback, useMemo, useRef, useState } from 'react' +import BN from 'bn.js' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useForm } from 'react-hook-form' +import { useGetFullCreatorTokenQuery } from '@/api/queries/__generated__/creatorTokens.generated' +import { NumberFormat } from '@/components/NumberFormat' +import { AmmModalFormTemplate } from '@/components/_crt/AmmModalTemplates' +import { AmmModalSummaryTemplate } from '@/components/_crt/AmmModalTemplates/AmmModalSummaryTemplate' import { BuyMarketTokenSuccess } from '@/components/_crt/BuyMarketTokenModal/steps/BuyMarketTokenSuccess' import { DialogProps } from '@/components/_overlays/Dialog' import { DialogModal } from '@/components/_overlays/DialogModal' import { useMediaMatch } from '@/hooks/useMediaMatch' -import { tokenNumberToHapiBn } from '@/joystream-lib/utils' -import { useJoystream } from '@/providers/joystream' +import { hapiBnToTokenNumber, tokenNumberToHapiBn } from '@/joystream-lib/utils' +import { useFee, useJoystream, useSubscribeAccountBalance } from '@/providers/joystream' import { useSnackbar } from '@/providers/snackbars' import { useTransaction } from '@/providers/transactions/transactions.hooks' import { useUser } from '@/providers/user/user.hooks' +import { calcBuyMarketPricePerToken } from '@/utils/crts' import { BuyMarketTokenConditions } from './steps/BuyMarketTokenConditions' -import { BuySaleTokenForm, getTokenDetails } from './steps/BuyMarketTokenForm' export type BuySaleTokenModalProps = { tokenId: string + show: boolean onClose: () => void } enum BUY_MARKET_TOKEN_STEPS { form, conditions, + summary, success, } -export const BuyMarketTokenModal = ({ tokenId, onClose }: BuySaleTokenModalProps) => { - const { title } = getTokenDetails(tokenId) +export const BuyMarketTokenModal = ({ tokenId, onClose, show }: BuySaleTokenModalProps) => { const [activeStep, setActiveStep] = useState(BUY_MARKET_TOKEN_STEPS.form) const [primaryButtonProps, setPrimaryButtonProps] = useState() const amountRef = useRef(null) + const { control, watch, handleSubmit, reset, formState } = useForm<{ tokens: number }>() + const tokens = watch('tokens') || 0 + const { fullFee } = useFee('purchaseTokenOnMarketTx', ['1', '1', String(tokens ?? 0), '1000000']) + const { data } = useGetFullCreatorTokenQuery({ + variables: { + id: tokenId, + }, + }) + const { accountBalance } = useSubscribeAccountBalance() + const title = data?.creatorTokenById?.symbol ?? 'N/A' + + const currentAmm = data?.creatorTokenById?.ammCurves.find((amm) => !amm.finalized) + const { memberId } = useUser() const smMatch = useMediaMatch('sm') const { displaySnackbar } = useSnackbar() @@ -46,18 +66,52 @@ export const BuyMarketTokenModal = ({ tokenId, onClose }: BuySaleTokenModalProps case BUY_MARKET_TOKEN_STEPS.form: return { text: 'Cancel', + onClick: onClose, + } + case BUY_MARKET_TOKEN_STEPS.summary: + return { + text: 'Back', + onClick: () => { + setActiveStep(BUY_MARKET_TOKEN_STEPS.conditions) + }, } default: return undefined } - }, [activeStep]) + }, [activeStep, onClose]) + + const calculateRequiredHapi = useCallback( + (amount: number) => { + const currentAmm = data?.creatorTokenById?.ammCurves.find((amm) => !amm.finalized) + if (!currentAmm) return + if (amount === 0) { + return new BN(0) + } + + return calcBuyMarketPricePerToken( + currentAmm.mintedByAmm, + currentAmm.ammSlopeParameter, + currentAmm.ammInitPrice, + amount + ) + }, + [data?.creatorTokenById] + ) + + const priceForAllToken = useMemo(() => { + return hapiBnToTokenNumber(calculateRequiredHapi(Math.max(tokens, 1)) ?? new BN(0)) + }, [tokens, calculateRequiredHapi]) + const pricePerUnit = priceForAllToken / (tokens || 1) const commonProps = { setPrimaryButtonProps, } - const onSubmitConditions = useCallback(async () => { - if (!joystream || !memberId || !amountRef.current) { + const onTermsSubmit = useCallback(() => setActiveStep(BUY_MARKET_TOKEN_STEPS.summary), []) + + const onTransactionSubmit = useCallback(() => { + const slippageAmount = calculateRequiredHapi(amountRef.current ?? 0) + if (!joystream || !memberId || !amountRef.current || !slippageAmount) { return } @@ -66,7 +120,8 @@ export const BuyMarketTokenModal = ({ tokenId, onClose }: BuySaleTokenModalProps (await joystream.extrinsics).purchaseTokenOnMarket( tokenId, memberId, - tokenNumberToHapiBn(amountRef.current as number).toString(), + String(amountRef.current), + slippageAmount.toString(), proxyCallback(updateStatus) ), onTxSync: async () => { @@ -79,34 +134,167 @@ export const BuyMarketTokenModal = ({ tokenId, onClose }: BuySaleTokenModalProps }) }, }) - }, [displaySnackbar, handleTransaction, joystream, memberId, proxyCallback, tokenId]) + }, [calculateRequiredHapi, displaySnackbar, handleTransaction, joystream, memberId, proxyCallback, tokenId]) + + const formDetails = useMemo( + () => [ + { + title: 'Available balance', + content: , + tooltipText: 'Lorem ipsum', + }, + { + title: 'You will pay', + content: ( + + ), + tooltipText: 'Lorem ipsum', + }, + ], + [accountBalance, calculateRequiredHapi, tokens] + ) + + const summaryDetails = useMemo( + () => [ + { + title: 'Purchase', + content: ( + + ), + tooltipText: 'Lorem ipsum', + }, + { + title: 'Price per unit', + content: ( + + ), + tooltipText: 'Lorem ipsum', + }, + { + title: 'Fee', + content: , + tooltipText: 'Lorem ipsum', + }, + { + title: 'Total', + content: ( + + ), + tooltipText: 'Lorem ipsum', + }, + { + title: 'You will get', + content: ( + + ), + tooltipText: 'Lorem ipsum', + }, + ], + [calculateRequiredHapi, fullFee, pricePerUnit, title, tokens] + ) + + useEffect(() => { + if (!show) { + reset({ tokens: 0 }) + } + }, [reset, show]) + + useEffect(() => { + if (activeStep === BUY_MARKET_TOKEN_STEPS.form) { + setPrimaryButtonProps({ + text: 'Continue', + onClick: () => + handleSubmit((data) => { + amountRef.current = data.tokens + setActiveStep(BUY_MARKET_TOKEN_STEPS.conditions) + })(), + }) + } + + if (activeStep === BUY_MARKET_TOKEN_STEPS.summary) { + setPrimaryButtonProps({ + text: 'Buy token', + onClick: onTransactionSubmit, + }) + } + }, [activeStep, handleSubmit, onTransactionSubmit]) + + if (!currentAmm && show) { + throw new Error('BuyAmmModal invoked on token without active amm') + } return ( {activeStep === BUY_MARKET_TOKEN_STEPS.form && ( - { - setActiveStep(BUY_MARKET_TOKEN_STEPS.conditions) - amountRef.current = tokens + { + if (!value || value < 1) return 'You need to buy at least one token' + const requiredHapi = calculateRequiredHapi(value ?? 0) + if (!accountBalance || !requiredHapi) return true + return accountBalance.gte(requiredHapi) ? true : "You don't have enough balance to buy this many tokens" }} - tokenId={tokenId} /> )} {activeStep === BUY_MARKET_TOKEN_STEPS.conditions && ( - + )} + {activeStep === BUY_MARKET_TOKEN_STEPS.summary && } {activeStep === BUY_MARKET_TOKEN_STEPS.success && ( - + { + onClose() + setActiveStep(BUY_MARKET_TOKEN_STEPS.form) + }} + tokenName={data?.creatorTokenById?.symbol ?? 'N/A'} + /> )} ) diff --git a/packages/atlas/src/components/_crt/BuyMarketTokenModal/steps/BuyMarketTokenConditions.tsx b/packages/atlas/src/components/_crt/BuyMarketTokenModal/steps/BuyMarketTokenConditions.tsx index 2cb14b05b7..34ec872ff8 100644 --- a/packages/atlas/src/components/_crt/BuyMarketTokenModal/steps/BuyMarketTokenConditions.tsx +++ b/packages/atlas/src/components/_crt/BuyMarketTokenModal/steps/BuyMarketTokenConditions.tsx @@ -37,7 +37,7 @@ export const BuyMarketTokenConditions = ({ setPrimaryButtonProps, onSubmit }: Bu useEffect(() => { setPrimaryButtonProps({ - text: 'Buy token', + text: 'Continue', onClick: () => { if (isChecked) { onSubmit() @@ -78,7 +78,7 @@ export const BuyMarketTokenConditions = ({ setPrimaryButtonProps, onSubmit }: Bu caption={checkboxError} error={!!checkboxError} value={isChecked} - label="I have saved my wallet seed phrase safely" + label="I accept the Terms & Conditions" /> diff --git a/packages/atlas/src/components/_crt/BuyMarketTokenModal/steps/BuyMarketTokenForm.tsx b/packages/atlas/src/components/_crt/BuyMarketTokenModal/steps/BuyMarketTokenForm.tsx deleted file mode 100644 index 831e832f58..0000000000 --- a/packages/atlas/src/components/_crt/BuyMarketTokenModal/steps/BuyMarketTokenForm.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { useMemo } from 'react' -import { Controller, useForm } from 'react-hook-form' - -import { FlexBox } from '@/components/FlexBox/FlexBox' -import { Information } from '@/components/Information' -import { JoyTokenIcon } from '@/components/JoyTokenIcon' -import { NumberFormat } from '@/components/NumberFormat' -import { Text } from '@/components/Text' -import { TextButton } from '@/components/_buttons/Button' -import { FormField } from '@/components/_inputs/FormField' -import { TokenInput } from '@/components/_inputs/TokenInput' -import { DetailsContent } from '@/components/_nft/NftTile' -import { atlasConfig } from '@/config' -import { useMediaMatch } from '@/hooks/useMediaMatch' -import { useMountEffect } from '@/hooks/useMountEffect' -import { hapiBnToTokenNumber } from '@/joystream-lib/utils' -import { useJoystream, useSubscribeAccountBalance } from '@/providers/joystream' - -import { CommonProps } from './types' - -export const getTokenDetails = (_: string) => ({ - title: 'JBC', - pricePerUnit: 1000, - tokensOnSale: 67773, - userBalance: 100000, -}) - -type BuySaleTokenFormProps = { - tokenId: string - onSubmit: (tokens: number | null) => void -} & CommonProps - -export const BuySaleTokenForm = ({ tokenId, setPrimaryButtonProps, onSubmit }: BuySaleTokenFormProps) => { - const { control, watch, handleSubmit } = useForm<{ tokens: number | null }>() - const { accountBalance } = useSubscribeAccountBalance() - const tokens = watch('tokens') - const { pricePerUnit, tokensOnSale, userBalance, title } = getTokenDetails(tokenId) - const { tokenPrice } = useJoystream() - const tokenInUsd = (tokens || 0) * pricePerUnit * (tokenPrice ?? 0) - - const smMatch = useMediaMatch('sm') - - const details = useMemo( - () => [ - { - title: 'You will get', - content: ( - 1_000_000 ? 'short' : 'full'} - variant="t200" - withDenomination="before" - withToken - customTicker={`$${title}`} - /> - ), - tooltipText: 'Lorem ipsum', - }, - { - title: 'Fee', - content: , - tooltipText: 'Lorem ipsum', - }, - { - title: 'You will spend', - content: , - tooltipText: 'Lorem ipsum', - }, - ], - [title, tokens, tokensOnSale] - ) - - useMountEffect(() => { - setPrimaryButtonProps({ - text: 'Continue', - onClick: () => handleSubmit((data) => onSubmit(data.tokens))(), - }) - }) - - return ( - <> - - - } - tooltipText="Lorem ipsum" - withDenomination - /> - } - tooltipText="Lorem ipsum" - withDenomination - /> - - ( - - - - ${tokenInUsd.toFixed(2)} - - - accountBalance && field.onChange(Math.floor(hapiBnToTokenNumber(accountBalance) / pricePerUnit)) - } - > - Max - - - } - /> - - )} - /> - - - {details.map((row, i) => ( - - - - {row.title} - - - - {row.content} - - ))} - - - - ) -} diff --git a/packages/atlas/src/components/_crt/CrtStatusWidget/CrtStatusWidget.styles.ts b/packages/atlas/src/components/_crt/CrtStatusWidget/CrtStatusWidget.styles.ts index d157d5c8e6..74fd7c1a7a 100644 --- a/packages/atlas/src/components/_crt/CrtStatusWidget/CrtStatusWidget.styles.ts +++ b/packages/atlas/src/components/_crt/CrtStatusWidget/CrtStatusWidget.styles.ts @@ -1,6 +1,5 @@ import styled from '@emotion/styled' -import { Text } from '@/components/Text' import { WidgetTile } from '@/components/WidgetTile' import { cVar, sizes, transitions } from '@/styles' @@ -50,10 +49,6 @@ export const SupplyLine = styled.div` gap: ${sizes(1)}; ` -export const LabelText = styled(Text)` - color: ${cVar('colorText')}; -` - export const Widget = styled(WidgetTile)` position: relative; diff --git a/packages/atlas/src/components/_crt/CrtStatusWidget/CrtStatusWidget.tsx b/packages/atlas/src/components/_crt/CrtStatusWidget/CrtStatusWidget.tsx index f65c5fa816..2399808f82 100644 --- a/packages/atlas/src/components/_crt/CrtStatusWidget/CrtStatusWidget.tsx +++ b/packages/atlas/src/components/_crt/CrtStatusWidget/CrtStatusWidget.tsx @@ -1,15 +1,19 @@ +import BN from 'bn.js' import { FC, useRef, useState } from 'react' +import { FlexBox } from '@/components/FlexBox' import { Information } from '@/components/Information' import { JoyTokenIcon } from '@/components/JoyTokenIcon' import { NumberFormat, NumberFormatProps } from '@/components/NumberFormat' import { Text } from '@/components/Text' import { ExpandButton } from '@/components/_buttons/ExpandButton' +import { BuyFromMarketButton } from '@/components/_crt/BuyFromMarketButton/BuyFromMarketButton' +import { SellOnMarketButton } from '@/components/_crt/SellOnMarketButton/SellOnMarketButton' import { DetailsContent } from '@/components/_nft/NftTile' import { useMediaMatch } from '@/hooks/useMediaMatch' import { formatDate } from '@/utils/time' -import { Drawer, LabelText, StatisticsContainer, SupplyLine, ToggleContainer, Widget } from './CrtStatusWidget.styles' +import { Drawer, StatisticsContainer, SupplyLine, ToggleContainer, Widget } from './CrtStatusWidget.styles' type Amount = NumberFormatProps['value'] export type CrtStatusWidgetProps = { @@ -20,6 +24,8 @@ export type CrtStatusWidgetProps = { revenue: Amount revenueShare: Amount transactionVolume: Amount + status: 'inactive' | 'sale' | 'market' + tokenId: string } export const CrtStatusWidget: FC = ({ @@ -30,6 +36,8 @@ export const CrtStatusWidget: FC = ({ revenue, revenueShare, transactionVolume, + tokenId, + status, }) => { const drawer = useRef(null) const [isExpanded, expand] = useState(false) @@ -39,20 +47,14 @@ export const CrtStatusWidget: FC = ({ return ( - - No active sale - - - - - Total supply: - - - - + {status === 'inactive' ? ( + + ) : status === 'sale' ? null : ( + + )} expand(!isExpanded)}> @@ -111,3 +113,65 @@ export const CrtStatusWidget: FC = ({ /> ) } + +const InactiveDetails = ({ symbol, totalSupply }: { symbol: string; totalSupply: number | BN }) => { + return ( + <> + + No active sale + + + + Total supply: + + + + + + ) +} + +const MarketDetails = ({ + symbol, + totalSupply, + tokenId, +}: { + symbol: string + totalSupply: number | BN + tokenId: string +}) => { + return ( + <> + + + + + + + + + + Type: + + + Market + + + + + + Total supply: + + + + + + + ) +} diff --git a/packages/atlas/src/components/_crt/SellOnMarketButton/SellOnMarketButton.tsx b/packages/atlas/src/components/_crt/SellOnMarketButton/SellOnMarketButton.tsx new file mode 100644 index 0000000000..fbc4939904 --- /dev/null +++ b/packages/atlas/src/components/_crt/SellOnMarketButton/SellOnMarketButton.tsx @@ -0,0 +1,20 @@ +import { useState } from 'react' + +import { Button } from '@/components/_buttons/Button' +import { SellTokenModal } from '@/components/_crt/SellTokenModal' + +type SellOnMarketButtonProps = { + tokenId: string +} + +export const SellOnMarketButton = ({ tokenId }: SellOnMarketButtonProps) => { + const [showModal, setShowModal] = useState(false) + return ( + <> + setShowModal(false)} /> + + + ) +} diff --git a/packages/atlas/src/components/_crt/SellTokenModal/SellTokenModal.tsx b/packages/atlas/src/components/_crt/SellTokenModal/SellTokenModal.tsx index 1a7f8cd9a6..89c29a9fc0 100644 --- a/packages/atlas/src/components/_crt/SellTokenModal/SellTokenModal.tsx +++ b/packages/atlas/src/components/_crt/SellTokenModal/SellTokenModal.tsx @@ -1,193 +1,218 @@ -import { useMemo } from 'react' -import { Controller, useForm } from 'react-hook-form' +import BN from 'bn.js' +import { useCallback, useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' -import { FlexBox } from '@/components/FlexBox/FlexBox' -import { JoyTokenIcon } from '@/components/JoyTokenIcon' +import { useGetFullCreatorTokenQuery } from '@/api/queries/__generated__/creatorTokens.generated' import { NumberFormat } from '@/components/NumberFormat' -import { Text } from '@/components/Text' -import { TextButton } from '@/components/_buttons/Button' -import { FormField } from '@/components/_inputs/FormField' -import { TokenInput } from '@/components/_inputs/TokenInput' -import { DetailsContent } from '@/components/_nft/NftTile' +import { AmmModalFormTemplate } from '@/components/_crt/AmmModalTemplates' +import { AmmModalSummaryTemplate } from '@/components/_crt/AmmModalTemplates/AmmModalSummaryTemplate' import { DialogModal } from '@/components/_overlays/DialogModal' import { atlasConfig } from '@/config' -import { useMediaMatch } from '@/hooks/useMediaMatch' -import { useFee, useJoystream, useTokenPrice } from '@/providers/joystream' +import { hapiBnToTokenNumber, tokenNumberToHapiBn } from '@/joystream-lib/utils' +import { useFee, useJoystream } from '@/providers/joystream' import { useSnackbar } from '@/providers/snackbars' import { useTransaction } from '@/providers/transactions/transactions.hooks' import { useUser } from '@/providers/user/user.hooks' +import { calcSellMarketPricePerToken } from '@/utils/crts' export type SellTokenModalProps = { tokenId: string onClose: () => void + show: boolean } -const getTokenDetails = (_: string) => ({ - title: 'JBC', - pricePerUnit: 1000, - tokensOnSale: 67773, - userBalance: 100000, -}) - -export const SellTokenModal = ({ tokenId, onClose }: SellTokenModalProps) => { - const { control, watch, handleSubmit } = useForm<{ tokens: number }>() - const { convertTokensToUSD } = useTokenPrice() - const smMatch = useMediaMatch('sm') +export const SellTokenModal = ({ tokenId, onClose, show }: SellTokenModalProps) => { + const [step, setStep] = useState<'form' | 'summary'>('form') + const { control, watch, handleSubmit, formState } = useForm<{ tokens: number }>() const { memberId } = useUser() - const { pricePerUnit, tokensOnSale, userBalance, title } = getTokenDetails(tokenId) - const tokens = watch('tokens') + const tokens = watch('tokens') || 0 const { joystream, proxyCallback } = useJoystream() const handleTransaction = useTransaction() const { displaySnackbar } = useSnackbar() - const { fullFee } = useFee('sellTokenOnMarketTx') - const details = useMemo( + const { fullFee } = useFee('sellTokenOnMarketTx', ['1', '1', '2', '10000000']) + const { data } = useGetFullCreatorTokenQuery({ + variables: { + id: tokenId, + }, + }) + + const currentAmm = data?.creatorTokenById?.ammCurves.find((amm) => !amm.finalized) + const title = data?.creatorTokenById?.symbol ?? 'N/A' + const userTokenBalance = 0 // todo: this will come from orion + + const calculateSlippageAmount = useCallback( + (amount: number) => { + const currentAmm = data?.creatorTokenById?.ammCurves.find((amm) => !amm.finalized) + return calcSellMarketPricePerToken( + currentAmm?.mintedByAmm, + currentAmm?.ammSlopeParameter, + currentAmm?.ammInitPrice, + amount + ) + }, + [data?.creatorTokenById] + ) + + const priceForAllToken = useMemo(() => { + return hapiBnToTokenNumber(calculateSlippageAmount(Math.max(tokens, 1)) ?? new BN(0)) + }, [tokens, calculateSlippageAmount]) + const pricePerUnit = priceForAllToken / (tokens || 1) + + const formDetails = useMemo( () => [ + { + title: 'Available balance', + content: ( + + ), + tooltipText: 'Lorem ipsum', + }, { title: 'You will receive', content: ( 1_000_000 ? 'short' : 'full'} + value={tokens > 0 ? priceForAllToken : 0} as="p" variant="t200-strong" withToken + withDenomination="before" /> ), + tooltipText: 'Lorem ipsum', }, + ], + [priceForAllToken, pricePerUnit, title, tokens] + ) + + const summaryDetails = useMemo( + () => [ { - title: 'Purchase', + title: 'Selling', content: ( ), + tooltipText: 'Lorem ipsum', }, { - title: 'Platform fee', // todo: introduce platform fee - content: , + title: 'Price per unit', + content: ( + + ), + tooltipText: 'Lorem ipsum', }, { - title: 'Transaction fee', - content: , + title: 'Fee', + content: , + tooltipText: 'Lorem ipsum', }, { - title: 'Total', + title: 'You will receive', content: ( - - - - + - - - + ), + tooltipText: 'Lorem ipsum', }, ], - [fullFee, pricePerUnit, title, tokens, tokensOnSale] + [fullFee, priceForAllToken, pricePerUnit, title, tokens] ) - const onSubmit = () => - handleSubmit((data) => { - if (!joystream || !memberId) { - return - } - handleTransaction({ - txFactory: async (updateStatus) => - (await joystream.extrinsics).sellTokenOnMarket( - tokenId, - memberId, - String(data.tokens), - proxyCallback(updateStatus) - ), - onError: () => { - displaySnackbar({ - title: 'Something went wrong', - iconType: 'error', - }) - }, - onTxSync: async () => { - displaySnackbar({ - title: `${tokens * pricePerUnit} ${atlasConfig.joystream.tokenTicker} received`, - description: `${tokens} $${title} sold`, - }) - onClose() - }, - }) + const onFormSubmit = () => + handleSubmit(() => { + setStep('summary') })() + const onTransactionSubmit = async () => { + const slippageTolerance = calculateSlippageAmount(tokens) + + if (!joystream || !memberId || !slippageTolerance) { + return + } + handleTransaction({ + txFactory: async (updateStatus) => + (await joystream.extrinsics).sellTokenOnMarket( + tokenId, + memberId, + String(tokens), + slippageTolerance.toString(), + proxyCallback(updateStatus) + ), + onError: () => { + displaySnackbar({ + title: 'Something went wrong', + iconType: 'error', + }) + }, + onTxSync: async () => { + displaySnackbar({ + title: `${(tokens * priceForAllToken) / tokens} ${atlasConfig.joystream.tokenTicker} received`, + description: `${tokens} $${title} sold`, + }) + onClose() + }, + }) + } + + if (!currentAmm && show) { + throw new Error('BuyAmmModal invoked on token without active amm') + } + + const isFormStep = step === 'form' + return ( setStep('summary'), + }} primaryButton={{ - text: 'Sell tokens', - onClick: onSubmit, + text: isFormStep ? 'Continue' : 'Sell tokens', + onClick: isFormStep ? onFormSubmit : onTransactionSubmit, }} > - - - } - withDenomination - /> - } - withDenomination - /> - - { + if (!value || value < 1) { + return 'You need to sell at least one token' + } + if (value > +(currentAmm?.mintedByAmm ?? 0)) return 'You cannot sell more tokens than minted' + if (value > userTokenBalance) return 'Amount exceeds your account balance' + return true }} - render={({ field }) => ( - - - - ${convertTokensToUSD(field.value * pricePerUnit)?.toFixed(2)} - - field.onChange(userBalance)}>Max - - } - /> - - )} /> - - - {details.map((row, i) => ( - - - {row.title} - - {row.content} - - ))} - - + ) : ( + + )} ) } diff --git a/packages/atlas/src/components/_nft/NftTile/NftTileDetails.tsx b/packages/atlas/src/components/_nft/NftTile/NftTileDetails.tsx index 35f0e93253..3b3e7436ca 100644 --- a/packages/atlas/src/components/_nft/NftTile/NftTileDetails.tsx +++ b/packages/atlas/src/components/_nft/NftTile/NftTileDetails.tsx @@ -263,10 +263,21 @@ type DetailsContentProps = { secondary?: boolean tileSize: TileSize | undefined withDenomination?: boolean + denominationMultiplier?: number tooltipText?: string } export const DetailsContent: FC = memo( - ({ tileSize, caption, icon, content, secondary, avoidIconStyling, withDenomination, tooltipText }) => { + ({ + tileSize, + denominationMultiplier, + caption, + icon, + content, + secondary, + avoidIconStyling, + withDenomination, + tooltipText, + }) => { const getSize = () => { switch (tileSize) { case 'small': @@ -304,6 +315,7 @@ export const DetailsContent: FC = memo( variant={getSize().content} color={secondary ? 'colorText' : undefined} withDenomination={withDenomination} + denominationMultiplier={denominationMultiplier} /> ) : ( content diff --git a/packages/atlas/src/joystream-lib/extrinsics.ts b/packages/atlas/src/joystream-lib/extrinsics.ts index 8580c8ddab..ae91659561 100644 --- a/packages/atlas/src/joystream-lib/extrinsics.ts +++ b/packages/atlas/src/joystream-lib/extrinsics.ts @@ -1179,18 +1179,14 @@ export class JoystreamLibExtrinsics { return { block } } - purchaseTokenOnMarketTx = async (tokenId: string, memberId: string, amount: string) => { + purchaseTokenOnMarketTx = async (tokenId: string, memberId: string, amount: string, slippageAmount: string) => { await this.ensureApi() - const amountCast = createType('u128', new BN(amount)) return this.api.tx.projectToken.buyOnAmm( parseInt(tokenId), parseInt(memberId), amountCast, - createType('Option>', [ - createType('Permill', new BN(0.5 * PERMILL_PER_PERCENT)), - amountCast, - ]) // percent, number of joy user wants to pay --- default on 0.5% + [createType('Permill', new BN(0.5 * PERMILL_PER_PERCENT)), createType('u128', new BN(slippageAmount))] // percent, number of joy user wants to pay --- default on 0.5% ) } @@ -1198,14 +1194,15 @@ export class JoystreamLibExtrinsics { tokenId, memberId, amount, + slippageAmount, cb ) => { - const tx = await this.purchaseTokenOnMarketTx(tokenId, memberId, amount) + const tx = await this.purchaseTokenOnMarketTx(tokenId, memberId, amount, slippageAmount) const { block } = await this.sendExtrinsic(tx, cb) return { block } } - sellTokenOnMarketTx = async (tokenId: string, memberId: string, amount: string) => { + sellTokenOnMarketTx = async (tokenId: string, memberId: string, amount: string, slippageAmount: string) => { await this.ensureApi() const amountCast = createType('u128', new BN(amount)) @@ -1213,10 +1210,7 @@ export class JoystreamLibExtrinsics { parseInt(tokenId), parseInt(memberId), amountCast, - createType('Option>', [ - createType('Permill', new BN(0.5 * PERMILL_PER_PERCENT)), - amountCast, - ]) // percent, number of joy user wants to pay --- default on 0.5% + [createType('Permill', new BN(0.5 * PERMILL_PER_PERCENT)), createType('u128', new BN(slippageAmount))] // percent, number of joy user wants to pay --- default on 0.5% ) } @@ -1224,9 +1218,10 @@ export class JoystreamLibExtrinsics { tokenId, memberId, amount, + slippageAmount, cb ) => { - const tx = await this.sellTokenOnMarketTx(tokenId, memberId, amount) + const tx = await this.sellTokenOnMarketTx(tokenId, memberId, amount, slippageAmount) const { block } = await this.sendExtrinsic(tx, cb) return { block } } diff --git a/packages/atlas/src/utils/crts.ts b/packages/atlas/src/utils/crts.ts new file mode 100644 index 0000000000..a419da4440 --- /dev/null +++ b/packages/atlas/src/utils/crts.ts @@ -0,0 +1,32 @@ +import BN from 'bn.js' + +export const calcBuyMarketPricePerToken = ( + mintedByAmm?: string, + ammSlopeParameter?: string, + ammInitPrice?: string, + amount = 1 +) => { + if (!mintedByAmm || !ammSlopeParameter || !ammInitPrice) return + + const totalSupply = new BN(mintedByAmm) + const allocation = totalSupply + .addn(amount) + .pow(new BN(2)) + .sub(totalSupply.pow(new BN(2))) + return new BN(ammSlopeParameter).muln(0.5).mul(allocation).add(new BN(ammInitPrice).muln(amount)) +} + +export const calcSellMarketPricePerToken = ( + mintedByAmm?: string, + ammSlopeParameter?: string, + ammInitPrice?: string, + amount = 1 +) => { + if (!mintedByAmm || !ammSlopeParameter || !ammInitPrice) return + if (amount > +mintedByAmm) { + return new BN(0) + } + const totalSupply = new BN(mintedByAmm) + const allocation = totalSupply.pow(new BN(2)).sub(totalSupply.subn(amount).pow(new BN(2))) + return new BN(ammSlopeParameter).muln(0.5).mul(allocation).add(new BN(ammInitPrice).muln(amount)) +} diff --git a/packages/atlas/src/views/studio/CrtDashboard/tabs/CrtDashboardMainTab.tsx b/packages/atlas/src/views/studio/CrtDashboard/tabs/CrtDashboardMainTab.tsx index b9aca8b822..aa90668ce7 100644 --- a/packages/atlas/src/views/studio/CrtDashboard/tabs/CrtDashboardMainTab.tsx +++ b/packages/atlas/src/views/studio/CrtDashboard/tabs/CrtDashboardMainTab.tsx @@ -64,6 +64,9 @@ export const CrtDashboardMainTab = ({ token }: CrtDashboardMainTabProps) => { member: { id_eq: memberId, }, + token: { + id_eq: token.id, + }, }, }, }) diff --git a/packages/atlas/src/views/viewer/ChannelView/ChannelViewTabs/ChannelToken.tsx b/packages/atlas/src/views/viewer/ChannelView/ChannelViewTabs/ChannelToken.tsx index 6d93d79c27..ebcab08b90 100644 --- a/packages/atlas/src/views/viewer/ChannelView/ChannelViewTabs/ChannelToken.tsx +++ b/packages/atlas/src/views/viewer/ChannelView/ChannelViewTabs/ChannelToken.tsx @@ -69,6 +69,8 @@ export const ChannelToken = ({ tokenId, memberId }: ChannelTokenProps) => { revenue={token.annualCreatorRewardPermill} revenueShare={token.annualCreatorRewardPermill} transactionVolume={token.annualCreatorRewardPermill} + tokenId={token.id} + status="inactive" /> {holdersData ? (