From 696ee0c74897c3e06b63bf1f67e0aac20657183e Mon Sep 17 00:00:00 2001 From: Onno Visser Date: Mon, 16 Sep 2024 16:57:36 +0200 Subject: [PATCH] Token Transfers MVP (#2443) --- .../src/components/Portfolio/Holdings.tsx | 28 +- .../Portfolio/TransferTokensDrawer.tsx | 394 ++++++++++++------ .../IssuerPool/Investors/InvestorStatus.tsx | 48 ++- .../IssuerPool/Investors/LiquidityPools.tsx | 25 +- centrifuge-app/src/utils/formatting.ts | 4 +- centrifuge-app/src/utils/useLiquidityPools.ts | 11 +- centrifuge-app/src/utils/usePools.ts | 46 +- centrifuge-js/src/modules/liquidityPools.ts | 81 +++- .../abi/CentrifugeRouter.abi.json | 14 - .../liquidityPools/abi/Currency.abi.json | 2 +- centrifuge-js/src/modules/pools.ts | 59 +++ centrifuge-js/src/modules/tokens.ts | 28 +- 12 files changed, 496 insertions(+), 244 deletions(-) diff --git a/centrifuge-app/src/components/Portfolio/Holdings.tsx b/centrifuge-app/src/components/Portfolio/Holdings.tsx index 8ea2f99b75..8c2d462982 100644 --- a/centrifuge-app/src/components/Portfolio/Holdings.tsx +++ b/centrifuge-app/src/components/Portfolio/Holdings.tsx @@ -26,7 +26,7 @@ import { Tooltips } from '../Tooltips' import { TransferTokensDrawer } from './TransferTokensDrawer' import { usePortfolioTokens } from './usePortfolio' -type Row = { +export type Holding = { currency: Token['currency'] poolId: string trancheId: string @@ -42,13 +42,13 @@ const columns: Column[] = [ { align: 'left', header: 'Token', - cell: (token: Row) => { + cell: (token: Holding) => { return }, }, { header: , - cell: ({ tokenPrice }: Row) => { + cell: ({ tokenPrice }: Holding) => { return ( {formatBalance(tokenPrice || 1, 'USD', 4)} @@ -59,7 +59,7 @@ const columns: Column[] = [ }, { header: , - cell: ({ currency, position }: Row) => { + cell: ({ currency, position }: Holding) => { return ( {formatBalanceAbbreviated(position || 0, currency?.symbol, 2)} @@ -71,7 +71,7 @@ const columns: Column[] = [ }, { header: , - cell: ({ marketValue }: Row) => { + cell: ({ marketValue }: Holding) => { return ( {formatBalanceAbbreviated(marketValue || 0, 'USD', 2)} @@ -82,14 +82,21 @@ const columns: Column[] = [ align: 'left', }, { - align: 'left', + align: 'right', header: '', // invest redeem buttons - cell: ({ showActions, poolId, trancheId, currency, connectedNetwork }: Row) => { + width: 'max-content', + cell: ({ showActions, poolId, trancheId, currency, connectedNetwork }: Holding) => { return ( {showActions ? ( trancheId ? ( + + Receive + + + Send + Redeem @@ -127,7 +134,7 @@ export function useHoldings(address?: string, chainId?: number, showActions = tr const currencies = usePoolCurrencies() const CFGPrice = useCFGTokenPrice() - const tokens: Row[] = [ + const tokens: Holding[] = [ ...portfolioTokens.map((token) => ({ ...token, tokenPrice: token.tokenPrice.toDecimal() || Dec(0), @@ -179,7 +186,7 @@ export function useHoldings(address?: string, chainId?: number, showActions = tr connectedNetwork: wallet.connectedNetworkName, } }) || []), - ...((wallet.connectedNetworkName === 'Centrifuge' && showActions) || centBalances?.native.balance.gtn(0) + ...((wallet.connectedNetwork === 'centrifuge' && showActions) || centBalances?.native.balance.gtn(0) ? [ { currency: { @@ -239,7 +246,6 @@ export function Holdings({ defaultView={openRedeemDrawer ? 'redeem' : 'invest'} /> navigate(pathname, { replace: true })} /> @@ -254,7 +260,7 @@ export function Holdings({ ) } -const TokenWithIcon = ({ poolId, currency }: Row) => { +const TokenWithIcon = ({ poolId, currency }: Holding) => { const pool = usePool(poolId, false) const { data: metadata } = usePoolMetadata(pool) const cent = useCentrifuge() diff --git a/centrifuge-app/src/components/Portfolio/TransferTokensDrawer.tsx b/centrifuge-app/src/components/Portfolio/TransferTokensDrawer.tsx index b07a2155d2..a2f929d910 100644 --- a/centrifuge-app/src/components/Portfolio/TransferTokensDrawer.tsx +++ b/centrifuge-app/src/components/Portfolio/TransferTokensDrawer.tsx @@ -1,9 +1,12 @@ import { CurrencyBalance } from '@centrifuge/centrifuge-js' import { - useBalances, + getChainInfo, useCentEvmChainId, + useCentrifuge, + useCentrifugeConsts, useCentrifugeTransaction, useCentrifugeUtils, + useWallet, } from '@centrifuge/centrifuge-react' import { AddressInput, @@ -14,6 +17,7 @@ import { Drawer, IconCheckCircle, IconCopy, + Select, Shelf, Stack, Tabs, @@ -21,203 +25,307 @@ import { Text, } from '@centrifuge/fabric' import { isAddress as isEvmAddress } from '@ethersproject/address' -import { isAddress as isSubstrateAddress } from '@polkadot/util-crypto' +import { isAddress } from '@polkadot/util-crypto' +import BN from 'bn.js' import Decimal from 'decimal.js-light' import { Field, FieldProps, Form, FormikProvider, useFormik } from 'formik' -import React, { useMemo } from 'react' +import React, { useEffect } from 'react' import { useQuery } from 'react-query' import { useLocation, useMatch, useNavigate } from 'react-router' import styled from 'styled-components' import centrifugeLogo from '../../assets/images/logoCentrifuge.svg' +import { useInvestorStatus } from '../../pages/IssuerPool/Investors/InvestorStatus' import { Dec } from '../../utils/Decimal' import { copyToClipboard } from '../../utils/copyToClipboard' import { formatBalance, formatBalanceAbbreviated } from '../../utils/formatting' +import { useEvmTransaction } from '../../utils/tinlake/useEvmTransaction' +import { useAddress } from '../../utils/useAddress' import { useCFGTokenPrice, useDailyCFGPrice } from '../../utils/useCFGTokenPrice' -import { useTransactionFeeEstimate } from '../../utils/useTransactionFeeEstimate' +import { useActiveDomains, useLiquidityPools } from '../../utils/useLiquidityPools' +import { combine, max, positiveNumber, required } from '../../utils/validation' import { truncate } from '../../utils/web3' import { FilterOptions, PriceChart } from '../Charts/PriceChart' import { LabelValueStack } from '../LabelValueStack' +import { LoadBoundary } from '../LoadBoundary' +import { Spinner } from '../Spinner' import { Tooltips } from '../Tooltips' +import { Holding, useHoldings } from './Holdings' type TransferTokensProps = { - address: string onClose: () => void isOpen: boolean } -export const TransferTokensDrawer = ({ address, onClose, isOpen }: TransferTokensProps) => { - const centBalances = useBalances(address) - const CFGPrice = useCFGTokenPrice() +export function TransferTokensDrawer({ onClose, isOpen }: TransferTokensProps) { + return ( + + + + + + ) +} + +function TransferTokensDrawerInner() { + const address = useAddress() + const consts = useCentrifugeConsts() + const tokens = useHoldings(address) const isPortfolioPage = useMatch('/portfolio') + const { search } = useLocation() const navigate = useNavigate() const params = new URLSearchParams(search) - const transferCurrencySymbol = params.get('receive') || params.get('send') - const isNativeTransfer = transferCurrencySymbol?.toLowerCase() === centBalances?.native.currency.symbol.toLowerCase() - const currency = useMemo(() => { - if (isNativeTransfer && centBalances?.native) { - return { - ...centBalances.native, - balance: new CurrencyBalance( - centBalances?.native.balance.sub(centBalances.native.locked), - centBalances.native.currency.decimals - ), - } + const transferKey = params.get('receive') || params.get('send') || '' + const isSend = !!params.get('send') + const isNativeTransfer = transferKey.toLowerCase() === consts.chainSymbol.toLowerCase() + + function getHolding() { + if (!transferKey) return null + + if (transferKey?.includes('.')) { + const [poolId, trancheId] = transferKey.split('.') + return tokens?.find((token) => token.poolId === poolId && token.trancheId === trancheId) + } else { + return tokens?.find((token) => token.currency.symbol === transferKey) } - return centBalances?.currencies.find((token) => token.currency.symbol === transferCurrencySymbol) - }, [centBalances, isNativeTransfer, transferCurrencySymbol]) + } - const tokenPrice = isNativeTransfer ? CFGPrice : 1 + const holding = getHolding() - return ( - - - - {transferCurrencySymbol || 'CFG'} Holdings - - - - - - ) : ( - 'Price' - ) + return holding ? ( + + + {holding?.currency.symbol} Holdings + + + + + : 'Price'} + value={formatBalance(holding?.tokenPrice || 0, 'USD', 4)} + /> + + {isPortfolioPage && ( + + + navigate({ + search: index === 0 ? `send=${transferKey}` : `receive=${transferKey}`, + }) } - value={formatBalance(tokenPrice || 0, 'USD', 4)} - /> - - {isPortfolioPage && ( - - - navigate({ - search: index === 0 ? `send=${transferCurrencySymbol}` : `receive=${transferCurrencySymbol}`, - }) - } - > - Send - Receive - - {params.get('send') ? ( - - ) : ( - - )} - - )} - {isNativeTransfer && ( - - - Price - - - - - - )} - - + > + Send + Receive + + {isSend ? ( + + ) : ( + + )} + + )} + {isNativeTransfer && ( + + + Price + + + + + + )} + + ) : ( + ) } -type SendReceiveProps = { - address: string - currency?: { - balance: CurrencyBalance - currency: { symbol: string; decimals: number; key: string | { ForeignAsset: number } } - } +type SendProps = { + holding: Holding isNativeTransfer?: boolean } -const SendToken = ({ address, currency, isNativeTransfer }: SendReceiveProps) => { +const SendToken = ({ holding, isNativeTransfer }: SendProps) => { + const address = useAddress() + const cent = useCentrifuge() + const { data: domains } = useActiveDomains(holding.poolId) + const activeDomains = domains?.filter((domain) => domain.hasDeployedLp) ?? [] + const { + connectedNetwork, + isEvmOnSubstrate, + evm: { chains, chainId: connectedEvmChainId, getProvider }, + } = useWallet() + const utils = useCentrifugeUtils() - const chainId = useCentEvmChainId() + const centEvmChainId = useCentEvmChainId() const { execute: transfer, isLoading } = useCentrifugeTransaction( - `Send ${currency?.currency.symbol || 'CFG'}`, + `Send ${holding.currency.symbol}`, (cent) => cent.tokens.transfer, { onSuccess: () => form.resetForm(), } ) - - const { txFee, execute: estimatedTxFee } = useTransactionFeeEstimate((cent) => cent.tokens.transfer) - useQuery( - ['paymentInfo', address], - async () => { - if (!currency) return - await estimatedTxFee([ - address, - currency?.currency.key, - CurrencyBalance.fromFloat(currency.balance.toDecimal(), currency?.currency.decimals), - ]) - }, + const { execute: evmTransfer, isLoading: evmIsLoading } = useEvmTransaction( + `Send ${holding.currency.symbol}`, + (cent) => cent.liquidityPools.transferTrancheTokens, { - enabled: !!address, + onSuccess: () => { + form.resetForm() + refetchAllowance() + }, } ) - const form = useFormik<{ amount: Decimal | undefined; recipientAddress: string; isDisclaimerAgreed: boolean }>({ + const form = useFormik<{ + amount: Decimal | number | '' + chain: number | '' + recipientAddress: string + isDisclaimerAgreed: boolean + }>({ initialValues: { - amount: undefined, + amount: '', + chain: '', recipientAddress: '', isDisclaimerAgreed: false, }, validate(values) { const errors: Partial<{ amount: string; recipientAddress: string; isDisclaimerAgreed: string }> = {} + const { chain, recipientAddress } = values + const validator = chain ? isEvmAddress : isAddress + const validAddress = validator(recipientAddress) ? recipientAddress : undefined + if (!validAddress) { + errors.recipientAddress = 'Invalid address' + } else if (!allowedTranches.includes(holding.trancheId)) { + errors.recipientAddress = 'Recipient is not allowed to receive this token' + } if (!values.isDisclaimerAgreed && values.recipientAddress.startsWith('0x') && isNativeTransfer) { errors.isDisclaimerAgreed = 'Please read and accept the above' } - if (values.amount && Dec(values.amount).gt(currency?.balance.toDecimal() || Dec(0))) { - errors.amount = 'Amount exceeds wallet balance' - } - if (!values.amount || Dec(values.amount).lte(0)) { - errors.amount = 'Amount must be greater than 0' - } - if (!(isSubstrateAddress(values.recipientAddress) || isEvmAddress(values.recipientAddress))) { - errors.recipientAddress = 'Invalid address format' - } - return errors }, onSubmit: (values, actions) => { - if (typeof values.amount === 'undefined') { - actions.setErrors({ amount: 'Amount must be greater than 0' }) - } else if (!currency) { - actions.setErrors({ amount: 'Invalid currency' }) - } else { - if (isEvmAddress(values.recipientAddress)) { - values.recipientAddress = utils.evmToSubstrateAddress(values.recipientAddress, chainId || 2031) - } + let { recipientAddress, chain } = values + if (isEvmAddress(recipientAddress) && chain === '') { + recipientAddress = utils.evmToSubstrateAddress(recipientAddress, centEvmChainId!) + } + if (connectedNetwork === 'centrifuge' || isEvmOnSubstrate) { transfer([ - values.recipientAddress, - currency?.currency.key, - CurrencyBalance.fromFloat(values.amount.toString(), currency?.currency.decimals), + recipientAddress, + holding.currency.key, + CurrencyBalance.fromFloat(values.amount.toString(), holding.currency.decimals), + chain === '' ? undefined : { evm: chain }, ]) + } else { + if (!liquidityPools?.[0]) return + const amount = CurrencyBalance.fromFloat(values.amount || 0, holding.currency.decimals) + const send = () => + evmTransfer([ + recipientAddress, + amount, + liquidityPools[0].lpAddress, + liquidityPools[0].trancheTokenAddress, + connectedEvmChainId!, + chain === '' ? 'centrifuge' : { evm: chain }, + ]) + if (isEvmAndNeedsApprove) { + executeApprove([ + send, + liquidityPools[0].trancheTokenAddress, + CurrencyBalance.fromFloat(values.amount || 0, holding.currency.decimals), + connectedEvmChainId!, + ]) + } else { + send() + } } actions.setSubmitting(false) }, }) + const { data: liquidityPools } = useLiquidityPools( + holding.poolId, + holding.trancheId, + !isEvmOnSubstrate ? connectedEvmChainId ?? -1 : -1 // typeof form.values.chain === 'number' ? form.values.chain : + ) + + const { allowedTranches } = useInvestorStatus( + holding.poolId, + form.values.recipientAddress, + form.values.chain || 'centrifuge' + ) + + const { data: allowanceData, refetch: refetchAllowance } = useQuery( + ['allowance', liquidityPools?.[0]?.trancheTokenAddress, connectedEvmChainId, address], + () => + cent.liquidityPools.getCentrifugeRouterAllowance( + [liquidityPools![0].trancheTokenAddress!, address!, connectedEvmChainId!], + { + rpcProvider: getProvider(connectedEvmChainId!), + } + ), + { + enabled: + !!liquidityPools?.[0]?.trancheTokenAddress && !!connectedEvmChainId && !!address && isEvmAddress(address), + } + ) + + const isEvmAndNeedsApprove = + !!liquidityPools?.[0]?.trancheTokenAddress && + allowanceData && + allowanceData?.allowance?.toDecimal().lt(Dec(form.values.amount || 0)) + + const { execute: executeApprove, isLoading: isApproving } = useEvmTransaction( + `Send ${holding.currency.symbol}`, + (cent) => + ([, ...args]: [cb: () => void, currencyAddress: string, amount: BN, chainId: number], options) => + cent.liquidityPools.approveForCurrency(args, options), + { + onSuccess: ([cb]) => { + cb() + }, + } + ) + + useEffect(() => { + form.validateForm() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allowedTranches]) + return (
+ + {({ field, form, meta }: FieldProps) => ( +