Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: STAKE-802 integrate claim sdk method #12018

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ import {
MOCK_GET_VAULT_RESPONSE,
MOCK_STAKED_ETH_ASSET,
} from '../../__mocks__/mockData';
import { createMockAccountsControllerState } from '../../../../../util/test/accountsControllerTestUtils';
import { backgroundState } from '../../../../../util/test/initial-root-state';

const MOCK_ADDRESS_1 = '0x0';

const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([
MOCK_ADDRESS_1,
]);

const mockInitialState = {
settings: {},
engine: {
backgroundState: {
...backgroundState,
AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
},
},
};

jest.mock('../../../../hooks/useIpfsGateway', () => jest.fn());

Expand Down Expand Up @@ -107,13 +125,15 @@ describe('StakingBalance', () => {
it('render matches snapshot', () => {
const { toJSON } = renderWithProvider(
<StakingBalance asset={MOCK_STAKED_ETH_ASSET} />,
{ state: mockInitialState },
);
expect(toJSON()).toMatchSnapshot();
});

it('redirects to StakeInputView on stake button click', () => {
const { getByText } = renderWithProvider(
<StakingBalance asset={MOCK_STAKED_ETH_ASSET} />,
{ state: mockInitialState },
);

fireEvent.press(getByText(strings('stake.stake_more')));
Expand All @@ -127,6 +147,7 @@ describe('StakingBalance', () => {
it('redirects to UnstakeInputView on unstake button click', () => {
const { getByText } = renderWithProvider(
<StakingBalance asset={MOCK_STAKED_ETH_ASSET} />,
{ state: mockInitialState },
);

fireEvent.press(getByText(strings('stake.unstake')));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ import { strings } from '../../../../../../../../locales/i18n';
import Button, {
ButtonVariants,
} from '../../../../../../../component-library/components/Buttons/Button';
import useTooltipModal from '../../../../../../hooks/useTooltipModal';
import { BannerProps } from '../../../../../../../component-library/components/Banners/Banner/Banner.types';
import { useStyles } from '../../../../../../../component-library/hooks';
import styleSheet from './ClaimBanner.styles';
import usePoolStakedClaim from '../../../../hooks/usePoolStakedClaim';
import { useSelector } from 'react-redux';
import { selectSelectedInternalAccount } from '../../../../../../../selectors/accountsController';
import usePooledStakes from '../../../../hooks/usePooledStakes';
import Engine from '../../../../../../../core/Engine';

type StakeBannerProps = Pick<BannerProps, 'style'> & {
claimableAmount: string;
Expand All @@ -23,9 +27,30 @@ type StakeBannerProps = Pick<BannerProps, 'style'> & {
const ClaimBanner = ({ claimableAmount, style }: StakeBannerProps) => {
const { styles } = useStyles(styleSheet, {});

const { openTooltipModal } = useTooltipModal();
const activeAccount = useSelector(selectSelectedInternalAccount);

const onClaimPress = () => openTooltipModal('TODO', 'Connect to claim flow');
const { attemptPoolStakedClaimTransaction } = usePoolStakedClaim();

const { pooledStakesData, refreshPooledStakes } = usePooledStakes();

const onClaimPress = async () => {
if (!activeAccount?.address) return;

const txRes = await attemptPoolStakedClaimTransaction(
activeAccount?.address,
pooledStakesData,
);

const transactionId = txRes?.transactionMeta.id;

Engine.controllerMessenger.subscribeOnceIf(
'TransactionController:transactionConfirmed',
() => {
refreshPooledStakes();
},
(transactionMeta) => transactionMeta.id === transactionId,
);
};

return (
<Banner
Expand Down
131 changes: 131 additions & 0 deletions app/components/UI/Stake/hooks/usePoolStakedClaim/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { PooledStake, PooledStakingContract } from '@metamask/stake-sdk';
import { useStakeContext } from '../useStakeContext';
import trackErrorAsAnalytics from '../../../../../util/metrics/TrackError/trackErrorAsAnalytics';
import { WalletDevice } from '@metamask/transaction-controller';
import { addTransaction } from '../../../../../util/transaction-controller';
import { ORIGIN_METAMASK } from '@metamask/controller-utils';
import {
generateClaimTxParams,
isRequestClaimable,
transformAggregatedClaimableExitRequestToMulticallArgs,
} from './utils';

const attemptMultiCallClaimTransaction = async (
pooledStakesData: PooledStake,
poolStakingContract: PooledStakingContract,
activeAccountAddress: string,
) => {
const multiCallData = transformAggregatedClaimableExitRequestToMulticallArgs(
pooledStakesData.exitRequests,
);

const gasLimit = await poolStakingContract.estimateMulticallGas(
multiCallData,
activeAccountAddress,
);

const { data, chainId } =
await poolStakingContract.encodeMulticallTransactionData(
multiCallData,
activeAccountAddress,
{ gasLimit, gasBufferPct: 30 }, // 30% buffer
);

const txParams = generateClaimTxParams(
activeAccountAddress,
poolStakingContract.contract.address,
data,
chainId,
gasLimit.toString(),
);

return addTransaction(txParams, {
deviceConfirmedOn: WalletDevice.MM_MOBILE,
origin: ORIGIN_METAMASK,
});
};

const attemptSingleClaimTransaction = async (
pooledStakesData: PooledStake,
poolStakingContract: PooledStakingContract,
activeAccountAddress: string,
) => {
const { positionTicket, timestamp, exitQueueIndex } =
pooledStakesData.exitRequests[0];

if (!isRequestClaimable(exitQueueIndex, timestamp)) return;

const gasLimit = await poolStakingContract.estimateClaimExitedAssetsGas(
positionTicket,
timestamp,
exitQueueIndex,
activeAccountAddress,
);

const { data, chainId } =
await poolStakingContract.encodeClaimExitedAssetsTransactionData(
positionTicket,
timestamp,
exitQueueIndex,
activeAccountAddress,
{
gasLimit,
gasBufferPct: 30, // 30% buffer
},
);

const txParams = generateClaimTxParams(
activeAccountAddress,
poolStakingContract.contract.address,
data,
chainId,
gasLimit.toString(),
);

return addTransaction(txParams, {
deviceConfirmedOn: WalletDevice.MM_MOBILE,
origin: ORIGIN_METAMASK,
});
};

const attemptPoolStakedClaimTransaction =
(poolStakingContract: PooledStakingContract) =>
async (activeAccountAddress: string, pooledStakesData: PooledStake) => {
try {
if (pooledStakesData.exitRequests.length === 0) return;

const isMultiCallClaim = pooledStakesData.exitRequests.length > 1;

return isMultiCallClaim
? attemptMultiCallClaimTransaction(
pooledStakesData,
poolStakingContract,
activeAccountAddress,
)
: attemptSingleClaimTransaction(
pooledStakesData,
poolStakingContract,
activeAccountAddress,
);
} catch (e) {
const errorMessage = (e as Error).message;
trackErrorAsAnalytics(
'Pooled Staking Claim Transaction Failed',
errorMessage,
);
}
};

const usePoolStakedClaim = () => {
const poolStakeContext = useStakeContext();

const stakingContract =
poolStakeContext?.stakingContract as PooledStakingContract;

return {
attemptPoolStakedClaimTransaction:
attemptPoolStakedClaimTransaction(stakingContract),
};
};

export default usePoolStakedClaim;
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {
ChainId,
PooledStakingContract,
StakingType,
} from '@metamask/stake-sdk';
import usePoolStakedClaim from '.';
import { Contract } from 'ethers';
import { Stake } from '../../sdk/stakeSdkProvider';
import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider';
import { createMockAccountsControllerState } from '../../../../../util/test/accountsControllerTestUtils';
import { backgroundState } from '../../../../../util/test/initial-root-state';
import { MOCK_GET_POOLED_STAKES_API_RESPONSE } from '../../__mocks__/mockData';

const MOCK_ADDRESS_1 = '0x0123456789abcdef0123456789abcdef01234567';

const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([
MOCK_ADDRESS_1,
]);

const mockInitialState = {
settings: {},
engine: {
backgroundState: {
...backgroundState,
AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
},
},
};

const MOCK_CLAIM_GAS_LIMIT = 201225;

const mockEstimateMulticallGas = jest
.fn()
.mockResolvedValue(MOCK_CLAIM_GAS_LIMIT);

const mockEstimateClaimExitedAssetsGas = jest
.fn()
.mockResolvedValue(MOCK_CLAIM_GAS_LIMIT);

const mockEncodeMulticallTransactionData = jest.fn().mockResolvedValue({
data: '0x00000',
chainId: '1',
});

const mockEncodeClaimExitedAssetsTransactionData = jest.fn().mockResolvedValue({
data: '0x00000',
chainId: '1',
});

const mockPooledStakingContractService: PooledStakingContract = {
chainId: ChainId.ETHEREUM,
connectSignerOrProvider: jest.fn(),
contract: new Contract('0x0000000000000000000000000000000000000000', []),
convertToShares: jest.fn(),
encodeClaimExitedAssetsTransactionData:
mockEncodeClaimExitedAssetsTransactionData,
encodeDepositTransactionData: jest.fn(),
encodeEnterExitQueueTransactionData: jest.fn(),
encodeMulticallTransactionData: mockEncodeMulticallTransactionData,
estimateClaimExitedAssetsGas: mockEstimateClaimExitedAssetsGas,
estimateDepositGas: jest.fn(),
estimateEnterExitQueueGas: jest.fn(),
estimateMulticallGas: mockEstimateMulticallGas,
};

const mockSdkContext: Stake = {
stakingContract: mockPooledStakingContractService,
sdkType: StakingType.POOLED,
setSdkType: jest.fn(),
};

jest.mock('../useStakeContext', () => ({
useStakeContext: () => mockSdkContext,
}));

let mockAddTransaction: jest.Mock;

jest.mock('../../../../../core/Engine', () => {
mockAddTransaction = jest.fn().mockResolvedValue(1);

return {
context: {
NetworkController: {
getNetworkClientById: () => ({
configuration: {
chainId: '0x1',
rpcUrl: 'https://mainnet.infura.io/v3',
ticker: 'ETH',
type: 'custom',
},
}),
findNetworkClientIdByChainId: () => 'mainnet',
},
TransactionController: {
addTransaction: mockAddTransaction,
},
},
};
});

const mockPooledStakeData = MOCK_GET_POOLED_STAKES_API_RESPONSE.accounts[0];

describe('usePoolStakedClaim', () => {
afterEach(() => {
jest.clearAllMocks();
});

afterAll(() => {
jest.resetAllMocks();
});

it('attempts to claim multiple exit requests via multicall', async () => {
const { result } = renderHookWithProvider(() => usePoolStakedClaim(), {
state: mockInitialState,
});

await result.current.attemptPoolStakedClaimTransaction(
MOCK_ADDRESS_1,
mockPooledStakeData,
);

expect(mockEstimateMulticallGas).toHaveBeenCalledTimes(1);
expect(mockEncodeMulticallTransactionData).toHaveBeenCalledTimes(1);
expect(mockAddTransaction).toHaveBeenCalledTimes(1);
});

it('attempts to claim a single exit request', async () => {
const mockPooledStakeDataWithSingleExistRequest = {
...mockPooledStakeData,
exitRequests: [mockPooledStakeData.exitRequests[1]],
};

const { result } = renderHookWithProvider(() => usePoolStakedClaim(), {
state: mockInitialState,
});

await result.current.attemptPoolStakedClaimTransaction(
MOCK_ADDRESS_1,
mockPooledStakeDataWithSingleExistRequest,
);

expect(mockEstimateClaimExitedAssetsGas).toHaveBeenCalledTimes(1);
expect(mockEncodeClaimExitedAssetsTransactionData).toHaveBeenCalledTimes(1);
expect(mockAddTransaction).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type MultiCallData = { functionName: string; args: string[] }[];
Loading
Loading