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 ? (