Skip to content

Commit

Permalink
feat(suite): add staking/unstaking instant amount forecasting
Browse files Browse the repository at this point in the history
  • Loading branch information
tomasklim authored and dev-pvl committed Oct 21, 2024
1 parent 0b4d00c commit 1807f1e
Show file tree
Hide file tree
Showing 8 changed files with 208 additions and 36 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useState } from 'react';
import styled from 'styled-components';
import { FiatValue, FormattedCryptoAmount, Translation } from 'src/components/suite';
import { Paragraph, Radio } from '@trezor/components';
Expand Down Expand Up @@ -61,16 +60,14 @@ const InputsWrapper = styled.div<{ $isShown: boolean }>`
display: ${({ $isShown }) => ($isShown ? 'block' : 'none')};
`;

type UnstakeOptions = 'all' | 'rewards' | 'other';

interface OptionsProps {
symbol: NetworkSymbol;
}

export const Options = ({ symbol }: OptionsProps) => {
const selectedAccount = useSelector(selectSelectedAccount);
const { unstakeOption, setUnstakeOption } = useUnstakeEthFormContext();

const [unstakeOption, setUnstakeOption] = useState<UnstakeOptions>('all');
const isRewardsSelected = unstakeOption === 'rewards';
const isAllSelected = unstakeOption === 'all';
const isOtherAmountSelected = unstakeOption === 'other';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import styled from 'styled-components';
import { Divider, Paragraph, Banner } from '@trezor/components';
import { spacingsPx } from '@trezor/theme';
import { Divider, Icon, Paragraph, Tooltip, Banner, Row, Column } from '@trezor/components';
import { spacings, spacingsPx } from '@trezor/theme';
import { Translation } from 'src/components/suite';
import { useSelector } from 'src/hooks/suite';
import { useUnstakeEthFormContext } from 'src/hooks/wallet/useUnstakeEthForm';
Expand All @@ -11,11 +11,8 @@ import { getUnstakingPeriodInDays } from 'src/utils/suite/stake';
import UnstakeFees from './Fees';
import { selectValidatorsQueueData } from '@suite-common/wallet-core';
import { getAccountEverstakeStakingPool } from '@suite-common/wallet-utils';

// eslint-disable-next-line local-rules/no-override-ds-component
const GreyP = styled(Paragraph)`
color: ${({ theme }) => theme.textSubdued};
`;
import { ApproximateEthAmount } from 'src/views/wallet/staking/components/EthStakingDashboard/components/ApproximateEthAmount';
import { BigNumber } from '@trezor/utils';

const DividerWrapper = styled.div`
& > div {
Expand All @@ -37,15 +34,6 @@ const WarningsWrapper = styled.div`
gap: ${spacingsPx.md};
`;

const UpToDaysWrapper = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
padding: ${spacingsPx.lg} 0 ${spacingsPx.md};
border-top: 1px solid ${({ theme }) => theme.borderElevation2};
`;

export const UnstakeEthForm = () => {
const selectedAccount = useSelector(selectSelectedAccount);

Expand All @@ -54,6 +42,7 @@ export const UnstakeEthForm = () => {
formState: { errors },
handleSubmit,
signTx,
approximatedInstantEthAmount,
} = useUnstakeEthFormContext();

const { symbol } = account;
Expand All @@ -62,12 +51,13 @@ export const UnstakeEthForm = () => {
selectValidatorsQueueData(state, account?.symbol),
);
const unstakingPeriod = getUnstakingPeriodInDays(validatorWithdrawTime);

const { canClaim = false, claimableAmount = '0' } =
getAccountEverstakeStakingPool(selectedAccount) ?? {};

const inputError = errors[CRYPTO_INPUT] || errors[FIAT_INPUT];
const showError = inputError && inputError.type === 'compose';
const shouldShowInstantUnstakeEthAmount =
approximatedInstantEthAmount && BigNumber(approximatedInstantEthAmount).gt(0);

return (
<form onSubmit={handleSubmit(signTx)}>
Expand Down Expand Up @@ -96,19 +86,52 @@ export const UnstakeEthForm = () => {
<Divider />
</DividerWrapper>

<UnstakeFees />

<UpToDaysWrapper>
<GreyP>
<Translation id="TR_STAKE_UNSTAKING_PERIOD" />
</GreyP>
<Translation
id="TR_UP_TO_DAYS"
values={{
count: unstakingPeriod,
}}
/>
</UpToDaysWrapper>
<Column gap={spacings.lg} alignItems="normal" hasDivider>
<UnstakeFees />

<Column gap={spacings.md} alignItems="normal" margin={{ bottom: spacings.lg }}>
<Row justifyContent="space-between">
<Paragraph typographyStyle="body" variant="tertiary">
<Translation id="TR_STAKE_UNSTAKING_PERIOD" />
</Paragraph>
<Translation
id="TR_UP_TO_DAYS"
values={{
count: unstakingPeriod,
}}
/>
</Row>

{shouldShowInstantUnstakeEthAmount && (
<Row justifyContent="space-between">
<Row gap={spacings.xxs}>
<Paragraph typographyStyle="body" variant="tertiary">
<Translation
id="TR_STAKE_UNSTAKING_APPROXIMATE"
values={{
symbol: symbol.toUpperCase(),
}}
/>
</Paragraph>

<Tooltip
maxWidth={328}
content={
<Translation id="TR_STAKE_UNSTAKING_APPROXIMATE_DESCRIPTION" />
}
>
<Icon name="info" size={14} />
</Tooltip>
</Row>

<ApproximateEthAmount
value={approximatedInstantEthAmount}
symbol={symbol.toUpperCase()}
/>
</Row>
)}
</Column>
</Column>
</form>
);
};
41 changes: 39 additions & 2 deletions packages/suite/src/hooks/wallet/useUnstakeEthForm.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useEffect, useMemo } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';

import {
Expand All @@ -22,7 +22,11 @@ import { selectLocalCurrency } from 'src/reducers/wallet/settingsReducer';

import { signTransaction } from 'src/actions/wallet/stakeActions';
import { PrecomposedTransactionFinal } from '@suite-common/wallet-types';
import { getEthNetworkForWalletSdk, getStakeFormsDefaultValues } from 'src/utils/suite/stake';
import {
getEthNetworkForWalletSdk,
getStakeFormsDefaultValues,
simulateUnstake,
} from 'src/utils/suite/stake';
import { useFormDraft } from './useFormDraft';
import useDebounce from 'react-use/lib/useDebounce';
import { isChanged } from '@suite-common/suite-utils';
Expand All @@ -35,8 +39,13 @@ import {
import { selectNetwork } from '@everstake/wallet-sdk/ethereum';
import { useFees } from './form/useFees';

type UnstakeOptions = 'all' | 'rewards' | 'other';

type UnstakeContextValues = UnstakeContextValuesBase & {
amountLimits: AmountLimitsString;
approximatedInstantEthAmount?: string | null;
unstakeOption: UnstakeOptions;
setUnstakeOption: (option: UnstakeOptions) => void;
};

export const UnstakeEthFormContext = createContext<UnstakeContextValues | null>(null);
Expand All @@ -46,6 +55,10 @@ export const useUnstakeEthForm = ({
selectedAccount,
}: UseStakeFormsProps): UnstakeContextValues => {
const dispatch = useDispatch();
const [approximatedInstantEthAmount, setApproximatedInstantEthAmount] = useState<string | null>(
null,
);
const [unstakeOption, setUnstakeOption] = useState<UnstakeOptions>('all');

const { account, network } = selectedAccount;
const { symbol } = account;
Expand Down Expand Up @@ -102,6 +115,27 @@ export const useUnstakeEthForm = ({

const values = useWatch<UnstakeFormState>({ control });

useEffect(() => {
const { cryptoInput } = values;

if (!cryptoInput || Object.keys(formState.errors).length) {
setApproximatedInstantEthAmount(null);

return;
}

const simulateUnstakeAmount = async () => {
const approximatedEthAmount = await simulateUnstake({
amount: cryptoInput,
from: account.descriptor,
symbol: account.symbol,
});
setApproximatedInstantEthAmount(approximatedEthAmount);
};

simulateUnstakeAmount();
}, [account.symbol, account.descriptor, formState.errors, values]);

useEffect(() => {
if (!isChanged(defaultValues, values)) {
removeDraft(account.key);
Expand Down Expand Up @@ -247,6 +281,9 @@ export const useUnstakeEthForm = ({
currentRate,
feeInfo,
changeFeeLevel,
approximatedInstantEthAmount,
unstakeOption,
setUnstakeOption,
};
};

Expand Down
10 changes: 10 additions & 0 deletions packages/suite/src/support/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8827,6 +8827,16 @@ export default defineMessages({
id: 'TR_STAKE_UNSTAKING_PERIOD',
defaultMessage: 'Unstaking period',
},
TR_STAKE_UNSTAKING_APPROXIMATE: {
id: 'TR_STAKE_UNSTAKING_APPROXIMATE',
defaultMessage: 'Approximate {symbol} available instantly',
},

TR_STAKE_UNSTAKING_APPROXIMATE_DESCRIPTION: {
id: 'TR_STAKE_UNSTAKING_APPROXIMATE_DESCRIPTION',
defaultMessage:
'Liquidity of the staking pool can allow for instant unstake of some funds. Remaining funds will follow the unstaking period',
},
TR_UP_TO_DAYS: {
id: 'TR_UP_TO_DAYS',
defaultMessage: 'up to {count, plural, one {# day} other {# days}}',
Expand Down
27 changes: 27 additions & 0 deletions packages/suite/src/utils/suite/__fixtures__/stake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,3 +863,30 @@ export const getChangedInternalTxFixture = [
},
},
];

export const simulateUnstakeFixture = [
{
description: 'should return null for no coin, from, or data',
args: {
amount: undefined,
from: undefined,
symbol: undefined,
},
ethereumCallResult: {},
result: null,
},
{
description: 'should return approximated amount for valid inputs',
args: {
amount: '0.1',
from: '0xfB0bc552ab5Fa1971E8530852753c957e29eEEFC',
to: '0xAFA848357154a6a624686b348303EF9a13F63264',
symbol: 'eth',
},
ethereumCallResult: {
success: true,
payload: { data: '0x000000000000000000000000000000000000000000000000016345785d8a0000' },
},
result: '0.1', // 0.1 eth
},
];
18 changes: 18 additions & 0 deletions packages/suite/src/utils/suite/__tests__/stake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import TrezorConnect, {
AccountInfo,
InternalTransfer,
Success,
SuccessWithDevice,
Unsuccessful,
} from '@trezor/connect';
import {
Expand All @@ -23,6 +24,7 @@ import {
getInstantStakeTypeFixture,
getChangedInternalTxFixture,
getUnstakingAmountFixtures,
simulateUnstakeFixture,
} from '../__fixtures__/stake';
import {
getUnstakingAmount,
Expand All @@ -43,6 +45,7 @@ import {
getEthNetworkForWalletSdk,
getInstantStakeType,
getChangedInternalTx,
simulateUnstake,
} from '../stake';
import {
BlockchainEstimatedFee,
Expand Down Expand Up @@ -265,3 +268,18 @@ describe('getChangedInternalTx', () => {
});
});
});

type EthereumCallResult = Unsuccessful | SuccessWithDevice<{ data: string }>;
type SimulateUnstakeArgs = StakeTxBaseArgs & { amount: string };

describe('simulateUnstake', () => {
simulateUnstakeFixture.forEach(test => {
it(test.description, async () => {
jest.spyOn(TrezorConnect, 'ethereumCall').mockImplementation(() =>
Promise.resolve(test.ethereumCallResult as EthereumCallResult),
);
const result = await simulateUnstake(test.args as SimulateUnstakeArgs);
expect(result).toEqual(test.result);
});
});
});
34 changes: 34 additions & 0 deletions packages/suite/src/utils/suite/stake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,3 +641,37 @@ export const validateStakingMax =
});
}
};
export const simulateUnstake = async ({
amount,
from,
symbol,
}: StakeTxBaseArgs & { amount: string }) => {
const ethNetwork = getEthNetworkForWalletSdk(symbol);
const { address_pool: poolAddress, contract_pool: contractPool } = selectNetwork(ethNetwork);

if (!amount || !from || !symbol) return null;

const amountWei = toWei(amount, 'ether');
const interchanges = 0;
const coin = symbol?.toString();

const data = contractPool.methods
.unstake(amountWei, interchanges, WALLET_SDK_SOURCE)
.encodeABI();
if (!data) return null;

const ethereumData = await TrezorConnect.ethereumCall({
coin,
from,
to: poolAddress,
data,
});

if (!ethereumData.success) {
throw new Error(ethereumData.payload.error);
}

const approximatedAmount = ethereumData.payload.data;

return fromWei(approximatedAmount, 'ether');
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { FormattedCryptoAmount } from 'src/components/suite';
import { Tooltip } from '@trezor/components';
import { BigNumber } from '@trezor/utils/src/bigNumber';

interface ApproximateEthAmountProps {
value: string | number;
symbol: string;
}

const DEFAULT_MAX_DECIMAL_PLACES = 2;

export const ApproximateEthAmount = ({ value, symbol }: ApproximateEthAmountProps) => {
const hasDecimals = value.toString().includes('.');

if (!hasDecimals) {
return <FormattedCryptoAmount value={value} symbol={symbol} />;
}

const trimmedAmount = new BigNumber(value).toFixed(DEFAULT_MAX_DECIMAL_PLACES, 1);

return (
<Tooltip content={<FormattedCryptoAmount value={value} symbol={symbol} />}>
<FormattedCryptoAmount value={trimmedAmount} symbol={symbol} />
</Tooltip>
);
};

0 comments on commit 1807f1e

Please sign in to comment.