import sortBy from 'lodash/sortBy'; import { maxSafe, required, vegaPublicKey, addDecimal, formatNumber, addDecimalsFormatNumber, toBigNum, } from '@vegaprotocol/utils'; import { useT } from './use-t'; import { TradingFormGroup, TradingInput, TradingInputError, TradingRichSelect, TradingSelect, Tooltip, TradingCheckbox, TradingButton, } from '@vegaprotocol/ui-toolkit'; import type { Transfer } from '@vegaprotocol/wallet'; import { normalizeTransfer } from '@vegaprotocol/wallet'; import BigNumber from 'bignumber.js'; import type { ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { AssetOption, Balance } from '@vegaprotocol/assets'; import { AccountType, AccountTypeMapping } from '@vegaprotocol/types'; interface FormFields { toVegaKey: string; asset: string; // This is used to simply filter the from account list, the fromAccount type should be used in the tx amount: string; fromAccount: string; // AccountType-AssetId } interface Asset { id: string; symbol: string; name: string; decimals: number; quantum: string; } export interface TransferFormProps { pubKey: string | null; pubKeys: string[] | null; accounts: Array<{ type: AccountType; balance: string; asset: Asset; }>; assetId?: string; feeFactor: string | null; minQuantumMultiple: string | null; submitTransfer: (transfer: Transfer) => void; } export const TransferForm = ({ pubKey, pubKeys, assetId: initialAssetId, feeFactor, submitTransfer, accounts, minQuantumMultiple, }: TransferFormProps) => { const t = useT(); const { control, register, watch, handleSubmit, setValue, formState: { errors }, } = useForm({ defaultValues: { asset: initialAssetId, toVegaKey: pubKey || '', }, }); const [toVegaKeyMode, setToVegaKeyMode] = useState('select'); const assets = sortBy( accounts .filter( (a) => a.type === AccountType.ACCOUNT_TYPE_GENERAL || a.type === AccountType.ACCOUNT_TYPE_VESTED_REWARDS ) // Sum the general and vested account balances so the value shown in the asset // dropdown is correct for all transferable accounts .reduce((merged, account) => { const existing = merged.findIndex( (m) => m.asset.id === account.asset.id ); if (existing > -1) { const balance = new BigNumber(merged[existing].balance) .plus(new BigNumber(account.balance)) .toString(); merged[existing] = { ...merged[existing], balance }; return merged; } return [...merged, account]; }, [] as typeof accounts) .map((account) => ({ key: account.asset.id, ...account.asset, balance: addDecimal(account.balance, account.asset.decimals), })), (a) => a.symbol.toLowerCase() ); const selectedPubKey = watch('toVegaKey'); const amount = watch('amount'); const fromAccount = watch('fromAccount'); const selectedAssetId = watch('asset'); // Convert the account type (Type-AssetId) into separate values const [accountType, accountAssetId] = fromAccount ? parseFromAccount(fromAccount) : [undefined, undefined]; const fromVested = accountType === AccountType.ACCOUNT_TYPE_VESTED_REWARDS; const asset = assets.find((a) => a.id === accountAssetId); const account = accounts.find( (a) => a.asset.id === accountAssetId && a.type === accountType ); const accountBalance = account && addDecimal(account.balance, account.asset.decimals); const [includeFee, setIncludeFee] = useState(false); // Max amount given selected asset and from account const max = accountBalance ? new BigNumber(accountBalance) : new BigNumber(0); const transferAmount = useMemo(() => { if (!amount) return undefined; if (includeFee && feeFactor) { return new BigNumber(1).minus(feeFactor).times(amount).toString(); } return amount; }, [amount, includeFee, feeFactor]); const fee = useMemo(() => { if (!transferAmount) return undefined; if (includeFee) { return new BigNumber(amount).minus(transferAmount).toString(); } return ( feeFactor && new BigNumber(feeFactor).times(transferAmount).toString() ); }, [amount, includeFee, transferAmount, feeFactor]); const onSubmit = useCallback( (fields: FormFields) => { if (!transferAmount) { throw new Error('Submitted transfer with no amount selected'); } const [type, assetId] = parseFromAccount(fields.fromAccount); const asset = assets.find((a) => a.id === assetId); if (!asset) { throw new Error('Submitted transfer with no asset selected'); } const transfer = normalizeTransfer( fields.toVegaKey, transferAmount, type, AccountType.ACCOUNT_TYPE_GENERAL, // field is readonly in the form { id: asset.id, decimals: asset.decimals, } ); submitTransfer(transfer); }, [submitTransfer, transferAmount, assets] ); // reset for placeholder workaround https://github.com/radix-ui/primitives/issues/1569 useEffect(() => { if (!pubKey) { setValue('asset', ''); } }, [setValue, pubKey]); return (
( { field.onChange(value); setValue('fromAccount', ''); }} placeholder={t('Please select an asset')} value={field.value} > {assets.map((a) => ( } /> ))} )} /> {errors.asset?.message && ( {errors.asset.message} )} { if ( pubKey === selectedPubKey && value === AccountType.ACCOUNT_TYPE_GENERAL ) { return t( 'Cannot transfer to the same account type for the connected key' ); } return true; }, }, }} render={({ field }) => ( { field.onChange(e); const [type] = parseFromAccount(e.target.value); // Enforce that if transferring from a vested rewards account it must go to // the current connected general account if ( type === AccountType.ACCOUNT_TYPE_VESTED_REWARDS && pubKey ) { setValue('toVegaKey', pubKey); setToVegaKeyMode('select'); setIncludeFee(false); } }} > {accounts .filter((a) => { if (!selectedAssetId) return true; return selectedAssetId === a.asset.id; }) .map((a) => { const id = `${a.type}-${a.asset.id}`; return ( ); })} )} /> {errors.fromAccount?.message && ( {errors.fromAccount.message} )} { setValue('toVegaKey', ''); setToVegaKeyMode((curr) => (curr === 'input' ? 'select' : 'input')); }} mode={toVegaKeyMode} select={ {pubKeys?.map((pk) => { const text = pk === pubKey ? t('Current key: {{pubKey}}', { pubKey: pk }) + pk : pk; return ( ); })} } input={ fromVested ? null : ( ) } /> {errors.toVegaKey?.message && ( {errors.toVegaKey.message} )} {asset.symbol} } {...register('amount', { validate: { required, minSafe: (v) => { if (!asset || !minQuantumMultiple) return true; const value = new BigNumber(v); if (value.isZero()) { return t('Amount cannot be 0'); } const minByQuantumMultiple = toBigNum( minQuantumMultiple, asset.decimals ); if (fromVested) { // special conditions which let you bypass min transfer rules set by quantum multiple if (value.isGreaterThanOrEqualTo(max)) { return true; } if (value.isLessThan(minByQuantumMultiple)) { return t( 'Amount below minimum requirements for partial transfer. Use max to bypass' ); } return true; } else { if (value.isLessThan(minByQuantumMultiple)) { return t( 'Amount below minimum requirement set by transfer.minTransferQuantumMultiple' ); } } return true; }, maxSafe: (v) => { const value = new BigNumber(v); if (value.isGreaterThan(max)) { return t('You cannot transfer more than available'); } return maxSafe(max)(v); }, }, })} /> {accountBalance && ( )} {errors.amount?.message && ( {errors.amount.message} )}
setIncludeFee((x) => !x)} />
{transferAmount && fee && ( )} {t('Confirm transfer')} ); }; export const TransferFee = ({ amount, transferAmount, feeFactor, fee, decimals, }: { amount: string; transferAmount: string; feeFactor: string | null; fee?: string; decimals?: number; }) => { const t = useT(); if (!feeFactor || !amount || !transferAmount || !fee) return null; if ( isNaN(Number(feeFactor)) || isNaN(Number(amount)) || isNaN(Number(transferAmount)) || isNaN(Number(fee)) ) { return null; } const totalValue = new BigNumber(transferAmount).plus(fee).toString(); return (
{t('Transfer fee')}
{formatNumber(fee, decimals)}
{t('Amount to be transferred')}
{formatNumber(amount, decimals)}
{t('Total amount (with fee)')}
{formatNumber(totalValue, decimals)}
); }; type ToVegaKeyMode = 'input' | 'select'; interface AddressInputProps { select: ReactNode; input: ReactNode; mode: ToVegaKeyMode; onChange: () => void; } export const AddressField = ({ select, input, mode, onChange, }: AddressInputProps) => { const t = useT(); const isInput = mode === 'input'; return ( <> {isInput ? input : select} {select && input && ( )} ); }; const parseFromAccount = (fromAccountStr: string) => { return fromAccountStr.split('-') as [AccountType, string]; };