Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: add transfer flow #26

Merged
merged 6 commits into from
Dec 22, 2023
Merged
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions src/common/extrinsicService/ExtrinsicProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<ExtrinsicBuildingOptions>,
) => Promise<Balance>;
estimateFee: EstimateFee;

submitExtrinsic: (
chainId: ChainId,
7 changes: 7 additions & 0 deletions src/common/extrinsicService/types/index.ts
Original file line number Diff line number Diff line change
@@ -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<ExtrinsicBuilder>;
}

export type EstimateFee = (
chainId: ChainId,
building: ExtrinsicBuilding,
options?: Partial<ExtrinsicBuildingOptions>,
) => Promise<Balance>;
7 changes: 4 additions & 3 deletions src/common/providers/contextProvider.tsx
Original file line number Diff line number Diff line change
@@ -5,14 +5,15 @@ import { getWallet } from '../wallet';
export interface IContext {
assets: AssetAccount[] | [];
publicKey?: HexString;
selectedAsset?: TrasferAsset;
selectedAsset?: TrasferAsset | null;
setPublicKey: React.Dispatch<React.SetStateAction<HexString | undefined>>;
setAssets: React.Dispatch<React.SetStateAction<AssetAccount[]>>;
setSelectedAsset: React.Dispatch<React.SetStateAction<TrasferAsset | undefined>>;
setSelectedAsset: React.Dispatch<React.SetStateAction<TrasferAsset | null>>;
}

export const GlobalContext = createContext<IContext>({
assets: [],
selectedAsset: null,
setPublicKey: () => {},
setAssets: () => {},
setSelectedAsset: () => {},
@@ -22,7 +23,7 @@ export const GlobalContext = createContext<IContext>({
export const GlobalStateProvider = ({ children }: { children: React.ReactNode }) => {
const [publicKey, setPublicKey] = useState<HexString>();
const [assets, setAssets] = useState<AssetAccount[]>([]);
const [selectedAsset, setSelectedAsset] = useState<AssetAccount>();
const [selectedAsset, setSelectedAsset] = useState<AssetAccount | null>(null);

useEffect(() => {
if (!publicKey) {
2 changes: 2 additions & 0 deletions src/common/routing/paths.ts
Original file line number Diff line number Diff line change
@@ -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];
3 changes: 3 additions & 0 deletions src/common/types/index.ts
Original file line number Diff line number Diff line change
@@ -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<T> = { resolve: (value: T) => void; reject: () => void };

30 changes: 30 additions & 0 deletions src/common/utils/address.ts
Original file line number Diff line number Diff line change
@@ -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;
sokolova-an marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you, please, clarify what result is?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a function from Spectr, in the end, this function will return whether the Address is valid or not


return isValid && Boolean(decoded.slice(ss58Length, endPos));
} catch (error) {
return false;
}
};
32 changes: 19 additions & 13 deletions src/common/utils/balance.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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);
});
}
9 changes: 6 additions & 3 deletions src/pages/onboarding/create-wallet/index.tsx
Original file line number Diff line number Diff line change
@@ -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);
};
}, []);

36 changes: 23 additions & 13 deletions src/pages/transfer/address.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
};
63 changes: 10 additions & 53 deletions src/pages/transfer/amount.tsx
Original file line number Diff line number Diff line change
@@ -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: switch to Layout pattern
export default function Amount() {
return (
<div className="min-h-screen p-4">
<div className="grid grid-cols-[40px,1fr,auto] items-center">
<Identicon address={selectedAsset?.destination} />
<HeadlineText className="flex gap-1">
Send to
<div className="w-[130px]">
<MiddleEllipsis>
<TextBase as="span" className="text-body-bold">
{selectedAsset?.destination}
</TextBase>
</MiddleEllipsis>
</div>
</HeadlineText>
<CaptionText className="text-text-link">
Max: {selectedAsset?.transferableBalance} {selectedAsset?.symbol}
</CaptionText>
</div>
<div className="my-6 grid grid-cols-[40px,1fr,auto] items-center gap-2">
<Icon name={selectedAsset?.symbol as IconNames} className="w-10 h-10" />
<LargeTitleText>{selectedAsset?.symbol}</LargeTitleText>
<Input
fullWidth={false}
variant="underlined"
classNames={{ input: ['text-right !text-large-title max-w-[150px]'] }}
value={amount}
onValueChange={setAmount}
/>
</div>
</div>
<ChainRegistry>
<ExtrinsicProvider>
<AmountPage />
</ExtrinsicProvider>
</ChainRegistry>
);
}
Loading