From 58543daae8fc07366af6db29461e6aa13409a82c Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Thu, 24 Oct 2024 17:31:34 -0400 Subject: [PATCH] feat: pool staked claim happy path working --- .../ClaimBanner/ClaimBanner.tsx | 31 +++- .../Stake/hooks/usePoolStakedClaim/index.ts | 132 ++++++++++++++++++ .../usePoolStakedClaim.test.tsx | 0 .../usePoolStakedClaim.types.ts | 1 + .../Stake/hooks/usePoolStakedClaim/utils.ts | 65 +++++++++ 5 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 app/components/UI/Stake/hooks/usePoolStakedClaim/index.ts create mode 100644 app/components/UI/Stake/hooks/usePoolStakedClaim/usePoolStakedClaim.test.tsx create mode 100644 app/components/UI/Stake/hooks/usePoolStakedClaim/usePoolStakedClaim.types.ts create mode 100644 app/components/UI/Stake/hooks/usePoolStakedClaim/utils.ts diff --git a/app/components/UI/Stake/components/StakingBalance/StakingBanners/ClaimBanner/ClaimBanner.tsx b/app/components/UI/Stake/components/StakingBalance/StakingBanners/ClaimBanner/ClaimBanner.tsx index 906be56cbc0..708dd7d038e 100644 --- a/app/components/UI/Stake/components/StakingBalance/StakingBanners/ClaimBanner/ClaimBanner.tsx +++ b/app/components/UI/Stake/components/StakingBalance/StakingBanners/ClaimBanner/ClaimBanner.tsx @@ -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 & { claimableAmount: string; @@ -23,9 +27,30 @@ type StakeBannerProps = Pick & { 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 ( { + 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, + }); +}; + +// TODO: Add tests +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; diff --git a/app/components/UI/Stake/hooks/usePoolStakedClaim/usePoolStakedClaim.test.tsx b/app/components/UI/Stake/hooks/usePoolStakedClaim/usePoolStakedClaim.test.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/components/UI/Stake/hooks/usePoolStakedClaim/usePoolStakedClaim.types.ts b/app/components/UI/Stake/hooks/usePoolStakedClaim/usePoolStakedClaim.types.ts new file mode 100644 index 00000000000..1c058367853 --- /dev/null +++ b/app/components/UI/Stake/hooks/usePoolStakedClaim/usePoolStakedClaim.types.ts @@ -0,0 +1 @@ +export type MultiCallData = { functionName: string; args: string[] }[]; diff --git a/app/components/UI/Stake/hooks/usePoolStakedClaim/utils.ts b/app/components/UI/Stake/hooks/usePoolStakedClaim/utils.ts new file mode 100644 index 00000000000..26cbda0843a --- /dev/null +++ b/app/components/UI/Stake/hooks/usePoolStakedClaim/utils.ts @@ -0,0 +1,65 @@ +import { ChainId, PooledStakeExitRequest } from '@metamask/stake-sdk'; +import { BigNumber } from 'ethers'; +import { MultiCallData } from './usePoolStakedClaim.types'; +import { TransactionParams } from '@metamask/transaction-controller'; +import { toHex } from '@metamask/controller-utils'; + +const TWENTY_FOUR_HOURS_IN_SECONDS = 86400; +const CLAIM_EXITED_ASSETS = 'claimExitedAssets'; + +export const have24HoursPassed = (timestamp: string) => { + const current = Math.floor(Number(new Date().getTime() / 1000)); + const timestampInSeconds = Math.floor(Number(timestamp) / 1000); + + const difference = Number(current) - Number(timestampInSeconds); + + return difference > TWENTY_FOUR_HOURS_IN_SECONDS; +}; + +export const isRequestClaimable = ( + exitQueueIndex: string, + timestamp: string, +) => { + const isValidExitQueueIndex = exitQueueIndex && exitQueueIndex !== '-1'; + return isValidExitQueueIndex && have24HoursPassed(timestamp); +}; + +export const transformAggregatedClaimableExitRequestToMulticallArgs = ( + exitRequests: PooledStakeExitRequest[], +): MultiCallData => { + const result: MultiCallData = []; + + for (const { positionTicket, timestamp, exitQueueIndex } of exitRequests) { + // claimExitedAssets rules: https://docs.google.com/document/d/1LJYXaTxdOaze8F7PwgJDG10yWw9xW0Vqinq2Nyn2Hp4/edit?tab=t.0#heading=h.a8yj0zi6pn8h + if (!isRequestClaimable(exitQueueIndex, timestamp)) continue; + + const claim = { + functionName: CLAIM_EXITED_ASSETS, + args: [ + BigNumber.from(positionTicket).toString(), + // Convert timestamp from milliseconds to seconds. + BigNumber.from(timestamp).div(1000).toString(), + BigNumber.from(exitQueueIndex).toString(), + ], + }; + + result.push(claim); + } + + return result; +}; + +export const generateClaimTxParams = ( + activeAccountAddress: string, + contractAddress: string, + encodedClaimTransactionData: string, + chainId: ChainId, + gasLimit: string, +): TransactionParams => ({ + to: contractAddress, + from: activeAccountAddress, + chainId: `0x${chainId}`, + data: encodedClaimTransactionData, + value: '0', + gas: toHex(gasLimit), +});