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 {
Contract = 'Contract',
Wallet = 'Wallet',
Unknown = 'Unknown',
}
export const StakingMethodRadio = ({

View File

@ -3,25 +3,8 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { useAnimateValue } from '../../hooks/use-animate-value';
import { BigNumber } from '../../lib/bignumber';
import { formatNumber } 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]);
};
import type { BigNumber } from '../../lib/bignumber';
import { useNumberParts } from '../../lib/format-number';
interface WalletCardProps {
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) => {
const decimalPlaces =
@ -6,8 +7,27 @@ export const formatNumber = (value: BigNumber, decimals?: number) => {
return value.dp(decimalPlaces).toFormat(decimalPlaces);
};
export const formatNumberPercentage = (value: BigNumber, decimals?: number) => {
const decimalPlaces =
typeof decimals === 'undefined' ? Math.max(value.dp(), 2) : decimals;
return `${value.dp(decimalPlaces).toFormat(decimalPlaces)}%`;
export const formatNumberPercentage = (value: BigNumber, decimals?: number) =>
`${formatNumber(value, decimals)}%`;
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) {
if (address.length < 11) return address;
return (
address.slice(0, 6) +
'\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 { DisassociatePage } from './disassociate-page';
import { DisassociatePageNoVega } from './disassociate-page-no-vega';
export const DisassociateContainer = () => {
return (
<StakingWalletsContainer needsEthereum={true} needsVega={false}>
{({ address, currVegaKey = null }) =>
currVegaKey ? (
<DisassociatePage address={address} vegaKey={currVegaKey} />
) : (
<DisassociatePageNoVega />
)
}
{({ address, currVegaKey = null }) => (
<DisassociatePage address={address} vegaKey={currVegaKey?.pub ?? ''} />
)}
</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 { 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 { WalletDisassociate } from './wallet-disassociate';
import type { VegaKeyExtended } from '@vegaprotocol/wallet';
import type { RemoveStakePayload } from './hooks';
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 = ({
address,
vegaKey,
}: {
address: string;
vegaKey: VegaKeyExtended;
vegaKey: string;
}) => {
const { t } = useTranslation();
const params = useSearchParams();
const [amount, setAmount] = React.useState<string>('');
const [selectedStakingMethod, setSelectedStakingMethod] =
React.useState<StakingMethod | null>(
(params.method as StakingMethod) || null
);
const {
appState: {
associationBreakdown: { stakingAssociations, vestingAssociations },
},
} = 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();
// Clear the amount when the staking method changes
React.useEffect(() => {
setAmount('');
}, [selectedStakingMethod]);
const payload: RemoveStakePayload = {
amount,
vegaKey: chosen?.key || '',
stakingMethod: chosen?.stakingMethod || StakingMethod.Unknown,
};
const {
state: txState,
dispatch: txDispatch,
perform: txPerform,
} = useRemoveStake(address, amount, vegaKey.pub, selectedStakingMethod);
} = useRemoveStake(address, payload);
React.useEffect(() => {
useEffect(() => {
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 (
<DisassociateTransaction
state={txState}
amount={amount}
vegaKey={vegaKey.pub}
stakingMethod={selectedStakingMethod as StakingMethod}
vegaKey={chosen?.key || ''}
stakingMethod={payload.stakingMethod}
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 (
<section className="disassociate-page" data-testid="disassociate-page">
<p>
@ -70,30 +152,12 @@ export const DisassociatePage = ({
<p>
<span className="text-vega-red">{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.'
'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>
<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>
<StakingMethodRadio
setSelectedStakingMethod={setSelectedStakingMethod}
selectedStakingMethod={selectedStakingMethod}
/>
{selectedStakingMethod &&
(selectedStakingMethod === StakingMethod.Wallet ? (
<WalletDisassociate
setAmount={setAmount}
amount={amount}
perform={txPerform}
/>
) : (
<ContractDisassociate
setAmount={setAmount}
amount={amount}
perform={txPerform}
/>
))}
{associations.length === 0 ? noKeysMessage : disassociate}
</section>
);
};

View File

@ -8,12 +8,24 @@ import { TxState } from '../../../hooks/transaction-reducer';
import { useGetAssociationBreakdown } from '../../../hooks/use-get-association-breakdown';
import { useRefreshBalances } from '../../../hooks/use-refresh-balances';
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 = (
address: string,
amount: string,
vegaKey: string,
stakingMethod: StakingMethod | null
payload: RemoveStakePayload
) => {
const { appState } = useAppState();
const { staking, vesting } = useContracts();
@ -21,11 +33,18 @@ export const useRemoveStake = (
// which if staked > wallet balance means you cannot unstaked
// even worse if you stake everything then you can't unstake anything!
const contractRemove = useTransaction(() =>
vesting.remove_stake(removeDecimal(amount, appState.decimals), vegaKey)
vesting.remove_stake(
removeDecimal(payload.amount, appState.decimals),
payload.vegaKey
)
);
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 getAssociationBreakdown = useGetAssociationBreakdown(
address,
@ -49,10 +68,13 @@ export const useRemoveStake = (
]);
return React.useMemo(() => {
if (stakingMethod === StakingMethod.Contract) {
return contractRemove;
} else {
return walletRemove;
switch (payload.stakingMethod) {
case StakingMethod.Contract:
return contractRemove;
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';
test('Prepends strings with 0x', () => {
expect(prepend0x('abc')).toEqual('0xabc');
expect(prepend0x('123456789')).toEqual('0x123456789');
describe('prepend0x', () => {
it.each([
{ 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) {
return `0x${str}`;
return !str || str.indexOf('0x') === 0 ? str : `0x${str}`;
}