From 7e89804b00efd9725c1730b1b436f2645e6cb66a Mon Sep 17 00:00:00 2001 From: sokolova-an Date: Thu, 21 Dec 2023 13:21:51 +0100 Subject: [PATCH 1/6] add transfer flow --- .../extrinsicService/ExtrinsicProvider.tsx | 8 +- src/common/extrinsicService/types/index.ts | 7 + src/common/providers/contextProvider.tsx | 7 +- src/common/routing/paths.ts | 2 + src/common/types/index.ts | 3 + src/common/utils/address.ts | 30 +++++ src/common/utils/balance.ts | 32 +++-- src/pages/onboarding/create-wallet/index.tsx | 9 +- src/pages/transfer/address.tsx | 36 +++-- src/pages/transfer/amount.tsx | 63 ++------- src/pages/transfer/confirmation.tsx | 94 +++++++++++++ src/pages/transfer/index.tsx | 8 +- src/pages/transfer/result.tsx | 45 +++++++ src/pages/transfer/select-token.tsx | 13 +- src/screens/dashboard/main/DashboardMain.tsx | 3 +- .../onboarding/restore/RestoreWallet.tsx | 34 ++--- src/screens/transfer/amount/amount.tsx | 124 ++++++++++++++++++ 17 files changed, 405 insertions(+), 113 deletions(-) create mode 100644 src/common/utils/address.ts create mode 100644 src/pages/transfer/confirmation.tsx create mode 100644 src/pages/transfer/result.tsx create mode 100644 src/screens/transfer/amount/amount.tsx diff --git a/src/common/extrinsicService/ExtrinsicProvider.tsx b/src/common/extrinsicService/ExtrinsicProvider.tsx index 5cea110a..3ef05b68 100644 --- a/src/common/extrinsicService/ExtrinsicProvider.tsx +++ b/src/common/extrinsicService/ExtrinsicProvider.tsx @@ -1,17 +1,13 @@ import { createContext, PropsWithChildren, useContext } from 'react'; import { ChainId } from '@common/types'; -import { ExtrinsicBuilding, ExtrinsicBuildingOptions } from '@common/extrinsicService/types'; +import { EstimateFee, ExtrinsicBuilding, ExtrinsicBuildingOptions } from '@common/extrinsicService/types'; import { Balance } from '@polkadot/types/interfaces'; import { SubmittableResultResult } from '@polkadot/api-base/types/submittable'; import { useExtrinsicService } from '@common/extrinsicService/ExtrinsicService'; import { KeyringPair } from '@polkadot/keyring/types'; type ExtrinsicProviderContextProps = { - estimateFee: ( - chainId: ChainId, - building: ExtrinsicBuilding, - options?: Partial, - ) => Promise; + estimateFee: EstimateFee; submitExtrinsic: ( chainId: ChainId, diff --git a/src/common/extrinsicService/types/index.ts b/src/common/extrinsicService/types/index.ts index a52fcfeb..b3c09a5b 100644 --- a/src/common/extrinsicService/types/index.ts +++ b/src/common/extrinsicService/types/index.ts @@ -1,6 +1,7 @@ import { ApiPromise } from '@polkadot/api'; import { SubmittableExtrinsic } from '@polkadot/api-base/types'; import { ChainId } from '@common/types'; +import { Balance } from '@polkadot/types/interfaces'; export interface ExtrinsicBuilder { api: ApiPromise; @@ -30,3 +31,9 @@ export interface ExtrinsicBuilderFactory { */ forChain(chainId: ChainId): Promise; } + +export type EstimateFee = ( + chainId: ChainId, + building: ExtrinsicBuilding, + options?: Partial, +) => Promise; diff --git a/src/common/providers/contextProvider.tsx b/src/common/providers/contextProvider.tsx index 72c99648..47a6745b 100644 --- a/src/common/providers/contextProvider.tsx +++ b/src/common/providers/contextProvider.tsx @@ -5,14 +5,15 @@ import { getWallet } from '../wallet'; export interface IContext { assets: AssetAccount[] | []; publicKey?: HexString; - selectedAsset?: TrasferAsset; + selectedAsset?: TrasferAsset | null; setPublicKey: React.Dispatch>; setAssets: React.Dispatch>; - setSelectedAsset: React.Dispatch>; + setSelectedAsset: React.Dispatch>; } export const GlobalContext = createContext({ assets: [], + selectedAsset: null, setPublicKey: () => {}, setAssets: () => {}, setSelectedAsset: () => {}, @@ -22,7 +23,7 @@ export const GlobalContext = createContext({ export const GlobalStateProvider = ({ children }: { children: React.ReactNode }) => { const [publicKey, setPublicKey] = useState(); const [assets, setAssets] = useState([]); - const [selectedAsset, setSelectedAsset] = useState(); + const [selectedAsset, setSelectedAsset] = useState(null); useEffect(() => { if (!publicKey) { diff --git a/src/common/routing/paths.ts b/src/common/routing/paths.ts index e3fba068..3718acb8 100644 --- a/src/common/routing/paths.ts +++ b/src/common/routing/paths.ts @@ -12,6 +12,8 @@ export const Paths = { TRANSFER_SELECT_TOKEN: '/transfer/select-token', TRANSFER_ADDRESS: '/transfer/address', TRANSFER_AMOUNT: '/transfer/amount', + TRANSFER_CONFIRMATION: '/transfer/confirmation', + TRANSFER_RESULT: '/transfer/result', } as const; export type PathValue = (typeof Paths)[keyof typeof Paths]; diff --git a/src/common/types/index.ts b/src/common/types/index.ts index 623cec7a..283cffbc 100644 --- a/src/common/types/index.ts +++ b/src/common/types/index.ts @@ -9,6 +9,7 @@ export function unwrapHexString(string: string): HexString { export type ChainId = HexString; export type AssetId = number; export type Address = string; +export type AccountId = HexString; export type PublicKey = HexString; export type ChainAssetId = { @@ -34,6 +35,8 @@ export type AssetAccount = ChainAssetAccount & { export type TrasferAsset = AssetAccount & { destination?: string; amount?: string; + fee?: number; + transferAll?: boolean; }; export type StateResolution = { resolve: (value: T) => void; reject: () => void }; diff --git a/src/common/utils/address.ts b/src/common/utils/address.ts new file mode 100644 index 00000000..35b9d8b3 --- /dev/null +++ b/src/common/utils/address.ts @@ -0,0 +1,30 @@ +import { isHex, isU8a, u8aToU8a } from '@polkadot/util'; +import { base58Decode, checkAddressChecksum } from '@polkadot/util-crypto'; +import { AccountId, Address } from '../types'; + +const ADDRESS_ALLOWED_ENCODED_LENGTHS = [35, 36, 37, 38]; +const PUBLIC_KEY_LENGTH_BYTES = 32; + +/** + * Check is account's address valid + * @param address account's address + * @return {Boolean} + */ +export const validateAddress = (address?: Address | AccountId): boolean => { + if (!address) return false; + + if (isU8a(address) || isHex(address)) { + return u8aToU8a(address).length === PUBLIC_KEY_LENGTH_BYTES; + } + + try { + const decoded = base58Decode(address); + if (!ADDRESS_ALLOWED_ENCODED_LENGTHS.includes(decoded.length)) return false; + + const [isValid, endPos, ss58Length] = checkAddressChecksum(decoded); + + return isValid && Boolean(decoded.slice(ss58Length, endPos)); + } catch (error) { + return false; + } +}; diff --git a/src/common/utils/balance.ts b/src/common/utils/balance.ts index e3124f81..83a4a5bc 100644 --- a/src/common/utils/balance.ts +++ b/src/common/utils/balance.ts @@ -1,7 +1,10 @@ import BigNumber from 'bignumber.js'; -import { AssetAccount } from '../types'; +import { decodeAddress } from '@polkadot/keyring'; +import { Address, AssetAccount, ChainId } from '../types'; import { Chain } from '../chainRegistry/types'; import { IAssetBalance } from '../balances/types'; +import { EstimateFee, ExtrinsicBuilder } from '../extrinsicService/types'; +import { Balance } from '@polkadot/types/interfaces'; const ZERO_BALANCE = '0'; @@ -151,15 +154,18 @@ export const updateAssetsBalance = (prevAssets: AssetAccount[], chain: Chain, ba // ); // } -// export async function handleFee(estimateFee, chainId: ChainId, address: Address) { -// estimateFee(chainId, (builder: ExtrinsicBuilder) => -// builder.addCall(builder.api.tx.balances.transferKeepAlive(decodeAddress(address), '0')), -// ).then( -// (fee) => { -// alert('Fee: ' + fee); -// }, -// (failure) => { -// alert('Failed to calculate fee: ' + failure); -// }, -// ); -// } +const FAKE_AMMOUNT = '1'; +export async function handleFee( + estimateFee: EstimateFee, + chainId: ChainId, + address: Address, + precision: number, +): Promise { + return await estimateFee(chainId, (builder: ExtrinsicBuilder) => + builder.addCall(builder.api.tx.balances.transferKeepAlive(decodeAddress(address), FAKE_AMMOUNT)), + ).then((fee: Balance) => { + const { formattedValue } = formatBalance(fee.toString(), precision); + + return Number(formattedValue); + }); +} diff --git a/src/pages/onboarding/create-wallet/index.tsx b/src/pages/onboarding/create-wallet/index.tsx index e1780199..cb9d71b9 100644 --- a/src/pages/onboarding/create-wallet/index.tsx +++ b/src/pages/onboarding/create-wallet/index.tsx @@ -15,6 +15,10 @@ export default function CreateWalletPage() { const [isLoading, setIsLoading] = useState(true); useEffect(() => { + const callback = () => { + router.push(Paths.DASHBOARD); + }; + (async () => { if (!publicKey) { console.error("Can't create wallet when public key is missing"); @@ -31,9 +35,7 @@ export default function CreateWalletPage() { // TODO: Handle errors here and display retry page maybe await completeOnboarding(publicKey, webApp); - MainButton?.onClick(() => { - router.push(Paths.DASHBOARD); - }); + MainButton?.onClick(callback); setTimeout(() => { setIsLoading(false); @@ -47,6 +49,7 @@ export default function CreateWalletPage() { MainButton?.setText('Continue'); MainButton?.hide(); MainButton?.hideProgress(); + MainButton?.offClick(callback); }; }, []); diff --git a/src/pages/transfer/address.tsx b/src/pages/transfer/address.tsx index ef14fadc..2f624a34 100644 --- a/src/pages/transfer/address.tsx +++ b/src/pages/transfer/address.tsx @@ -5,6 +5,7 @@ import { Button, Input } from '@nextui-org/react'; import { useTelegram } from '@common/providers/telegramProvider'; import { useGlobalContext } from '@/common/providers/contextProvider'; +import { validateAddress } from '@/common/utils/address'; import { Icon, HelpText, BodyText, Identicon } from '@/components'; import { Paths } from '@/common/routing'; @@ -16,38 +17,47 @@ export default function AddressPage() { const [isAddressValid, setIsAddressValid] = useState(true); useEffect(() => { - BackButton?.show(); - BackButton?.onClick(() => { + router.prefetch(Paths.TRANSFER_AMOUNT); + const callback = () => { router.push(Paths.TRANSFER_SELECT_TOKEN); - }); + }; + + BackButton?.show(); + BackButton?.onClick(callback); + + return () => { + BackButton?.offClick(callback); + }; }, []); useEffect(() => { + const callback = () => { + setSelectedAsset((prev) => ({ ...prev!, destination: address })); + router.push(Paths.TRANSFER_AMOUNT); + }; + if (address.length) { MainButton?.show(); - MainButton?.onClick(() => { - setSelectedAsset((prev) => ({ ...prev!, destination: address })); - router.push(Paths.TRANSFER_AMOUNT); - }); isAddressValid ? MainButton?.enable() : MainButton?.disable(); + MainButton?.onClick(callback); } else { MainButton?.hide(); } + + return () => { + MainButton?.offClick(callback); + }; }, [address, isAddressValid]); const handleChange = (value: string) => { setAddress(value); - - // TODO: validate address - setIsAddressValid(value.length > 10); + setIsAddressValid(validateAddress(value)); }; const handleQrCode = () => { webApp?.showScanQrPopup({ text: 'Scan QR code' }, (value) => { setAddress(value); - - // TODO: validate address - setIsAddressValid(true); + setIsAddressValid(validateAddress(value)); webApp.closeScanQrPopup(); }); }; diff --git a/src/pages/transfer/amount.tsx b/src/pages/transfer/amount.tsx index 67b12fbe..077bcf65 100644 --- a/src/pages/transfer/amount.tsx +++ b/src/pages/transfer/amount.tsx @@ -1,58 +1,15 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/router'; -import MiddleEllipsis from 'react-middle-ellipsis'; -import { Input } from '@nextui-org/react'; - -import { useTelegram } from '@common/providers/telegramProvider'; -import { useGlobalContext } from '@/common/providers/contextProvider'; -import { Paths } from '@/common/routing'; -import { HeadlineText, Icon, Identicon, CaptionText, LargeTitleText, TextBase } from '@/components'; -import { IconNames } from '@/components/Icon/types'; - -export default function AmountPage() { - const router = useRouter(); - const { BackButton, MainButton } = useTelegram(); - const { selectedAsset } = useGlobalContext(); - const [amount, setAmount] = useState('0'); - - useEffect(() => { - BackButton?.show(); - MainButton?.disable(); - BackButton?.onClick(() => { - router.push(Paths.TRANSFER_ADDRESS); - }); - }, []); +import { ExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; +import { ChainRegistry } from '@/common/chainRegistry'; +import AmountPage from '@/screens/transfer/amount/amount'; +// TODO: swithch to Layout pattern +export default function Amount() { return ( -
-
- - - Send to -
- - - {selectedAsset?.destination} - - -
-
- - Max: {selectedAsset?.transferableBalance} {selectedAsset?.symbol} - -
-
- - {selectedAsset?.symbol} - -
-
+ + + + + ); } diff --git a/src/pages/transfer/confirmation.tsx b/src/pages/transfer/confirmation.tsx new file mode 100644 index 00000000..a1cd0efe --- /dev/null +++ b/src/pages/transfer/confirmation.tsx @@ -0,0 +1,94 @@ +'use client'; +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; +// @ts-expect-error no types +import MiddleEllipsis from 'react-middle-ellipsis'; +import { Divider } from '@nextui-org/react'; + +import { useTelegram } from '@common/providers/telegramProvider'; +import { useGlobalContext } from '@/common/providers/contextProvider'; +import { Paths } from '@/common/routing'; +import { HeadlineText, Icon, Identicon, LargeTitleText, TextBase, Plate, BodyText, CaptionText } from '@/components'; +import { IconNames } from '@/components/Icon/types'; + +export default function ConfirmationPage() { + const router = useRouter(); + const { BackButton, MainButton } = useTelegram(); + const { selectedAsset } = useGlobalContext(); + + useEffect(() => { + const mainCallback = () => { + router.push(Paths.TRANSFER_RESULT); + }; + const backCallback = () => { + router.push(Paths.TRANSFER_AMOUNT); + }; + + BackButton?.show(); + BackButton?.onClick(backCallback); + + MainButton?.show(); + MainButton?.setText('Confirm'); + MainButton?.onClick(mainCallback); + + return () => { + MainButton?.offClick(mainCallback); + BackButton?.offClick(backCallback); + }; + }, []); + + const details = [ + { + title: 'Recipients address', + value: selectedAsset?.destination, + }, + { + title: 'Fee', + value: `${selectedAsset?.fee} ${selectedAsset?.symbol}`, + }, + { + title: 'Total amount', + value: `${selectedAsset?.amount} ${selectedAsset?.symbol}`, + }, + { + title: 'Network', + value: selectedAsset?.name, + }, + ]; + + return ( +
+
+ + + Send to + + + + {selectedAsset?.destination} + + + + +
+
+ + {selectedAsset?.symbol} + {selectedAsset?.amount} +
+ + {details.map(({ title, value }, index) => ( + <> + {index !== 0 && } +
+ + {title} + + {value} +
+ + ))} +
+
+ ); +} diff --git a/src/pages/transfer/index.tsx b/src/pages/transfer/index.tsx index 53ac5c66..7bd2295e 100644 --- a/src/pages/transfer/index.tsx +++ b/src/pages/transfer/index.tsx @@ -12,13 +12,15 @@ export default function TransferPage() { const { BackButton } = useTelegram(); useEffect(() => { - BackButton?.show(); - BackButton?.onClick(() => { + const callback = () => { router.push(Paths.DASHBOARD); - }); + }; + BackButton?.show(); + BackButton?.onClick(callback); return () => { BackButton?.hide(); + BackButton?.offClick(callback); }; }, []); diff --git a/src/pages/transfer/result.tsx b/src/pages/transfer/result.tsx new file mode 100644 index 00000000..e8b00d56 --- /dev/null +++ b/src/pages/transfer/result.tsx @@ -0,0 +1,45 @@ +'use client'; +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; + +import { useTelegram } from '@common/providers/telegramProvider'; +import { useGlobalContext } from '@/common/providers/contextProvider'; +import { Paths } from '@/common/routing'; +import { HeadlineText, TitleText } from '@/components'; + +export default function ResultPage() { + const router = useRouter(); + const { BackButton, MainButton } = useTelegram(); + const { selectedAsset, setSelectedAsset } = useGlobalContext(); + + useEffect(() => { + MainButton?.setText('Done'); + BackButton?.hide(); + MainButton?.show(); + const callback = () => { + router.replace(Paths.DASHBOARD); + }; + MainButton?.onClick(callback); + + return () => { + MainButton?.hide(); + MainButton?.setText('Continue'); + MainButton?.offClick(callback); + setSelectedAsset(null); + }; + }, []); + + return ( +
+ + {selectedAsset?.amount} {selectedAsset?.symbol} Sent to + + + {selectedAsset?.destination} + + + Your transaction has been sent to the network and will be processed in a few seconds. + +
+ ); +} diff --git a/src/pages/transfer/select-token.tsx b/src/pages/transfer/select-token.tsx index d7b9b18c..c0a41588 100644 --- a/src/pages/transfer/select-token.tsx +++ b/src/pages/transfer/select-token.tsx @@ -14,10 +14,17 @@ export default function SelectTokenPage() { const { assets, setSelectedAsset } = useGlobalContext(); useEffect(() => { - BackButton?.show(); - BackButton?.onClick(() => { + router.prefetch(Paths.TRANSFER_ADDRESS); + + const callback = () => { router.push(Paths.TRANSFER); - }); + }; + BackButton?.show(); + BackButton?.onClick(callback); + + return () => { + BackButton?.offClick(callback); + }; }, []); return ( diff --git a/src/screens/dashboard/main/DashboardMain.tsx b/src/screens/dashboard/main/DashboardMain.tsx index c6e35a28..83f27dfe 100644 --- a/src/screens/dashboard/main/DashboardMain.tsx +++ b/src/screens/dashboard/main/DashboardMain.tsx @@ -20,10 +20,11 @@ export const DashboardMain = () => { const { getAllChains } = useChainRegistry(); const { subscribeBalance } = useBalances(); const { publicKey, setAssets, assets } = useGlobalContext(); - const { user, MainButton } = useTelegram(); + const { user, MainButton, BackButton } = useTelegram(); useEffect(() => { MainButton?.hide(); + BackButton?.hide(); if (!publicKey) { return; } diff --git a/src/screens/onboarding/restore/RestoreWallet.tsx b/src/screens/onboarding/restore/RestoreWallet.tsx index 1d405172..941ff4a9 100644 --- a/src/screens/onboarding/restore/RestoreWallet.tsx +++ b/src/screens/onboarding/restore/RestoreWallet.tsx @@ -27,26 +27,30 @@ export const RestoreWalletPage = ({ mnemonic }: Props) => { }, []); useEffect(() => { - if (password.length) { - MainButton?.enable(); - - MainButton?.onClick(() => { - if (password.length < 8) { - setIsPasswordValid(false); + const callback = () => { + if (password.length < 8) { + setIsPasswordValid(false); - return; - } - const wallet = initializeWalletFromCloud(password, mnemonic); - setIsPasswordValid(Boolean(wallet)); + return; + } + const wallet = initializeWalletFromCloud(password, mnemonic); + setIsPasswordValid(Boolean(wallet)); + if (wallet) { + setPublicKey(wallet?.publicKey); + router.push(Paths.DASHBOARD); + } + }; - if (wallet) { - setPublicKey(wallet?.publicKey); - router.push(Paths.DASHBOARD); - } - }); + if (password.length) { + MainButton?.enable(); + MainButton?.onClick(callback); } else { MainButton?.disable(); } + + return () => { + MainButton?.offClick(callback); + }; }, [password]); return ( diff --git a/src/screens/transfer/amount/amount.tsx b/src/screens/transfer/amount/amount.tsx new file mode 100644 index 00000000..8716bec2 --- /dev/null +++ b/src/screens/transfer/amount/amount.tsx @@ -0,0 +1,124 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +// @ts-expect-error no types +import MiddleEllipsis from 'react-middle-ellipsis'; +import { Button, Input } from '@nextui-org/react'; + +import { useTelegram } from '@common/providers/telegramProvider'; +import { useGlobalContext } from '@/common/providers/contextProvider'; +import { Paths } from '@/common/routing'; +import { HeadlineText, Icon, Identicon, CaptionText, LargeTitleText, TextBase } from '@/components'; +import { IconNames } from '@/components/Icon/types'; +import { useExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; +import { handleFee } from '@/common/utils/balance'; + +export default function AmountPage() { + const router = useRouter(); + const { estimateFee } = useExtrinsicProvider(); + const { BackButton, MainButton } = useTelegram(); + const { selectedAsset, setSelectedAsset } = useGlobalContext(); + + const [amount, setAmount] = useState('0'); + const [transferAll, setTransferAll] = useState(false); + const [maxAmountToSend, setMaxAmountToSend] = useState(); + const [isAmountValid, setIsAmountValid] = useState(true); + + useEffect(() => { + router.prefetch(Paths.TRANSFER_CONFIRMATION); + MainButton?.setText('Continue'); + BackButton?.show(); + MainButton?.show(); + MainButton?.disable(); + + const callback = () => { + router.push(Paths.TRANSFER_ADDRESS); + }; + BackButton?.onClick(callback); + + if (!selectedAsset) return; + + (async () => { + const fee = + selectedAsset.fee || + (await handleFee(estimateFee, selectedAsset.chainId, selectedAsset.address, selectedAsset.precision)); + + // should we round it more? + const max = Math.max(Number(selectedAsset.transferableBalance) - fee, 0); + setMaxAmountToSend(max); + setSelectedAsset((prev) => ({ ...prev!, fee })); + })(); + + return () => { + BackButton?.offClick(callback); + }; + }, [BackButton, MainButton]); + + useEffect(() => { + const callback = () => { + setSelectedAsset((prev) => ({ ...prev!, transferAll, amount })); + router.push(Paths.TRANSFER_CONFIRMATION); + }; + + // commented validation for the test purpose + // TODO: uncomment it + + // if (!isAmountValid || !Number(amount)) return; + MainButton?.enable(); + MainButton?.onClick(callback); + + return () => { + MainButton?.offClick(callback); + }; + }, [amount, isAmountValid, MainButton]); + + const handleMaxSend = () => { + if (maxAmountToSend === undefined) return; + setTransferAll(true); + setAmount(String(maxAmountToSend)); + setIsAmountValid(Boolean(maxAmountToSend)); + }; + + const handleChange = (value: string) => { + setTransferAll(false); + setIsAmountValid(!!Number(value) && Number(value) <= (maxAmountToSend || 0)); + setAmount(value); + }; + + return ( +
+
+ + + Send to + + + + {selectedAsset?.destination} + + + + + +
+
+ + {selectedAsset?.symbol} + +
+
+ ); +} From c5627a8e148f30c90b2927cc4f9de6bd9bbb3c33 Mon Sep 17 00:00:00 2001 From: sokolova-an Date: Thu, 21 Dec 2023 13:27:43 +0100 Subject: [PATCH 2/6] typo --- src/pages/transfer/amount.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/transfer/amount.tsx b/src/pages/transfer/amount.tsx index 077bcf65..4c157b11 100644 --- a/src/pages/transfer/amount.tsx +++ b/src/pages/transfer/amount.tsx @@ -3,7 +3,7 @@ import { ExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; import { ChainRegistry } from '@/common/chainRegistry'; import AmountPage from '@/screens/transfer/amount/amount'; -// TODO: swithch to Layout pattern +// TODO: switch to Layout pattern export default function Amount() { return ( From b09bc361bce3c65b940429a229c356edd0128fad Mon Sep 17 00:00:00 2001 From: sokolova-an Date: Thu, 21 Dec 2023 14:48:36 +0100 Subject: [PATCH 3/6] balance --- src/screens/transfer/amount/amount.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/screens/transfer/amount/amount.tsx b/src/screens/transfer/amount/amount.tsx index 8716bec2..3d04a461 100644 --- a/src/screens/transfer/amount/amount.tsx +++ b/src/screens/transfer/amount/amount.tsx @@ -11,7 +11,7 @@ import { Paths } from '@/common/routing'; import { HeadlineText, Icon, Identicon, CaptionText, LargeTitleText, TextBase } from '@/components'; import { IconNames } from '@/components/Icon/types'; import { useExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; -import { handleFee } from '@/common/utils/balance'; +import { formatBalance, handleFee } from '@/common/utils/balance'; export default function AmountPage() { const router = useRouter(); @@ -42,9 +42,12 @@ export default function AmountPage() { const fee = selectedAsset.fee || (await handleFee(estimateFee, selectedAsset.chainId, selectedAsset.address, selectedAsset.precision)); + const formattedBalance = Number( + formatBalance(selectedAsset.transferableBalance, selectedAsset.precision).formattedValue, + ); // should we round it more? - const max = Math.max(Number(selectedAsset.transferableBalance) - fee, 0); + const max = Math.max(formattedBalance - fee, 0); setMaxAmountToSend(max); setSelectedAsset((prev) => ({ ...prev!, fee })); })(); From be68806377441bb38df467b09ce8fb6333c9882e Mon Sep 17 00:00:00 2001 From: sokolova-an Date: Fri, 22 Dec 2023 00:17:34 +0100 Subject: [PATCH 4/6] add extrinsic functions --- .../extrinsicService/ExtrinsicProvider.tsx | 29 +++-- src/common/extrinsicService/types/index.ts | 8 +- src/common/utils/address.ts | 4 +- src/common/utils/balance.ts | 63 +++++++---- src/common/wallet/store.ts | 21 ++-- src/pages/transfer/confirmation.tsx | 99 ++--------------- src/screens/transfer/amount/amount.tsx | 13 +-- .../transfer/confirmation/confirmation.tsx | 104 ++++++++++++++++++ 8 files changed, 191 insertions(+), 150 deletions(-) create mode 100644 src/screens/transfer/confirmation/confirmation.tsx diff --git a/src/common/extrinsicService/ExtrinsicProvider.tsx b/src/common/extrinsicService/ExtrinsicProvider.tsx index 3ef05b68..9b4ec091 100644 --- a/src/common/extrinsicService/ExtrinsicProvider.tsx +++ b/src/common/extrinsicService/ExtrinsicProvider.tsx @@ -1,19 +1,18 @@ import { createContext, PropsWithChildren, useContext } from 'react'; import { ChainId } from '@common/types'; -import { EstimateFee, ExtrinsicBuilding, ExtrinsicBuildingOptions } from '@common/extrinsicService/types'; -import { Balance } from '@polkadot/types/interfaces'; -import { SubmittableResultResult } from '@polkadot/api-base/types/submittable'; +import { + EstimateFee, + ExtrinsicBuilding, + ExtrinsicBuildingOptions, + SubmitExtrinsic, +} from '@common/extrinsicService/types'; +import { Balance, Hash } from '@polkadot/types/interfaces'; import { useExtrinsicService } from '@common/extrinsicService/ExtrinsicService'; -import { KeyringPair } from '@polkadot/keyring/types'; +import { getKeyringPair } from '../wallet'; type ExtrinsicProviderContextProps = { estimateFee: EstimateFee; - - submitExtrinsic: ( - chainId: ChainId, - building: ExtrinsicBuilding, - options?: Partial, - ) => SubmittableResultResult<'promise'>; + submitExtrinsic: SubmitExtrinsic; }; const ExtrinsicProviderContext = createContext({} as ExtrinsicProviderContextProps); @@ -34,17 +33,17 @@ export const ExtrinsicProvider = ({ children }: PropsWithChildren) => { return paymentInfo.partialFee; } - function submitExtrinsic( + async function submitExtrinsic( chainId: ChainId, building: ExtrinsicBuilding, options?: Partial, - ): SubmittableResultResult<`promise`> { + ): Promise { const extrinsicPromise = prepareExtrinsic<'promise'>(chainId, building, options); - const keyringPromise = new Promise(function () {}); - return extrinsicPromise.then(async (extrinsic) => { - const keyringPair = await keyringPromise; + const keyringPair = getKeyringPair(); + + if (!keyringPair) return; await extrinsic.signAsync(keyringPair); keyringPair.lock(); diff --git a/src/common/extrinsicService/types/index.ts b/src/common/extrinsicService/types/index.ts index b3c09a5b..003b6459 100644 --- a/src/common/extrinsicService/types/index.ts +++ b/src/common/extrinsicService/types/index.ts @@ -1,7 +1,7 @@ import { ApiPromise } from '@polkadot/api'; import { SubmittableExtrinsic } from '@polkadot/api-base/types'; import { ChainId } from '@common/types'; -import { Balance } from '@polkadot/types/interfaces'; +import { Balance, Hash } from '@polkadot/types/interfaces'; export interface ExtrinsicBuilder { api: ApiPromise; @@ -37,3 +37,9 @@ export type EstimateFee = ( building: ExtrinsicBuilding, options?: Partial, ) => Promise; + +export type SubmitExtrinsic = ( + chainId: ChainId, + building: ExtrinsicBuilding, + options?: Partial, +) => Promise; diff --git a/src/common/utils/address.ts b/src/common/utils/address.ts index 35b9d8b3..3e79a059 100644 --- a/src/common/utils/address.ts +++ b/src/common/utils/address.ts @@ -3,7 +3,7 @@ import { base58Decode, checkAddressChecksum } from '@polkadot/util-crypto'; import { AccountId, Address } from '../types'; const ADDRESS_ALLOWED_ENCODED_LENGTHS = [35, 36, 37, 38]; -const PUBLIC_KEY_LENGTH_BYTES = 32; +const ACCOUNT_ID_LENGTH = 32; /** * Check is account's address valid @@ -14,7 +14,7 @@ export const validateAddress = (address?: Address | AccountId): boolean => { if (!address) return false; if (isU8a(address) || isHex(address)) { - return u8aToU8a(address).length === PUBLIC_KEY_LENGTH_BYTES; + return u8aToU8a(address).length === ACCOUNT_ID_LENGTH; } try { diff --git a/src/common/utils/balance.ts b/src/common/utils/balance.ts index 83a4a5bc..86a01409 100644 --- a/src/common/utils/balance.ts +++ b/src/common/utils/balance.ts @@ -1,9 +1,11 @@ import BigNumber from 'bignumber.js'; import { decodeAddress } from '@polkadot/keyring'; -import { Address, AssetAccount, ChainId } from '../types'; +import { BN, BN_TEN } from '@polkadot/util'; + +import { AssetAccount, ChainId, TrasferAsset } from '../types'; import { Chain } from '../chainRegistry/types'; import { IAssetBalance } from '../balances/types'; -import { EstimateFee, ExtrinsicBuilder } from '../extrinsicService/types'; +import { EstimateFee, ExtrinsicBuilder, SubmitExtrinsic } from '../extrinsicService/types'; import { Balance } from '@polkadot/types/interfaces'; const ZERO_BALANCE = '0'; @@ -141,28 +143,47 @@ export const updateAssetsBalance = (prevAssets: AssetAccount[], chain: Chain, ba ); }; -// async function handleSign() { -// extrinsicService -// .submitExtrinsic(polkadot.chainId, (builder) => builder.addCall(builder.api.tx.system.remark('Hello'))) -// .then( -// (hash) => { -// alert('Success: ' + hash); -// }, -// (failure) => { -// alert('Failed: ' + failure); -// }, -// ); -// } +export const formatAmount = (amount: string, precision: number): string => { + if (!amount) return ZERO_BALANCE; + + const isDecimalValue = amount.match(/^(\d+)\.(\d+)$/); + const bnPrecision = new BN(precision); + if (isDecimalValue) { + const div = new BN(amount.replace(/\.\d*$/, '')); + const modString = amount.replace(/^\d+\./, '').slice(0, precision); + const mod = new BN(modString); + + return div + .mul(BN_TEN.pow(bnPrecision)) + .add(mod.mul(BN_TEN.pow(new BN(precision - modString.length)))) + .toString(); + } + + return new BN(amount.replace(/\D/g, '')).mul(BN_TEN.pow(bnPrecision)).toString(); +}; + +export async function handleSend( + submitExtrinsic: SubmitExtrinsic, + { destination, chainId, amount, transferAll, precision }: TrasferAsset, +) { + const decodedAddress = decodeAddress(destination); + + return await submitExtrinsic(chainId, (builder) => { + const transferFunction = transferAll + ? builder.api.tx.balances.transferAll(decodedAddress, false) + : builder.api.tx.balances.transferKeepAlive(decodedAddress, formatAmount(amount as string, precision)); + + builder.addCall(transferFunction); + }).then((hash) => { + console.log('Success, Hash:', hash?.toString()); + }); +} const FAKE_AMMOUNT = '1'; -export async function handleFee( - estimateFee: EstimateFee, - chainId: ChainId, - address: Address, - precision: number, -): Promise { +const FAKE_ACCOUNT = '5FHhXAVs62Xx18YhnYFzU2pQ2wN2h41UBMZRJ88sSJxkesVb'; +export async function handleFee(estimateFee: EstimateFee, chainId: ChainId, precision: number): Promise { return await estimateFee(chainId, (builder: ExtrinsicBuilder) => - builder.addCall(builder.api.tx.balances.transferKeepAlive(decodeAddress(address), FAKE_AMMOUNT)), + builder.addCall(builder.api.tx.balances.transferKeepAlive(decodeAddress(FAKE_ACCOUNT), FAKE_AMMOUNT)), ).then((fee: Balance) => { const { formattedValue } = formatBalance(fee.toString(), precision); diff --git a/src/common/wallet/store.ts b/src/common/wallet/store.ts index 8fe333f7..d788930d 100644 --- a/src/common/wallet/store.ts +++ b/src/common/wallet/store.ts @@ -44,16 +44,8 @@ export const backupMnemonic = (mnemonic: string, password: string): void => { window.Telegram.WebApp.CloudStorage.setItem(MNEMONIC_STORE, encryptedMnemonic); }; -export const getMnemonic = (password: string): string | null => { - const encryptedMnemonic = secureLocalStorage.getItem(MNEMONIC_STORE); - - if (encryptedMnemonic) { - const mnemonic = AES.decrypt(encryptedMnemonic, password); - - return mnemonic.toString(CryptoJS.enc.Utf8); - } else { - return null; - } +export const getMnemonic = (): string | null => { + return (secureLocalStorage.getItem(MNEMONIC_STORE) as string) || null; }; /** @@ -61,14 +53,15 @@ export const getMnemonic = (password: string): string | null => { * Make sure to call lock() after pair was used to clean up secret! * @param password */ -export const getKeyringPair = (password: string): KeyringPair | undefined => { +export const getKeyringPair = (): KeyringPair | undefined => { try { - const mnemonic = getMnemonic(password); - if (mnemonic === null) return undefined; + const mnemonic = getMnemonic(); + + if (mnemonic === null) return; return keyring.createFromUri(mnemonic); } catch (e) { - return undefined; + console.warn(e); } }; diff --git a/src/pages/transfer/confirmation.tsx b/src/pages/transfer/confirmation.tsx index a1cd0efe..d54bda6a 100644 --- a/src/pages/transfer/confirmation.tsx +++ b/src/pages/transfer/confirmation.tsx @@ -1,94 +1,15 @@ 'use client'; -import { useEffect } from 'react'; -import { useRouter } from 'next/router'; -// @ts-expect-error no types -import MiddleEllipsis from 'react-middle-ellipsis'; -import { Divider } from '@nextui-org/react'; - -import { useTelegram } from '@common/providers/telegramProvider'; -import { useGlobalContext } from '@/common/providers/contextProvider'; -import { Paths } from '@/common/routing'; -import { HeadlineText, Icon, Identicon, LargeTitleText, TextBase, Plate, BodyText, CaptionText } from '@/components'; -import { IconNames } from '@/components/Icon/types'; - -export default function ConfirmationPage() { - const router = useRouter(); - const { BackButton, MainButton } = useTelegram(); - const { selectedAsset } = useGlobalContext(); - - useEffect(() => { - const mainCallback = () => { - router.push(Paths.TRANSFER_RESULT); - }; - const backCallback = () => { - router.push(Paths.TRANSFER_AMOUNT); - }; - - BackButton?.show(); - BackButton?.onClick(backCallback); - - MainButton?.show(); - MainButton?.setText('Confirm'); - MainButton?.onClick(mainCallback); - - return () => { - MainButton?.offClick(mainCallback); - BackButton?.offClick(backCallback); - }; - }, []); - - const details = [ - { - title: 'Recipients address', - value: selectedAsset?.destination, - }, - { - title: 'Fee', - value: `${selectedAsset?.fee} ${selectedAsset?.symbol}`, - }, - { - title: 'Total amount', - value: `${selectedAsset?.amount} ${selectedAsset?.symbol}`, - }, - { - title: 'Network', - value: selectedAsset?.name, - }, - ]; +import { ExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; +import { ChainRegistry } from '@/common/chainRegistry'; +import ConfirmationPage from '@/screens/transfer/confirmation/confirmation'; +// TODO: switch to Layout pattern +export default function Confirmation() { return ( -
-
- - - Send to - - - - {selectedAsset?.destination} - - - - -
-
- - {selectedAsset?.symbol} - {selectedAsset?.amount} -
- - {details.map(({ title, value }, index) => ( - <> - {index !== 0 && } -
- - {title} - - {value} -
- - ))} -
-
+ + + + + ); } diff --git a/src/screens/transfer/amount/amount.tsx b/src/screens/transfer/amount/amount.tsx index 3d04a461..0acc3c73 100644 --- a/src/screens/transfer/amount/amount.tsx +++ b/src/screens/transfer/amount/amount.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/router'; // @ts-expect-error no types import MiddleEllipsis from 'react-middle-ellipsis'; -import { Button, Input } from '@nextui-org/react'; +import { Button, CircularProgress, Input } from '@nextui-org/react'; import { useTelegram } from '@common/providers/telegramProvider'; import { useGlobalContext } from '@/common/providers/contextProvider'; @@ -39,15 +39,12 @@ export default function AmountPage() { if (!selectedAsset) return; (async () => { - const fee = - selectedAsset.fee || - (await handleFee(estimateFee, selectedAsset.chainId, selectedAsset.address, selectedAsset.precision)); + const fee = selectedAsset.fee || (await handleFee(estimateFee, selectedAsset.chainId, selectedAsset.precision)); const formattedBalance = Number( formatBalance(selectedAsset.transferableBalance, selectedAsset.precision).formattedValue, ); - // should we round it more? - const max = Math.max(formattedBalance - fee, 0); + const max = Number(Math.max(formattedBalance - fee, 0).toFixed(5)); setMaxAmountToSend(max); setSelectedAsset((prev) => ({ ...prev!, fee })); })(); @@ -102,9 +99,9 @@ export default function AmountPage() { - diff --git a/src/screens/transfer/confirmation/confirmation.tsx b/src/screens/transfer/confirmation/confirmation.tsx new file mode 100644 index 00000000..45ff32b1 --- /dev/null +++ b/src/screens/transfer/confirmation/confirmation.tsx @@ -0,0 +1,104 @@ +'use client'; +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; +// @ts-expect-error no types +import MiddleEllipsis from 'react-middle-ellipsis'; +import { Divider } from '@nextui-org/react'; + +import { useTelegram } from '@common/providers/telegramProvider'; +import { useGlobalContext } from '@/common/providers/contextProvider'; +import { Paths } from '@/common/routing'; +import { HeadlineText, Icon, Identicon, LargeTitleText, TextBase, Plate, BodyText, CaptionText } from '@/components'; +import { IconNames } from '@/components/Icon/types'; +import { useExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; +import { handleSend } from '@/common/utils/balance'; + +export default function ConfirmationPage() { + const router = useRouter(); + const { submitExtrinsic } = useExtrinsicProvider(); + + const { BackButton, MainButton } = useTelegram(); + const { selectedAsset } = useGlobalContext(); + + useEffect(() => { + if (!selectedAsset) return; + + const mainCallback = async () => { + MainButton?.showProgress(false); + await handleSend(submitExtrinsic, selectedAsset).then(() => { + router.push(Paths.TRANSFER_RESULT); + }); + }; + const backCallback = () => { + router.push(Paths.TRANSFER_AMOUNT); + }; + + BackButton?.show(); + BackButton?.onClick(backCallback); + + MainButton?.show(); + MainButton?.setText('Confirm'); + MainButton?.onClick(mainCallback); + + return () => { + MainButton?.hideProgress(); + MainButton?.offClick(mainCallback); + BackButton?.offClick(backCallback); + }; + }, [selectedAsset]); + + const details = [ + { + title: 'Recipients address', + value: selectedAsset?.destination, + }, + { + title: 'Fee', + value: `${selectedAsset?.fee} ${selectedAsset?.symbol}`, + }, + { + title: 'Total amount', + value: `${(Number(selectedAsset?.amount) + (selectedAsset?.fee as number)).toFixed(5)} ${selectedAsset?.symbol}`, + }, + { + title: 'Network', + value: selectedAsset?.name, + }, + ]; + + return ( +
+
+ + + Send to + + + + {selectedAsset?.destination} + + + + +
+
+ + {selectedAsset?.symbol} + {selectedAsset?.amount} +
+ + {details.map(({ title, value }, index) => ( +
+ {index !== 0 && } +
+ + {title} + + {value} +
+
+ ))} +
+
+ ); +} From 3c9af4c542e8966add07888ca82b2cc8c96f573a Mon Sep 17 00:00:00 2001 From: sokolova-an Date: Fri, 22 Dec 2023 12:53:04 +0100 Subject: [PATCH 5/6] add layout --- .../extrinsicService/ExtrinsicProvider.tsx | 17 +-- src/common/providers/telegramProvider.tsx | 4 +- src/common/utils/balance.ts | 5 +- src/common/utils/constants.ts | 1 + src/components/Layout/layout.tsx | 13 ++ src/components/index.ts | 2 + src/pages/_app.tsx | 20 ++- src/pages/transfer/amount.tsx | 130 ++++++++++++++++-- src/pages/transfer/confirmation.tsx | 123 +++++++++++++++-- src/screens/transfer/amount/amount.tsx | 124 ----------------- .../transfer/confirmation/confirmation.tsx | 104 -------------- 11 files changed, 274 insertions(+), 269 deletions(-) create mode 100644 src/components/Layout/layout.tsx delete mode 100644 src/screens/transfer/amount/amount.tsx delete mode 100644 src/screens/transfer/confirmation/confirmation.tsx diff --git a/src/common/extrinsicService/ExtrinsicProvider.tsx b/src/common/extrinsicService/ExtrinsicProvider.tsx index 9b4ec091..5ce5cb45 100644 --- a/src/common/extrinsicService/ExtrinsicProvider.tsx +++ b/src/common/extrinsicService/ExtrinsicProvider.tsx @@ -9,6 +9,7 @@ import { import { Balance, Hash } from '@polkadot/types/interfaces'; import { useExtrinsicService } from '@common/extrinsicService/ExtrinsicService'; import { getKeyringPair } from '../wallet'; +import { FAKE_ACCOUNT_ID } from '../utils/constants'; type ExtrinsicProviderContextProps = { estimateFee: EstimateFee; @@ -17,8 +18,6 @@ type ExtrinsicProviderContextProps = { const ExtrinsicProviderContext = createContext({} as ExtrinsicProviderContextProps); -export const FAKE_ACCOUNT_ID = '0x' + '1'.repeat(64); - export const ExtrinsicProvider = ({ children }: PropsWithChildren) => { const { prepareExtrinsic } = useExtrinsicService(); @@ -38,17 +37,15 @@ export const ExtrinsicProvider = ({ children }: PropsWithChildren) => { building: ExtrinsicBuilding, options?: Partial, ): Promise { - const extrinsicPromise = prepareExtrinsic<'promise'>(chainId, building, options); + const extrinsic = await prepareExtrinsic<'promise'>(chainId, building, options); - return extrinsicPromise.then(async (extrinsic) => { - const keyringPair = getKeyringPair(); + const keyringPair = getKeyringPair(); + if (!keyringPair) return; - if (!keyringPair) return; - await extrinsic.signAsync(keyringPair); - keyringPair.lock(); + await extrinsic.signAsync(keyringPair); + keyringPair.lock(); - return await extrinsic.send(); - }); + return await extrinsic.send(); } return ( diff --git a/src/common/providers/telegramProvider.tsx b/src/common/providers/telegramProvider.tsx index 208d021c..fafd8075 100644 --- a/src/common/providers/telegramProvider.tsx +++ b/src/common/providers/telegramProvider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import React, { PropsWithChildren, createContext, useContext, useEffect, useMemo, useState } from 'react'; import Script from 'next/script'; import { WebApp, WebAppUser, MainButton, BackButton } from '@twa-dev/types'; @@ -11,7 +11,7 @@ export interface ITelegramContext { export const TelegramContext = createContext({}); -export const TelegramProvider = ({ children }: { children: React.ReactNode }) => { +export const TelegramProvider = ({ children }: PropsWithChildren) => { const [webApp, setWebApp] = useState(null); useEffect(() => { diff --git a/src/common/utils/balance.ts b/src/common/utils/balance.ts index 86a01409..6d66a3df 100644 --- a/src/common/utils/balance.ts +++ b/src/common/utils/balance.ts @@ -7,6 +7,7 @@ import { Chain } from '../chainRegistry/types'; import { IAssetBalance } from '../balances/types'; import { EstimateFee, ExtrinsicBuilder, SubmitExtrinsic } from '../extrinsicService/types'; import { Balance } from '@polkadot/types/interfaces'; +import { FAKE_ACCOUNT_ID } from './constants'; const ZERO_BALANCE = '0'; @@ -180,10 +181,10 @@ export async function handleSend( } const FAKE_AMMOUNT = '1'; -const FAKE_ACCOUNT = '5FHhXAVs62Xx18YhnYFzU2pQ2wN2h41UBMZRJ88sSJxkesVb'; + export async function handleFee(estimateFee: EstimateFee, chainId: ChainId, precision: number): Promise { return await estimateFee(chainId, (builder: ExtrinsicBuilder) => - builder.addCall(builder.api.tx.balances.transferKeepAlive(decodeAddress(FAKE_ACCOUNT), FAKE_AMMOUNT)), + builder.addCall(builder.api.tx.balances.transferKeepAlive(decodeAddress(FAKE_ACCOUNT_ID), FAKE_AMMOUNT)), ).then((fee: Balance) => { const { formattedValue } = formatBalance(fee.toString(), precision); diff --git a/src/common/utils/constants.ts b/src/common/utils/constants.ts index b76b0e29..2a1ba5c5 100644 --- a/src/common/utils/constants.ts +++ b/src/common/utils/constants.ts @@ -1,2 +1,3 @@ export const PUBLIC_KEY_STORE = 'publicKey'; export const MNEMONIC_STORE = 'mnemonic'; +export const FAKE_ACCOUNT_ID = '0x' + '1'.repeat(64); diff --git a/src/components/Layout/layout.tsx b/src/components/Layout/layout.tsx new file mode 100644 index 00000000..6345974f --- /dev/null +++ b/src/components/Layout/layout.tsx @@ -0,0 +1,13 @@ +import { PropsWithChildren } from 'react'; +import { ExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; +import { ChainRegistry } from '@/common/chainRegistry'; + +export default function Layout({ children }: PropsWithChildren) { + return ( + + +
{children}
+
+
+ ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 59e100c8..96198dec 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -17,6 +17,7 @@ import Plate from './Plate/Plate'; import PasswordForm from './PasswordForm/PasswordForm'; import Shimmering from './Shimmering/Shimmering'; import Identicon from './Identicon/Identicon'; +import Layout from './Layout/layout'; export { TextBase, @@ -37,4 +38,5 @@ export { PasswordForm, Shimmering, Identicon, + Layout, }; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index f2c2146f..b5c9534e 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,22 +1,28 @@ import { AppProps } from 'next/app'; -import { useEffect, useState } from 'react'; +import { ReactElement, ReactNode, useEffect, useState } from 'react'; import { NextUIProvider } from '@nextui-org/react'; import { TelegramProvider } from '@common/providers/telegramProvider'; import { GlobalStateProvider } from '@/common/providers/contextProvider'; import './globals.css'; +import { NextPage } from 'next'; -const App = ({ Component, pageProps }: AppProps) => { +export type NextPageWithLayout

= NextPage & { + getLayout?: (page: ReactElement) => ReactNode; +}; + +type AppPropsWithLayout = AppProps & { + Component: NextPageWithLayout; +}; + +const App = ({ Component, pageProps }: AppPropsWithLayout) => { const [render, setRender] = useState(false); useEffect(() => setRender(true), []); + const getLayout = Component.getLayout || ((page) => page); return ( - {render && ( - - - - )} + {render && {getLayout()} } ); diff --git a/src/pages/transfer/amount.tsx b/src/pages/transfer/amount.tsx index 4c157b11..d70aff00 100644 --- a/src/pages/transfer/amount.tsx +++ b/src/pages/transfer/amount.tsx @@ -1,15 +1,125 @@ 'use client'; -import { ExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; -import { ChainRegistry } from '@/common/chainRegistry'; -import AmountPage from '@/screens/transfer/amount/amount'; +import { ReactElement, useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +// @ts-expect-error no types +import MiddleEllipsis from 'react-middle-ellipsis'; +import { Button, CircularProgress, Input } from '@nextui-org/react'; + +import { useTelegram } from '@common/providers/telegramProvider'; +import { useGlobalContext } from '@/common/providers/contextProvider'; +import { Paths } from '@/common/routing'; +import { HeadlineText, Icon, Identicon, CaptionText, LargeTitleText, TextBase, Layout } from '@/components'; +import { IconNames } from '@/components/Icon/types'; +import { useExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; +import { formatBalance, handleFee } from '@/common/utils/balance'; + +export default function AmountPage() { + const router = useRouter(); + const { estimateFee } = useExtrinsicProvider(); + const { BackButton, MainButton } = useTelegram(); + const { selectedAsset, setSelectedAsset } = useGlobalContext(); + + const [amount, setAmount] = useState('0'); + const [transferAll, setTransferAll] = useState(false); + const [maxAmountToSend, setMaxAmountToSend] = useState(); + const [isAmountValid, setIsAmountValid] = useState(true); + + useEffect(() => { + router.prefetch(Paths.TRANSFER_CONFIRMATION); + MainButton?.setText('Continue'); + BackButton?.show(); + MainButton?.show(); + MainButton?.disable(); + + const callback = () => { + router.push(Paths.TRANSFER_ADDRESS); + }; + BackButton?.onClick(callback); + + if (!selectedAsset) return; + + (async () => { + const fee = selectedAsset.fee || (await handleFee(estimateFee, selectedAsset.chainId, selectedAsset.precision)); + const formattedBalance = Number( + formatBalance(selectedAsset.transferableBalance, selectedAsset.precision).formattedValue, + ); + + const max = Number(Math.max(formattedBalance - fee, 0).toFixed(5)); + setMaxAmountToSend(max); + setSelectedAsset((prev) => ({ ...prev!, fee })); + })(); + + return () => { + BackButton?.offClick(callback); + }; + }, [BackButton, MainButton]); + + useEffect(() => { + const callback = () => { + setSelectedAsset((prev) => ({ ...prev!, transferAll, amount })); + router.push(Paths.TRANSFER_CONFIRMATION); + }; + + if (!isAmountValid || !Number(amount)) return; + MainButton?.enable(); + MainButton?.onClick(callback); + + return () => { + MainButton?.offClick(callback); + }; + }, [amount, isAmountValid, maxAmountToSend]); + + const handleMaxSend = () => { + if (maxAmountToSend === undefined) return; + setTransferAll(true); + setAmount(String(maxAmountToSend)); + setIsAmountValid(Boolean(maxAmountToSend)); + }; + + const handleChange = (value: string) => { + setTransferAll(false); + setIsAmountValid(!!Number(value) && Number(value) <= (maxAmountToSend || 0)); + setAmount(value); + }; -// TODO: switch to Layout pattern -export default function Amount() { return ( - - - - - + <> +

+ + + Send to + + + + {selectedAsset?.destination} + + + + + +
+
+ + {selectedAsset?.symbol} + +
+ ); } + +AmountPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/src/pages/transfer/confirmation.tsx b/src/pages/transfer/confirmation.tsx index d54bda6a..bb91a8c5 100644 --- a/src/pages/transfer/confirmation.tsx +++ b/src/pages/transfer/confirmation.tsx @@ -1,15 +1,118 @@ 'use client'; -import { ExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; -import { ChainRegistry } from '@/common/chainRegistry'; -import ConfirmationPage from '@/screens/transfer/confirmation/confirmation'; +import { ReactElement, useEffect } from 'react'; +import { useRouter } from 'next/router'; +// @ts-expect-error no types +import MiddleEllipsis from 'react-middle-ellipsis'; +import { Divider } from '@nextui-org/react'; + +import { useTelegram } from '@common/providers/telegramProvider'; +import { useGlobalContext } from '@/common/providers/contextProvider'; +import { Paths } from '@/common/routing'; +import { + HeadlineText, + Icon, + Identicon, + LargeTitleText, + TextBase, + Plate, + BodyText, + CaptionText, + Layout, +} from '@/components'; +import { IconNames } from '@/components/Icon/types'; +import { useExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; +import { handleSend } from '@/common/utils/balance'; + +export default function ConfirmationPage() { + const router = useRouter(); + const { submitExtrinsic } = useExtrinsicProvider(); + + const { BackButton, MainButton } = useTelegram(); + const { selectedAsset } = useGlobalContext(); + + useEffect(() => { + if (!selectedAsset) return; + + const mainCallback = async () => { + MainButton?.showProgress(false); + await handleSend(submitExtrinsic, selectedAsset).then(() => { + router.push(Paths.TRANSFER_RESULT); + }); + }; + const backCallback = () => { + router.push(Paths.TRANSFER_AMOUNT); + }; + + BackButton?.show(); + BackButton?.onClick(backCallback); + + MainButton?.show(); + MainButton?.setText('Confirm'); + MainButton?.onClick(mainCallback); + + return () => { + MainButton?.hideProgress(); + MainButton?.offClick(mainCallback); + BackButton?.offClick(backCallback); + }; + }, [selectedAsset]); + + const details = [ + { + title: 'Recipients address', + value: selectedAsset?.destination, + }, + { + title: 'Fee', + value: `${selectedAsset?.fee} ${selectedAsset?.symbol}`, + }, + { + title: 'Total amount', + value: `${(Number(selectedAsset?.amount) + (selectedAsset?.fee as number)).toFixed(5)} ${selectedAsset?.symbol}`, + }, + { + title: 'Network', + value: selectedAsset?.name, + }, + ]; -// TODO: switch to Layout pattern -export default function Confirmation() { return ( - - - - - + <> +
+ + + Send to + + + + {selectedAsset?.destination} + + + + +
+
+ + {selectedAsset?.symbol} + {selectedAsset?.amount} +
+ + {details.map(({ title, value }, index) => ( +
+ {index !== 0 && } +
+ + {title} + + {value} +
+
+ ))} +
+ ); } + +ConfirmationPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/src/screens/transfer/amount/amount.tsx b/src/screens/transfer/amount/amount.tsx deleted file mode 100644 index 0acc3c73..00000000 --- a/src/screens/transfer/amount/amount.tsx +++ /dev/null @@ -1,124 +0,0 @@ -'use client'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/router'; -// @ts-expect-error no types -import MiddleEllipsis from 'react-middle-ellipsis'; -import { Button, CircularProgress, Input } from '@nextui-org/react'; - -import { useTelegram } from '@common/providers/telegramProvider'; -import { useGlobalContext } from '@/common/providers/contextProvider'; -import { Paths } from '@/common/routing'; -import { HeadlineText, Icon, Identicon, CaptionText, LargeTitleText, TextBase } from '@/components'; -import { IconNames } from '@/components/Icon/types'; -import { useExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; -import { formatBalance, handleFee } from '@/common/utils/balance'; - -export default function AmountPage() { - const router = useRouter(); - const { estimateFee } = useExtrinsicProvider(); - const { BackButton, MainButton } = useTelegram(); - const { selectedAsset, setSelectedAsset } = useGlobalContext(); - - const [amount, setAmount] = useState('0'); - const [transferAll, setTransferAll] = useState(false); - const [maxAmountToSend, setMaxAmountToSend] = useState(); - const [isAmountValid, setIsAmountValid] = useState(true); - - useEffect(() => { - router.prefetch(Paths.TRANSFER_CONFIRMATION); - MainButton?.setText('Continue'); - BackButton?.show(); - MainButton?.show(); - MainButton?.disable(); - - const callback = () => { - router.push(Paths.TRANSFER_ADDRESS); - }; - BackButton?.onClick(callback); - - if (!selectedAsset) return; - - (async () => { - const fee = selectedAsset.fee || (await handleFee(estimateFee, selectedAsset.chainId, selectedAsset.precision)); - const formattedBalance = Number( - formatBalance(selectedAsset.transferableBalance, selectedAsset.precision).formattedValue, - ); - - const max = Number(Math.max(formattedBalance - fee, 0).toFixed(5)); - setMaxAmountToSend(max); - setSelectedAsset((prev) => ({ ...prev!, fee })); - })(); - - return () => { - BackButton?.offClick(callback); - }; - }, [BackButton, MainButton]); - - useEffect(() => { - const callback = () => { - setSelectedAsset((prev) => ({ ...prev!, transferAll, amount })); - router.push(Paths.TRANSFER_CONFIRMATION); - }; - - // commented validation for the test purpose - // TODO: uncomment it - - // if (!isAmountValid || !Number(amount)) return; - MainButton?.enable(); - MainButton?.onClick(callback); - - return () => { - MainButton?.offClick(callback); - }; - }, [amount, isAmountValid, MainButton]); - - const handleMaxSend = () => { - if (maxAmountToSend === undefined) return; - setTransferAll(true); - setAmount(String(maxAmountToSend)); - setIsAmountValid(Boolean(maxAmountToSend)); - }; - - const handleChange = (value: string) => { - setTransferAll(false); - setIsAmountValid(!!Number(value) && Number(value) <= (maxAmountToSend || 0)); - setAmount(value); - }; - - return ( -
-
- - - Send to - - - - {selectedAsset?.destination} - - - - - -
-
- - {selectedAsset?.symbol} - -
-
- ); -} diff --git a/src/screens/transfer/confirmation/confirmation.tsx b/src/screens/transfer/confirmation/confirmation.tsx deleted file mode 100644 index 45ff32b1..00000000 --- a/src/screens/transfer/confirmation/confirmation.tsx +++ /dev/null @@ -1,104 +0,0 @@ -'use client'; -import { useEffect } from 'react'; -import { useRouter } from 'next/router'; -// @ts-expect-error no types -import MiddleEllipsis from 'react-middle-ellipsis'; -import { Divider } from '@nextui-org/react'; - -import { useTelegram } from '@common/providers/telegramProvider'; -import { useGlobalContext } from '@/common/providers/contextProvider'; -import { Paths } from '@/common/routing'; -import { HeadlineText, Icon, Identicon, LargeTitleText, TextBase, Plate, BodyText, CaptionText } from '@/components'; -import { IconNames } from '@/components/Icon/types'; -import { useExtrinsicProvider } from '@/common/extrinsicService/ExtrinsicProvider'; -import { handleSend } from '@/common/utils/balance'; - -export default function ConfirmationPage() { - const router = useRouter(); - const { submitExtrinsic } = useExtrinsicProvider(); - - const { BackButton, MainButton } = useTelegram(); - const { selectedAsset } = useGlobalContext(); - - useEffect(() => { - if (!selectedAsset) return; - - const mainCallback = async () => { - MainButton?.showProgress(false); - await handleSend(submitExtrinsic, selectedAsset).then(() => { - router.push(Paths.TRANSFER_RESULT); - }); - }; - const backCallback = () => { - router.push(Paths.TRANSFER_AMOUNT); - }; - - BackButton?.show(); - BackButton?.onClick(backCallback); - - MainButton?.show(); - MainButton?.setText('Confirm'); - MainButton?.onClick(mainCallback); - - return () => { - MainButton?.hideProgress(); - MainButton?.offClick(mainCallback); - BackButton?.offClick(backCallback); - }; - }, [selectedAsset]); - - const details = [ - { - title: 'Recipients address', - value: selectedAsset?.destination, - }, - { - title: 'Fee', - value: `${selectedAsset?.fee} ${selectedAsset?.symbol}`, - }, - { - title: 'Total amount', - value: `${(Number(selectedAsset?.amount) + (selectedAsset?.fee as number)).toFixed(5)} ${selectedAsset?.symbol}`, - }, - { - title: 'Network', - value: selectedAsset?.name, - }, - ]; - - return ( -
-
- - - Send to - - - - {selectedAsset?.destination} - - - - -
-
- - {selectedAsset?.symbol} - {selectedAsset?.amount} -
- - {details.map(({ title, value }, index) => ( -
- {index !== 0 && } -
- - {title} - - {value} -
-
- ))} -
-
- ); -} From 630f5f4a44c040a806eef7c9cda560c8f9463c88 Mon Sep 17 00:00:00 2001 From: sokolova-an Date: Fri, 22 Dec 2023 13:17:12 +0100 Subject: [PATCH 6/6] add address copy --- src/common/types/index.ts | 2 +- src/common/utils/balance.ts | 4 ++-- src/components/Assets/AssetsList.tsx | 10 +++++++++- src/pages/transfer/address.tsx | 2 +- src/pages/transfer/amount.tsx | 4 ++-- src/pages/transfer/confirmation.tsx | 6 +++--- src/pages/transfer/result.tsx | 2 +- src/screens/dashboard/main/DashboardMain.tsx | 6 ------ 8 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/common/types/index.ts b/src/common/types/index.ts index 283cffbc..c0d86329 100644 --- a/src/common/types/index.ts +++ b/src/common/types/index.ts @@ -33,7 +33,7 @@ export type AssetAccount = ChainAssetAccount & { }; export type TrasferAsset = AssetAccount & { - destination?: string; + destinationAddress?: string; amount?: string; fee?: number; transferAll?: boolean; diff --git a/src/common/utils/balance.ts b/src/common/utils/balance.ts index 6d66a3df..0bca504e 100644 --- a/src/common/utils/balance.ts +++ b/src/common/utils/balance.ts @@ -165,9 +165,9 @@ export const formatAmount = (amount: string, precision: number): string => { export async function handleSend( submitExtrinsic: SubmitExtrinsic, - { destination, chainId, amount, transferAll, precision }: TrasferAsset, + { destinationAddress, chainId, amount, transferAll, precision }: TrasferAsset, ) { - const decodedAddress = decodeAddress(destination); + const decodedAddress = decodeAddress(destinationAddress); return await submitExtrinsic(chainId, (builder) => { const transferFunction = transferAll diff --git a/src/components/Assets/AssetsList.tsx b/src/components/Assets/AssetsList.tsx index 84f28822..2dd1f35e 100644 --- a/src/components/Assets/AssetsList.tsx +++ b/src/components/Assets/AssetsList.tsx @@ -1,14 +1,22 @@ import { useGlobalContext } from '@/common/providers/contextProvider'; import AssetBalance from './AssetBalance'; +import { Button } from '@nextui-org/react'; const AssetsList = () => { const { assets } = useGlobalContext(); + // TODO: delete it later + const copyAddress = (address: string) => { + navigator.clipboard.writeText(address); + }; + return (
{assets.map((asset) => ( - + ))}
); diff --git a/src/pages/transfer/address.tsx b/src/pages/transfer/address.tsx index 2f624a34..96310789 100644 --- a/src/pages/transfer/address.tsx +++ b/src/pages/transfer/address.tsx @@ -32,7 +32,7 @@ export default function AddressPage() { useEffect(() => { const callback = () => { - setSelectedAsset((prev) => ({ ...prev!, destination: address })); + setSelectedAsset((prev) => ({ ...prev!, destinationAddress: address })); router.push(Paths.TRANSFER_AMOUNT); }; diff --git a/src/pages/transfer/amount.tsx b/src/pages/transfer/amount.tsx index d70aff00..b890149c 100644 --- a/src/pages/transfer/amount.tsx +++ b/src/pages/transfer/amount.tsx @@ -85,13 +85,13 @@ export default function AmountPage() { return ( <>
- + Send to - {selectedAsset?.destination} + {selectedAsset?.destinationAddress} diff --git a/src/pages/transfer/confirmation.tsx b/src/pages/transfer/confirmation.tsx index bb91a8c5..7b899f19 100644 --- a/src/pages/transfer/confirmation.tsx +++ b/src/pages/transfer/confirmation.tsx @@ -60,7 +60,7 @@ export default function ConfirmationPage() { const details = [ { title: 'Recipients address', - value: selectedAsset?.destination, + value: selectedAsset?.destinationAddress, }, { title: 'Fee', @@ -79,13 +79,13 @@ export default function ConfirmationPage() { return ( <>
- + Send to - {selectedAsset?.destination} + {selectedAsset?.destinationAddress} diff --git a/src/pages/transfer/result.tsx b/src/pages/transfer/result.tsx index e8b00d56..258f7c60 100644 --- a/src/pages/transfer/result.tsx +++ b/src/pages/transfer/result.tsx @@ -35,7 +35,7 @@ export default function ResultPage() { {selectedAsset?.amount} {selectedAsset?.symbol} Sent to - {selectedAsset?.destination} + {selectedAsset?.destinationAddress} Your transaction has been sent to the network and will be processed in a few seconds. diff --git a/src/screens/dashboard/main/DashboardMain.tsx b/src/screens/dashboard/main/DashboardMain.tsx index 83f27dfe..ee2111dc 100644 --- a/src/screens/dashboard/main/DashboardMain.tsx +++ b/src/screens/dashboard/main/DashboardMain.tsx @@ -87,12 +87,6 @@ export const DashboardMain = () => { - {/* - */}
); };