fix(#806): dissasociation without vega wallet (#1075)

* fix: dissasociation without vega wallet (806)

* fix: removed unused import

* chore: removed redundant func prepend0xIfNeeded

* Update apps/token/src/routes/staking/disassociate/disassociate-page.tsx

Co-authored-by: candida-d <62548908+candida-d@users.noreply.github.com>

* Update apps/token/src/routes/staking/disassociate/disassociate-page.tsx

Co-authored-by: candida-d <62548908+candida-d@users.noreply.github.com>

Co-authored-by: candida-d <62548908+candida-d@users.noreply.github.com>
This commit is contained in:
Art 2022-08-24 00:09:08 +02:00 committed by GitHub
parent aea2a85519
commit db08a177c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 238 additions and 234 deletions

View File

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
export enum StakingMethod { export enum StakingMethod {
Contract = 'Contract', Contract = 'Contract',
Wallet = 'Wallet', Wallet = 'Wallet',
Unknown = 'Unknown',
} }
export const StakingMethodRadio = ({ export const StakingMethodRadio = ({

View File

@ -3,25 +3,8 @@ import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useAnimateValue } from '../../hooks/use-animate-value'; import { useAnimateValue } from '../../hooks/use-animate-value';
import { BigNumber } from '../../lib/bignumber'; import type { BigNumber } from '../../lib/bignumber';
import { formatNumber } from '../../lib/format-number'; import { useNumberParts } from '../../lib/format-number';
const useNumberParts = (
value: BigNumber | null | undefined,
decimals: number
) => {
return React.useMemo(() => {
if (!value) {
return ['0', '0'.repeat(decimals)];
}
// @ts-ignore confident not undefined
const separator = BigNumber.config().FORMAT.decimalSeparator as string;
const [integers, decimalsPlaces] = formatNumber(value, 18)
.toString()
.split(separator);
return [integers, decimalsPlaces];
}, [decimals, value]);
};
interface WalletCardProps { interface WalletCardProps {
children: React.ReactNode; children: React.ReactNode;

View File

@ -0,0 +1,33 @@
import { BigNumber } from './bignumber';
import {
formatNumber,
formatNumberPercentage,
toNumberParts,
} from './format-number';
describe('formatNumber and formatNumberPercentage', () => {
it.each([
{ v: new BigNumber(123), d: 3, o: '123.000' },
{ v: new BigNumber(123.123), d: 3, o: '123.123' },
{ v: new BigNumber(123.123), d: 6, o: '123.123000' },
{ v: new BigNumber(123.123), d: 0, o: '123' },
{ v: new BigNumber(123), d: undefined, o: '123.00' }, // it default to 2 decimal places
])('formats given number correctly', ({ v, d, o }) => {
expect(formatNumber(v, d)).toStrictEqual(o);
expect(formatNumberPercentage(v, d)).toStrictEqual(`${o}%`);
});
});
describe('toNumberParts', () => {
it.each([
{ v: null, d: 3, o: ['0', '000'] },
{ v: undefined, d: 3, o: ['0', '000'] },
{ v: new BigNumber(123), d: 3, o: ['123', '000'] },
{ v: new BigNumber(123.123), d: 3, o: ['123', '123'] },
{ v: new BigNumber(123.123), d: 6, o: ['123', '123000'] },
{ v: new BigNumber(123.123), d: 0, o: ['123', ''] },
{ v: new BigNumber(123), d: undefined, o: ['123', '000000000000000000'] },
])('returns correct tuple given the different arguments', ({ v, d, o }) => {
expect(toNumberParts(v, d)).toStrictEqual(o);
});
});

View File

@ -1,4 +1,5 @@
import type { BigNumber } from './bignumber'; import React from 'react';
import { BigNumber } from './bignumber';
export const formatNumber = (value: BigNumber, decimals?: number) => { export const formatNumber = (value: BigNumber, decimals?: number) => {
const decimalPlaces = const decimalPlaces =
@ -6,8 +7,27 @@ export const formatNumber = (value: BigNumber, decimals?: number) => {
return value.dp(decimalPlaces).toFormat(decimalPlaces); return value.dp(decimalPlaces).toFormat(decimalPlaces);
}; };
export const formatNumberPercentage = (value: BigNumber, decimals?: number) => { export const formatNumberPercentage = (value: BigNumber, decimals?: number) =>
const decimalPlaces = `${formatNumber(value, decimals)}%`;
typeof decimals === 'undefined' ? Math.max(value.dp(), 2) : decimals;
return `${value.dp(decimalPlaces).toFormat(decimalPlaces)}%`; export const toNumberParts = (
value: BigNumber | null | undefined,
decimals = 18
): [integers: string, decimalPlaces: string] => {
if (!value) {
return ['0', '0'.repeat(decimals)];
}
// @ts-ignore confident not undefined
const separator = BigNumber.config().FORMAT.decimalSeparator as string;
const [integers, decimalsPlaces] = formatNumber(value, decimals)
.toString()
.split(separator);
return [integers, decimalsPlaces || ''];
};
export const useNumberParts = (
value: BigNumber | null | undefined,
decimals: number
): [integers: string, decimalPlaces: string] => {
return React.useMemo(() => toNumberParts(value, decimals), [decimals, value]);
}; };

View File

@ -0,0 +1,15 @@
import { truncateMiddle } from './truncate-middle';
describe('truncateMiddle', () => {
it.each([
{ i: '1234567890134567890', o: '123456\u20267890' },
{ i: '12345678901', o: '123456\u20268901' },
{ i: '1234567890', o: '1234567890' },
{ i: '123456', o: '123456' },
])(
'truncates the middle section of any long string (address)',
({ i, o }) => {
expect(truncateMiddle(i)).toStrictEqual(o);
}
);
});

View File

@ -1,4 +1,5 @@
export function truncateMiddle(address: string) { export function truncateMiddle(address: string) {
if (address.length < 11) return address;
return ( return (
address.slice(0, 6) + address.slice(0, 6) +
'\u2026' + '\u2026' +

View File

@ -1,41 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { TokenInput } from '../../../components/token-input';
import { useAppState } from '../../../contexts/app-state/app-state-context';
export const ContractDisassociate = ({
perform,
amount,
setAmount,
}: {
perform: () => void;
amount: string;
setAmount: React.Dispatch<React.SetStateAction<string>>;
}) => {
const {
appState: { lien },
} = useAppState();
const { t } = useTranslation();
if (lien.isEqualTo('0')) {
return (
<div className="disassociate-page__error">
{t(
'You have no VEGA tokens currently staked through your connected Eth wallet.'
)}
</div>
);
}
return (
<TokenInput
submitText={t('Disassociate VEGA Tokens from key')}
perform={perform}
maximum={lien}
amount={amount}
setAmount={setAmount}
currency={t('VEGA Tokens')}
/>
);
};

View File

@ -1,17 +1,12 @@
import { StakingWalletsContainer } from '../staking-wallets-container'; import { StakingWalletsContainer } from '../staking-wallets-container';
import { DisassociatePage } from './disassociate-page'; import { DisassociatePage } from './disassociate-page';
import { DisassociatePageNoVega } from './disassociate-page-no-vega';
export const DisassociateContainer = () => { export const DisassociateContainer = () => {
return ( return (
<StakingWalletsContainer needsEthereum={true} needsVega={false}> <StakingWalletsContainer needsEthereum={true} needsVega={false}>
{({ address, currVegaKey = null }) => {({ address, currVegaKey = null }) => (
currVegaKey ? ( <DisassociatePage address={address} vegaKey={currVegaKey?.pub ?? ''} />
<DisassociatePage address={address} vegaKey={currVegaKey} /> )}
) : (
<DisassociatePageNoVega />
)
}
</StakingWalletsContainer> </StakingWalletsContainer>
); );
}; };

View File

@ -1,53 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
StakingMethod,
StakingMethodRadio,
} from '../../../components/staking-method-radio';
import { useSearchParams } from '../../../hooks/use-search-params';
import { ConnectToVega } from '../connect-to-vega';
import { ContractDisassociate } from './contract-disassociate';
export const DisassociatePageNoVega = () => {
const { t } = useTranslation();
const params = useSearchParams();
const [amount, setAmount] = React.useState<string>('');
const [selectedStakingMethod, setSelectedStakingMethod] =
React.useState<StakingMethod | null>(
(params.method as StakingMethod) || null
);
return (
<section className="disassociate-page" data-testid="disassociate-page">
<p>
{t(
'Use this form to disassociate VEGA tokens with a Vega key. This returns them to either the Ethereum wallet that used the Staking bridge or the vesting contract.'
)}
</p>
<p>
<span className="disassociate-page__error">{t('Warning')}:</span>{' '}
{t(
'Any Tokens that have been nominated to a node will sacrifice any Rewards they are due for the current epoch. If you do not wish to sacrifices fees you should remove stake from a node at the end of an epoch before disassocation.'
)}
</p>
<h2>{t('What Vega wallet are you removing Tokens from?')}</h2>
<ConnectToVega />
<h2>{t('What tokens would you like to return?')}</h2>
<StakingMethodRadio
setSelectedStakingMethod={setSelectedStakingMethod}
selectedStakingMethod={selectedStakingMethod}
/>
{selectedStakingMethod &&
(selectedStakingMethod === StakingMethod.Wallet ? (
<ConnectToVega />
) : (
<ContractDisassociate
setAmount={setAmount}
amount={amount}
perform={() => undefined}
/>
))}
</section>
);
};

View File

@ -1,65 +1,147 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ConnectedVegaKey } from '../../../components/connected-vega-key';
import {
StakingMethod,
StakingMethodRadio,
} from '../../../components/staking-method-radio';
import { TxState } from '../../../hooks/transaction-reducer';
import { useSearchParams } from '../../../hooks/use-search-params';
import { useRefreshAssociatedBalances } from '../../../hooks/use-refresh-associated-balances';
import { ContractDisassociate } from './contract-disassociate';
import { DisassociateTransaction } from './disassociate-transaction'; import { DisassociateTransaction } from './disassociate-transaction';
import { formatNumber } from '../../../lib/format-number';
import { remove0x, toBigNum } from '@vegaprotocol/react-helpers';
import { Select } from '@vegaprotocol/ui-toolkit';
import { StakingMethod } from '../../../components/staking-method-radio';
import { TokenInput } from '../../../components/token-input';
import { TxState } from '../../../hooks/transaction-reducer';
import { useAppState } from '../../../contexts/app-state/app-state-context';
import { useRefreshAssociatedBalances } from '../../../hooks/use-refresh-associated-balances';
import { useRemoveStake } from './hooks'; import { useRemoveStake } from './hooks';
import { WalletDisassociate } from './wallet-disassociate'; import type { RemoveStakePayload } from './hooks';
import type { VegaKeyExtended } from '@vegaprotocol/wallet'; import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { BigNumber } from '../../../lib/bignumber';
type Association = {
key: string;
value: BigNumber;
stakingMethod: StakingMethod;
};
const toListOfAssociations = (
obj: { [vegaKey: string]: BigNumber },
stakingMethod: StakingMethod
): Association[] =>
Object.keys(obj)
.map((k) => ({
key: remove0x(k),
value: obj[k],
stakingMethod,
}))
.filter((k) => k.value.isGreaterThan(0));
export const DisassociatePage = ({ export const DisassociatePage = ({
address, address,
vegaKey, vegaKey,
}: { }: {
address: string; address: string;
vegaKey: VegaKeyExtended; vegaKey: string;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const params = useSearchParams();
const [amount, setAmount] = React.useState<string>(''); const {
const [selectedStakingMethod, setSelectedStakingMethod] = appState: {
React.useState<StakingMethod | null>( associationBreakdown: { stakingAssociations, vestingAssociations },
(params.method as StakingMethod) || null },
); } = useAppState();
const associations = useMemo(
() => [
...toListOfAssociations(stakingAssociations, StakingMethod.Wallet),
...toListOfAssociations(vestingAssociations, StakingMethod.Contract),
],
[stakingAssociations, vestingAssociations]
);
useEffect(() => {
setChosen(associations.find((k) => k.key === vegaKey) || associations[0]);
}, [associations, vegaKey]);
const [chosen, setChosen] = useState<Association>();
const maximum = chosen?.value || toBigNum(0, 0);
const [amount, setAmount] = useState<string>('');
const refreshBalances = useRefreshAssociatedBalances(); const refreshBalances = useRefreshAssociatedBalances();
// Clear the amount when the staking method changes const payload: RemoveStakePayload = {
React.useEffect(() => { amount,
setAmount(''); vegaKey: chosen?.key || '',
}, [selectedStakingMethod]); stakingMethod: chosen?.stakingMethod || StakingMethod.Unknown,
};
const { const {
state: txState, state: txState,
dispatch: txDispatch, dispatch: txDispatch,
perform: txPerform, perform: txPerform,
} = useRemoveStake(address, amount, vegaKey.pub, selectedStakingMethod); } = useRemoveStake(address, payload);
React.useEffect(() => { useEffect(() => {
if (txState.txState === TxState.Complete) { if (txState.txState === TxState.Complete) {
refreshBalances(address, vegaKey.pub); refreshBalances(address, chosen?.key || '');
} }
}, [txState, refreshBalances, address, vegaKey.pub]); }, [txState, refreshBalances, address, chosen]);
if (txState.txState !== TxState.Default) { if (txState.txState !== TxState.Default && payload) {
return ( return (
<DisassociateTransaction <DisassociateTransaction
state={txState} state={txState}
amount={amount} amount={amount}
vegaKey={vegaKey.pub} vegaKey={chosen?.key || ''}
stakingMethod={selectedStakingMethod as StakingMethod} stakingMethod={payload.stakingMethod}
dispatch={txDispatch} dispatch={txDispatch}
/> />
); );
} }
const noKeysMessage = (
<div className="disassociate-page__error">
{t(
'You have no VEGA tokens currently associated through your connected Ethereum wallet.'
)}
</div>
);
const disassociate = (
<>
<div className="pb-8">
<Select
className="font-mono"
disabled={associations.length === 1}
id="vega-key-selector"
onChange={(e) => {
if (!e.target.value) return;
const chosen = associations.find((k) => k.key === e.target.value);
if (chosen) {
setChosen(chosen);
setAmount('');
}
}}
value={chosen?.key}
>
{associations.map((k) => (
<option
key={k.key}
value={k.key}
title={`${t(k.stakingMethod)}: ${formatNumber(k.value, 18)}`}
>
{k.key}
</option>
))}
</Select>
</div>
<TokenInput
submitText={t('Disassociate VEGA Tokens from key')}
perform={txPerform}
maximum={maximum}
amount={amount}
setAmount={setAmount}
currency={t('VEGA Tokens')}
/>
</>
);
return ( return (
<section className="disassociate-page" data-testid="disassociate-page"> <section className="disassociate-page" data-testid="disassociate-page">
<p> <p>
@ -70,30 +152,12 @@ export const DisassociatePage = ({
<p> <p>
<span className="text-vega-red">{t('Warning')}:</span>{' '} <span className="text-vega-red">{t('Warning')}:</span>{' '}
{t( {t(
'Any Tokens that have been nominated to a node will sacrifice any Rewards they are due for the current epoch. If you do not wish to sacrifices fees you should remove stake from a node at the end of an epoch before disassocation.' 'Any tokens that have been nominated to a node will sacrifice rewards they are due for the current epoch. If you do not wish to sacrifice these, you should remove stake from a node at the end of an epoch before disassociation.'
)} )}
</p> </p>
<h2>{t('What Vega wallet are you removing Tokens from?')}</h2>
<ConnectedVegaKey pubKey={vegaKey.pub} />
<h2>{t('What tokens would you like to return?')}</h2> <h2>{t('What tokens would you like to return?')}</h2>
<StakingMethodRadio {associations.length === 0 ? noKeysMessage : disassociate}
setSelectedStakingMethod={setSelectedStakingMethod}
selectedStakingMethod={selectedStakingMethod}
/>
{selectedStakingMethod &&
(selectedStakingMethod === StakingMethod.Wallet ? (
<WalletDisassociate
setAmount={setAmount}
amount={amount}
perform={txPerform}
/>
) : (
<ContractDisassociate
setAmount={setAmount}
amount={amount}
perform={txPerform}
/>
))}
</section> </section>
); );
}; };

View File

@ -8,12 +8,24 @@ import { TxState } from '../../../hooks/transaction-reducer';
import { useGetAssociationBreakdown } from '../../../hooks/use-get-association-breakdown'; import { useGetAssociationBreakdown } from '../../../hooks/use-get-association-breakdown';
import { useRefreshBalances } from '../../../hooks/use-refresh-balances'; import { useRefreshBalances } from '../../../hooks/use-refresh-balances';
import { useTransaction } from '../../../hooks/use-transaction'; import { useTransaction } from '../../../hooks/use-transaction';
import { initialState } from '../../../hooks/transaction-reducer';
export type RemoveStakePayload = {
amount: string;
vegaKey: string;
stakingMethod: StakingMethod;
};
const EMPTY_REMOVE = {
state: initialState,
dispatch: () => undefined,
perform: () => undefined as void,
reset: () => undefined as void,
};
export const useRemoveStake = ( export const useRemoveStake = (
address: string, address: string,
amount: string, payload: RemoveStakePayload
vegaKey: string,
stakingMethod: StakingMethod | null
) => { ) => {
const { appState } = useAppState(); const { appState } = useAppState();
const { staking, vesting } = useContracts(); const { staking, vesting } = useContracts();
@ -21,11 +33,18 @@ export const useRemoveStake = (
// which if staked > wallet balance means you cannot unstaked // which if staked > wallet balance means you cannot unstaked
// even worse if you stake everything then you can't unstake anything! // even worse if you stake everything then you can't unstake anything!
const contractRemove = useTransaction(() => const contractRemove = useTransaction(() =>
vesting.remove_stake(removeDecimal(amount, appState.decimals), vegaKey) vesting.remove_stake(
removeDecimal(payload.amount, appState.decimals),
payload.vegaKey
)
); );
const walletRemove = useTransaction(() => const walletRemove = useTransaction(() =>
staking.remove_stake(removeDecimal(amount, appState.decimals), vegaKey) staking.remove_stake(
removeDecimal(payload.amount, appState.decimals),
payload.vegaKey
)
); );
const refreshBalances = useRefreshBalances(address); const refreshBalances = useRefreshBalances(address);
const getAssociationBreakdown = useGetAssociationBreakdown( const getAssociationBreakdown = useGetAssociationBreakdown(
address, address,
@ -49,10 +68,13 @@ export const useRemoveStake = (
]); ]);
return React.useMemo(() => { return React.useMemo(() => {
if (stakingMethod === StakingMethod.Contract) { switch (payload.stakingMethod) {
return contractRemove; case StakingMethod.Contract:
} else { return contractRemove;
return walletRemove; case StakingMethod.Wallet:
return walletRemove;
default:
return EMPTY_REMOVE;
} }
}, [contractRemove, stakingMethod, walletRemove]); }, [contractRemove, payload, walletRemove]);
}; };

View File

@ -1,41 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { TokenInput } from '../../../components/token-input';
import { useAppState } from '../../../contexts/app-state/app-state-context';
export const WalletDisassociate = ({
perform,
amount,
setAmount,
}: {
perform: () => void;
amount: string;
setAmount: React.Dispatch<React.SetStateAction<string>>;
}) => {
const {
appState: { walletAssociatedBalance },
} = useAppState();
const { t } = useTranslation();
if (!walletAssociatedBalance || walletAssociatedBalance.isEqualTo('0')) {
return (
<div className="disassociate-page__error">
{t(
'You have no VEGA tokens currently staked through your connected Vega wallet.'
)}
</div>
);
}
return (
<TokenInput
submitText={t('Disassociate VEGA Tokens from key')}
perform={perform}
maximum={walletAssociatedBalance}
amount={amount}
setAmount={setAmount}
currency={t('VEGA Tokens')}
/>
);
};

View File

@ -1,6 +1,11 @@
import { prepend0x } from './prepend-0x'; import { prepend0x } from './prepend-0x';
test('Prepends strings with 0x', () => { describe('prepend0x', () => {
expect(prepend0x('abc')).toEqual('0xabc'); it.each([
expect(prepend0x('123456789')).toEqual('0x123456789'); { input: 'ABC123', output: '0xABC123' },
{ input: '0XABC123', output: '0x0XABC123' },
{ input: '0xABC123', output: '0xABC123' },
])('prepends strings with 0x only if needed', ({ input, output }) => {
expect(prepend0x(input)).toBe(output);
});
}); });

View File

@ -1,3 +1,3 @@
export function prepend0x(str: string) { export function prepend0x(str: string) {
return `0x${str}`; return !str || str.indexOf('0x') === 0 ? str : `0x${str}`;
} }