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 committed Oct 9, 2024
1 parent 8cdc715 commit e3cc1f5
Show file tree
Hide file tree
Showing 8 changed files with 211 additions and 7 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,5 +1,5 @@
import styled from 'styled-components';
import { Button, Divider, Paragraph, Tooltip, Banner } from '@trezor/components';
import { Button, Divider, Icon, Paragraph, Tooltip, Banner } from '@trezor/components';
import { spacingsPx } from '@trezor/theme';
import { Translation } from 'src/components/suite';
import { useDevice, useSelector } from 'src/hooks/suite';
Expand All @@ -12,6 +12,7 @@ import UnstakeFees from './Fees';
import { selectValidatorsQueueData } from '@suite-common/wallet-core';
import { getAccountEverstakeStakingPool } from '@suite-common/wallet-utils';
import { useMessageSystemStaking } from 'src/hooks/suite/useMessageSystemStaking';
import { ApproximateEthAmount } from 'src/views/wallet/staking/components/EthStakingDashboard/components/ApproximateEthAmount';

// eslint-disable-next-line local-rules/no-override-ds-component
const GreyP = styled(Paragraph)`
Expand Down Expand Up @@ -47,6 +48,19 @@ const UpToDaysWrapper = styled.div`
border-top: 1px solid ${({ theme }) => theme.borderElevation2};
`;

const ApproximateEthWrapper = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
padding: ${spacingsPx.xxs} 0 ${spacingsPx.md};
`;

const ApproximateEthTitleWrapper = styled.div`
display: flex;
gap: ${spacingsPx.xxs};
align-items: center;
`;

export const UnstakeEthForm = () => {
const { device, isLocked } = useDevice();
const selectedAccount = useSelector(selectSelectedAccount);
Expand All @@ -59,6 +73,8 @@ export const UnstakeEthForm = () => {
handleSubmit,
watch,
signTx,
approximatedEthAmount,
unstakeOption,
} = useUnstakeEthFormContext();

const { symbol } = account;
Expand All @@ -70,6 +86,7 @@ export const UnstakeEthForm = () => {
const hasValues = Boolean(watch(FIAT_INPUT) || watch(CRYPTO_INPUT));
// used instead of formState.isValid, which is sometimes returning false even if there are no errors
const formIsValid = Object.keys(errors).length === 0;
const isUnstakeOptionOther = unstakeOption === 'other';

const { canClaim = false, claimableAmount = '0' } =
getAccountEverstakeStakingPool(selectedAccount) ?? {};
Expand Down Expand Up @@ -119,6 +136,38 @@ export const UnstakeEthForm = () => {
}}
/>
</UpToDaysWrapper>

{isUnstakeOptionOther && (
<ApproximateEthWrapper>
<ApproximateEthTitleWrapper>
<GreyP>
<Translation
id="TR_STAKE_UNSTAKING_APPROXIMATE"
values={{
symbol: symbol.toUpperCase(),
}}
/>
</GreyP>

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

{approximatedEthAmount && (
<ApproximateEthAmount
value={approximatedEthAmount}
symbol={symbol.toUpperCase()}
/>
)}
</ApproximateEthWrapper>
)}

<Tooltip content={unstakingMessageContent}>
<Button
type="submit"
Expand Down
39 changes: 37 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;
approximatedEthAmount?: string | null;
unstakeOption: UnstakeOptions;
setUnstakeOption: (option: UnstakeOptions) => void;
};

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

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

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

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

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

return;
}

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

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

useEffect(() => {
if (!isChanged(defaultValues, values)) {
removeDraft(account.key);
Expand Down Expand Up @@ -247,6 +279,9 @@ export const useUnstakeEthForm = ({
currentRate,
feeInfo,
changeFeeLevel,
approximatedEthAmount,
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 @@ -8678,6 +8678,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);
});
});
});
35 changes: 35 additions & 0 deletions packages/suite/src/utils/suite/stake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,3 +618,38 @@ export const getChangedInternalTx = (

return internalTransfer ?? null;
};

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,33 @@
import { FormattedCryptoAmount } from 'src/components/suite';
import styled from 'styled-components';
import { Tooltip, variables } from '@trezor/components';
import { BigNumber } from '@trezor/utils/src/bigNumber';

const StyledFormattedCryptoAmount = styled(FormattedCryptoAmount)`
display: block;
font-size: ${variables.FONT_SIZE.NORMAL};
`;

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

const DEFAULT_MAX_DECIMAL_PLACES = 5;

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

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

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

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

0 comments on commit e3cc1f5

Please sign in to comment.