From 3ee3ef9ef934635dca5e600a4c700d68b050f0fc Mon Sep 17 00:00:00 2001 From: Satish Ravi Date: Tue, 17 Dec 2024 18:56:47 -0800 Subject: [PATCH 1/2] feat(earn): support cross chain swap and deposit --- src/analytics/Properties.tsx | 6 +- src/earn/EarnDepositBottomSheet.test.tsx | 55 +++++- src/earn/EarnDepositBottomSheet.tsx | 8 +- src/earn/EarnEnterAmount.test.tsx | 216 +++++++++++++++++++++-- src/earn/EarnEnterAmount.tsx | 48 +++-- src/earn/prepareTransactions.test.ts | 6 +- src/earn/prepareTransactions.ts | 1 + src/earn/saga.test.ts | 167 +++++++++++------- src/earn/saga.ts | 40 +++-- src/statsig/types.ts | 1 + 10 files changed, 439 insertions(+), 109 deletions(-) diff --git a/src/analytics/Properties.tsx b/src/analytics/Properties.tsx index 8ecbae88456..9bb62e9cf13 100644 --- a/src/analytics/Properties.tsx +++ b/src/analytics/Properties.tsx @@ -1551,7 +1551,7 @@ interface PointsEventsProperties { export interface EarnCommonProperties { providerId: string poolId: string - networkId: NetworkId + networkId: NetworkId // this is always the pool's networkId depositTokenId: string } @@ -1562,6 +1562,8 @@ interface EarnDepositProperties extends EarnCommonProperties { // same as the depositTokenAmount and depositTokenId fromTokenAmount: string fromTokenId: string + fromNetworkId: NetworkId + swapType?: SwapType } interface EarnWithdrawProperties extends EarnCommonProperties { @@ -1609,7 +1611,9 @@ interface EarnEventsProperties { // For withdrawals this will be in units of the depositToken fromTokenAmount: string fromTokenId: string + fromNetworkId: NetworkId depositTokenAmount?: string + swapType?: SwapType // only for swap-deposit } & EarnCommonProperties [EarnEvents.earn_deposit_add_gas_press]: EarnCommonProperties & { gasTokenId: string } [EarnEvents.earn_feed_item_select]: { diff --git a/src/earn/EarnDepositBottomSheet.test.tsx b/src/earn/EarnDepositBottomSheet.test.tsx index 80029bea70f..076d58e60da 100644 --- a/src/earn/EarnDepositBottomSheet.test.tsx +++ b/src/earn/EarnDepositBottomSheet.test.tsx @@ -17,6 +17,7 @@ import { mockAccount, mockArbEthTokenId, mockArbUsdcTokenId, + mockCeloTokenId, mockEarnPositions, mockTokenBalances, mockUSDCAddress, @@ -88,6 +89,28 @@ const mockSwapDepositProps = { }, } +const mockCrossChainProps = { + ...mockSwapDepositProps, + preparedTransaction: { + ...mockPreparedTransaction, + feeCurrency: { + ...mockTokenBalances[mockCeloTokenId], + isNative: true, + balance: new BigNumber(10), + priceUsd: new BigNumber(1), + lastKnownPriceUsd: new BigNumber(1), + }, + }, + inputTokenId: mockCeloTokenId, + swapTransaction: { + ...mockSwapDepositProps.swapTransaction, + swapType: 'cross-chain' as const, + estimatedDuration: 300, + maxCrossChainFee: '0.1', + estimatedCrossChainFee: '0.05', + }, +} + describe('EarnDepositBottomSheet', () => { const commonAnalyticsProperties = { depositTokenId: mockArbUsdcTokenId, @@ -95,6 +118,7 @@ describe('EarnDepositBottomSheet', () => { networkId: NetworkId['arbitrum-sepolia'], providerId: mockEarnPositions[0].appId, poolId: mockEarnPositions[0].positionId, + fromNetworkId: NetworkId['arbitrum-sepolia'], } beforeEach(() => { @@ -143,14 +167,17 @@ describe('EarnDepositBottomSheet', () => { expect(getByTestId('EarnDeposit/SecondaryCta')).toBeTruthy() }) - it('renders all elements for swap-deposit', () => { + it.each([ + { swapType: 'same-chain', props: mockSwapDepositProps, fromSymbol: 'ETH', fiatFee: '0.012' }, + { swapType: 'cross-chain', props: mockCrossChainProps, fromSymbol: 'CELO', fiatFee: '0.00011' }, + ])('renders all elements for $swapType swap-deposit', ({ props, fromSymbol, fiatFee }) => { const { getByTestId, queryByTestId, getByText } = render( - + ) expect(getByText('earnFlow.depositBottomSheet.title')).toBeTruthy() @@ -166,11 +193,11 @@ describe('EarnDepositBottomSheet', () => { expect(getByText('earnFlow.depositBottomSheet.amount')).toBeTruthy() expect(getByTestId('EarnDeposit/Amount')).toHaveTextContent('100.00 USDC(₱133.00)') - expect(getByTestId('EarnDeposit/Swap/From')).toHaveTextContent('0.041 ETH') + expect(getByTestId('EarnDeposit/Swap/From')).toHaveTextContent(`0.041 ${fromSymbol}`) expect(getByTestId('EarnDeposit/Swap/To')).toHaveTextContent('100.00 USDC') expect(getByText('earnFlow.depositBottomSheet.fee')).toBeTruthy() - expect(getByTestId('EarnDeposit/Fee')).toHaveTextContent('₱0.012(0.000006 ETH)') + expect(getByTestId('EarnDeposit/Fee')).toHaveTextContent(`₱${fiatFee}(0.000006 ${fromSymbol})`) expect(getByText('earnFlow.depositBottomSheet.provider')).toBeTruthy() expect(getByText('Aave')).toBeTruthy() @@ -189,26 +216,43 @@ describe('EarnDepositBottomSheet', () => { describe.each([ { + testName: 'deposit', mode: 'deposit', props: mockDepositProps, fromTokenAmount: '100', fromTokenId: mockArbUsdcTokenId, depositTokenAmount: '100', + swapType: undefined, }, { + testName: 'same chain swap & deposit', mode: 'swap-deposit', props: mockSwapDepositProps, fromTokenAmount: '0.041', fromTokenId: mockArbEthTokenId, depositTokenAmount: '99.999', + swapType: 'same-chain', + }, + { + testName: 'cross chain swap & deposit', + mode: 'swap-deposit', + props: mockCrossChainProps, + fromTokenAmount: '0.041', + fromTokenId: mockCeloTokenId, + depositTokenAmount: '99.999', + swapType: 'cross-chain', }, - ])('$mode', ({ mode, props, fromTokenAmount, fromTokenId, depositTokenAmount }) => { + ])('$testName', ({ mode, props, fromTokenAmount, fromTokenId, depositTokenAmount, swapType }) => { + const fromNetworkId = + swapType === 'cross-chain' ? NetworkId['celo-alfajores'] : NetworkId['arbitrum-sepolia'] const expectedAnalyticsProperties = { ...commonAnalyticsProperties, mode, fromTokenAmount, fromTokenId, depositTokenAmount, + swapType, + fromNetworkId, } it('pressing complete submits action and fires analytics event', () => { @@ -362,6 +406,7 @@ describe('EarnDepositBottomSheet', () => { ) expect(getByTestId('EarnDeposit/GasSubsidized')).toBeTruthy() + expect(earnUtils.isGasSubsidizedForNetwork).toHaveBeenCalledWith(fromNetworkId) }) }) }) diff --git a/src/earn/EarnDepositBottomSheet.tsx b/src/earn/EarnDepositBottomSheet.tsx index a3794818504..88097be685d 100644 --- a/src/earn/EarnDepositBottomSheet.tsx +++ b/src/earn/EarnDepositBottomSheet.tsx @@ -72,9 +72,11 @@ export default function EarnDepositBottomSheet({ depositTokenAmount: depositAmount.toString(), fromTokenId: inputTokenId, fromTokenAmount: inputAmount.toString(), + fromNetworkId: preparedTransaction.feeCurrency.networkId, networkId: pool.networkId, poolId: pool.positionId, mode, + swapType: swapTransaction?.swapType, } const { estimatedFeeAmount, feeCurrency } = getFeeCurrencyAndAmounts(preparedTransaction) @@ -84,7 +86,7 @@ export default function EarnDepositBottomSheet({ return null } - const isGasSubsidized = isGasSubsidizedForNetwork(pool.networkId) + const isGasSubsidized = isGasSubsidizedForNetwork(preparedTransaction.feeCurrency.networkId) const { termsUrl } = pool.dataProps const onPressProviderIcon = () => { @@ -236,9 +238,7 @@ export default function EarnDepositBottomSheet({ - - {NETWORK_NAMES[preparedTransaction.feeCurrency.networkId]} - + {NETWORK_NAMES[pool.networkId]} {termsUrl ? ( diff --git a/src/earn/EarnEnterAmount.test.tsx b/src/earn/EarnEnterAmount.test.tsx index 73f720ccd30..b3881a2c969 100644 --- a/src/earn/EarnEnterAmount.test.tsx +++ b/src/earn/EarnEnterAmount.test.tsx @@ -12,7 +12,7 @@ import { Status as EarnStatus } from 'src/earn/slice' import { CICOFlow } from 'src/fiatExchanges/types' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' -import { getFeatureGate } from 'src/statsig' +import { getFeatureGate, getMultichainFeatures } from 'src/statsig' import { StatsigFeatureGates } from 'src/statsig/types' import { SwapTransaction } from 'src/swap/types' import { TokenBalance } from 'src/tokens/slice' @@ -30,19 +30,27 @@ import { mockArbArbTokenId, mockArbEthTokenId, mockArbUsdcTokenId, + mockCeloAddress, + mockCeloTokenId, + mockCusdTokenId, mockEarnPositions, mockPositions, mockRewardsPositions, mockTokenBalances, mockUSDCAddress, + mockUSDCTokenId, } from 'test/values' jest.mock('src/earn/hooks') jest.mock('react-native-localize') -jest.mock('src/statsig') // statsig isn't used directly but the hooksApiSelector uses it -jest - .mocked(getFeatureGate) - .mockImplementation((featureGateName) => featureGateName === StatsigFeatureGates.SHOW_POSITIONS) +jest.mock('src/statsig') // for cross chain swap and indirect use in hooksApiSelector +jest.mocked(getMultichainFeatures).mockReturnValue({ + showSwap: [ + NetworkId['arbitrum-sepolia'], + NetworkId['celo-alfajores'], + NetworkId['ethereum-sepolia'], + ], +}) const mockPreparedTransaction: PreparedTransactionsPossible = { type: 'possible' as const, @@ -88,7 +96,7 @@ const mockPreparedTransactionNotEnough: PreparedTransactionsNotEnoughBalanceForG ], } -const mockFeeCurrencies: TokenBalance[] = [ +const mockArbFeeCurrencies: TokenBalance[] = [ { ...mockTokenBalances[mockArbEthTokenId], isNative: true, @@ -98,6 +106,22 @@ const mockFeeCurrencies: TokenBalance[] = [ }, ] +const mockCeloFeeCurrencies: TokenBalance[] = [ + { + ...mockTokenBalances[mockCeloTokenId], + isNative: true, + balance: new BigNumber(5), + priceUsd: new BigNumber(mockTokenBalances[mockCeloTokenId].priceUsd!), + lastKnownPriceUsd: new BigNumber(mockTokenBalances[mockCeloTokenId].priceUsd!), + }, + { + ...mockTokenBalances[mockCusdTokenId], + balance: new BigNumber(5), + priceUsd: new BigNumber(mockTokenBalances[mockCusdTokenId].priceUsd!), + lastKnownPriceUsd: new BigNumber(mockTokenBalances[mockCusdTokenId].priceUsd!), + }, +] + const mockSwapTransaction: SwapTransaction = { swapType: 'same-chain', chainId: 42161, @@ -118,6 +142,17 @@ const mockSwapTransaction: SwapTransaction = { estimatedPriceImpact: '0.1', } +const mockCrossChainSwapTransaction: SwapTransaction = { + ...mockSwapTransaction, + swapType: 'cross-chain', + estimatedDuration: 300, + maxCrossChainFee: '0.1', + estimatedCrossChainFee: '0.05', + sellTokenAddress: mockCeloAddress, + price: '4', + guaranteedPrice: '4', +} + function createStore(depositStatus: EarnStatus = 'idle') { return createMockStore({ tokens: { @@ -140,6 +175,18 @@ function createStore(depositStatus: EarnStatus = 'idle') { ...mockTokenBalances[mockAaveArbUsdcTokenId], balance: '10', }, + mockCeloTokenId: { + ...mockTokenBalances[mockCeloTokenId], + balance: '5', + }, + mockCusdTokenId: { + ...mockTokenBalances[mockCusdTokenId], + balance: '5', + }, + mockUSDCTokenId: { + ...mockTokenBalances[mockUSDCTokenId], + balance: '5', + }, }, }, positions: { @@ -167,6 +214,11 @@ describe('EarnEnterAmount', () => { const refreshPreparedTransactionsSpy = jest.fn() beforeEach(() => { jest.clearAllMocks() + jest + .mocked(getFeatureGate) + .mockImplementation( + (featureGateName) => featureGateName === StatsigFeatureGates.SHOW_POSITIONS + ) jest .mocked(getNumberFormatSettings) .mockReturnValue({ decimalSeparator: '.', groupingSeparator: ',' }) @@ -230,7 +282,7 @@ describe('EarnEnterAmount', () => { walletAddress: mockAccount.toLowerCase(), pool: mockEarnPositions[0], hooksApiUrl: networkConfig.hooksApiUrl, - feeCurrencies: mockFeeCurrencies, + feeCurrencies: mockArbFeeCurrencies, shortcutId: 'deposit', useMax: false, }) @@ -278,6 +330,7 @@ describe('EarnEnterAmount', () => { poolId: mockEarnPositions[0].positionId, fromTokenId: mockArbUsdcTokenId, fromTokenAmount: '8', + fromNetworkId: NetworkId['arbitrum-sepolia'], depositTokenAmount: '8', mode: 'deposit', }) @@ -287,7 +340,7 @@ describe('EarnEnterAmount', () => { describe('swap-deposit', () => { const swapDepositParams = { ...params, mode: 'swap-deposit' } - it('should show the token dropdown and allow the user to select a token', async () => { + it('should show the token dropdown and allow the user to select a token only from same chain if feature gate is off', async () => { const { getByTestId, getAllByTestId } = render( @@ -300,10 +353,45 @@ describe('EarnEnterAmount', () => { expect(getByTestId('downArrowIcon')).toBeTruthy() expect(getAllByTestId('TokenBalanceItem')).toHaveLength(2) expect(getAllByTestId('TokenBalanceItem')[0]).toHaveTextContent('ETH') + expect(getAllByTestId('TokenBalanceItem')[0]).toHaveTextContent('Arbitrum Sepolia') expect(getAllByTestId('TokenBalanceItem')[1]).toHaveTextContent('ARB') + expect(getAllByTestId('TokenBalanceItem')[1]).toHaveTextContent('Arbitrum Sepolia') expect(getByTestId('TokenBottomSheet')).not.toHaveTextContent('USDC') }) + it('should show the token dropdown and allow the user to select a token from all chains if feature gate is on', async () => { + jest + .mocked(getFeatureGate) + .mockImplementation( + (featureGateName) => + featureGateName === StatsigFeatureGates.ALLOW_CROSS_CHAIN_SWAP_AND_DEPOSIT || + featureGateName === StatsigFeatureGates.SHOW_POSITIONS + ) + const { getByTestId, getAllByTestId } = render( + + + + ) + + expect(getByTestId('EarnEnterAmount/TokenSelect')).toBeTruthy() + expect(getByTestId('EarnEnterAmount/TokenSelect')).toHaveTextContent('ETH') + expect(getByTestId('EarnEnterAmount/TokenSelect')).toBeEnabled() + expect(getByTestId('downArrowIcon')).toBeTruthy() + expect(getAllByTestId('TokenBalanceItem')).toHaveLength(6) + expect(getAllByTestId('TokenBalanceItem')[0]).toHaveTextContent('ETH') + expect(getAllByTestId('TokenBalanceItem')[0]).toHaveTextContent('Arbitrum Sepolia') + expect(getAllByTestId('TokenBalanceItem')[1]).toHaveTextContent('ARB') + expect(getAllByTestId('TokenBalanceItem')[1]).toHaveTextContent('Arbitrum Sepolia') + expect(getAllByTestId('TokenBalanceItem')[2]).toHaveTextContent('CELO') + expect(getAllByTestId('TokenBalanceItem')[2]).toHaveTextContent('Celo Alfajores') + expect(getAllByTestId('TokenBalanceItem')[3]).toHaveTextContent('cUSD') + expect(getAllByTestId('TokenBalanceItem')[3]).toHaveTextContent('Celo Alfajores') + expect(getAllByTestId('TokenBalanceItem')[4]).toHaveTextContent('USDC') + expect(getAllByTestId('TokenBalanceItem')[4]).toHaveTextContent('Ethereum Sepolia') + expect(getAllByTestId('TokenBalanceItem')[5]).toHaveTextContent('POOF') + expect(getAllByTestId('TokenBalanceItem')[5]).toHaveTextContent('Celo Alfajores') + }) + it('should default to the swappable token if only one is eligible and not show dropdown', async () => { const store = createMockStore({ tokens: { @@ -338,7 +426,7 @@ describe('EarnEnterAmount', () => { expect(queryByTestId('downArrowIcon')).toBeFalsy() }) - it('should prepare transactions with the expected inputs', async () => { + it('should prepare transactions with the expected inputs for same-chain swap', async () => { const { getByTestId } = render( @@ -359,17 +447,52 @@ describe('EarnEnterAmount', () => { walletAddress: mockAccount.toLowerCase(), pool: mockEarnPositions[0], hooksApiUrl: networkConfig.hooksApiUrl, - feeCurrencies: mockFeeCurrencies, + feeCurrencies: mockArbFeeCurrencies, shortcutId: 'swap-deposit', useMax: false, }) }) - it('should show tx details and handle navigating to the deposit bottom sheet', async () => { + it('should prepare transactions with the expected inputs for cross-chain swap', async () => { + jest + .mocked(getFeatureGate) + .mockImplementation( + (featureGateName) => + featureGateName === StatsigFeatureGates.ALLOW_CROSS_CHAIN_SWAP_AND_DEPOSIT || + featureGateName === StatsigFeatureGates.SHOW_POSITIONS + ) + const { getByTestId, getAllByTestId } = render( + + + + ) + + fireEvent.press(getAllByTestId('TokenBalanceItem')[2]) // select celo for cross chain swap + fireEvent.changeText(getByTestId('EarnEnterAmount/TokenAmountInput'), '.25') + + await waitFor(() => expect(refreshPreparedTransactionsSpy).toHaveBeenCalledTimes(1)) + expect(refreshPreparedTransactionsSpy).toHaveBeenCalledWith({ + amount: '0.25', + token: { + ...mockTokenBalances[mockCeloTokenId], + priceUsd: new BigNumber(mockTokenBalances[mockCeloTokenId].priceUsd!), + lastKnownPriceUsd: new BigNumber(mockTokenBalances[mockCeloTokenId].priceUsd!), + balance: new BigNumber(5), + }, + walletAddress: mockAccount.toLowerCase(), + pool: mockEarnPositions[0], + hooksApiUrl: networkConfig.hooksApiUrl, + feeCurrencies: expect.arrayContaining(mockCeloFeeCurrencies), + shortcutId: 'swap-deposit', + useMax: false, + }) + }) + + it('should show tx details and handle navigating to the deposit bottom sheet for same-chain swap', async () => { jest.mocked(usePrepareEnterAmountTransactionsCallback).mockReturnValue({ prepareTransactionsResult: { prepareTransactionsResult: mockPreparedTransaction, - swapTransaction: mockSwapTransaction, + swapTransaction: mockCrossChainSwapTransaction, }, refreshPreparedTransactions: jest.fn(), clearPreparedTransactions: jest.fn(), @@ -413,8 +536,75 @@ describe('EarnEnterAmount', () => { providerId: mockEarnPositions[0].appId, poolId: mockEarnPositions[0].positionId, fromTokenId: mockArbEthTokenId, + fromNetworkId: NetworkId['arbitrum-sepolia'], depositTokenAmount: '0.99999', mode: 'swap-deposit', + swapType: 'cross-chain', + }) + await waitFor(() => expect(getByText('earnFlow.depositBottomSheet.title')).toBeVisible()) + }) + + it('should show tx details and handle navigating to the deposit bottom sheet for cross-chain swap', async () => { + jest + .mocked(getFeatureGate) + .mockImplementation( + (featureGateName) => + featureGateName === StatsigFeatureGates.ALLOW_CROSS_CHAIN_SWAP_AND_DEPOSIT || + featureGateName === StatsigFeatureGates.SHOW_POSITIONS + ) + jest.mocked(usePrepareEnterAmountTransactionsCallback).mockReturnValue({ + prepareTransactionsResult: { + prepareTransactionsResult: mockPreparedTransaction, + swapTransaction: mockCrossChainSwapTransaction, + }, + refreshPreparedTransactions: jest.fn(), + clearPreparedTransactions: jest.fn(), + prepareTransactionError: undefined, + isPreparingTransactions: false, + }) + const { getByTestId, getByText, getAllByTestId } = render( + + + + ) + + fireEvent.press(getAllByTestId('TokenBalanceItem')[2]) // select celo for cross chain swap + fireEvent.changeText(getByTestId('EarnEnterAmount/TokenAmountInput'), '0.25') + + await waitFor(() => expect(getByText('earnFlow.enterAmount.continue')).not.toBeDisabled()) + + expect(getByTestId('EarnEnterAmount/Swap/From')).toBeTruthy() + expect(getByTestId('EarnEnterAmount/Swap/From')).toHaveTextContent('0.25 CELO') + + expect(getByTestId('EarnEnterAmount/Swap/To')).toBeTruthy() + expect(getByTestId('EarnEnterAmount/Swap/To')).toHaveTextContent('1.00 USDC') + + expect(getByTestId('EarnEnterAmount/Deposit/Crypto')).toBeTruthy() + expect(getByTestId('EarnEnterAmount/Deposit/Crypto')).toHaveTextContent('1.00 USDC') + + expect(getByTestId('EarnEnterAmount/Deposit/Fiat')).toBeTruthy() + expect(getByTestId('EarnEnterAmount/Deposit/Fiat')).toHaveTextContent('₱1.33') + + expect(getByTestId('EarnEnterAmount/Fees')).toBeTruthy() + expect(getByTestId('EarnEnterAmount/Fees')).toHaveTextContent('₱0.012') + + fireEvent.press(getByText('earnFlow.enterAmount.continue')) + + await waitFor(() => expect(AppAnalytics.track).toHaveBeenCalledTimes(2)) // one for token selection, one for continue press + + expect(AppAnalytics.track).toHaveBeenCalledWith(EarnEvents.earn_enter_amount_continue_press, { + amountEnteredIn: 'token', + amountInUsd: '3.31', + networkId: NetworkId['arbitrum-sepolia'], + fromTokenAmount: '0.25', + depositTokenId: mockArbUsdcTokenId, + providerId: mockEarnPositions[0].appId, + poolId: mockEarnPositions[0].positionId, + fromTokenId: mockCeloTokenId, + fromNetworkId: NetworkId['celo-alfajores'], + depositTokenAmount: '1', + mode: 'swap-deposit', + swapType: 'cross-chain', }) await waitFor(() => expect(getByText('earnFlow.depositBottomSheet.title')).toBeVisible()) }) @@ -473,7 +663,7 @@ describe('EarnEnterAmount', () => { walletAddress: mockAccount.toLowerCase(), pool: mockPoolWithHighPricePerShare, hooksApiUrl: networkConfig.hooksApiUrl, - feeCurrencies: mockFeeCurrencies, + feeCurrencies: mockArbFeeCurrencies, shortcutId: 'withdraw', useMax: false, }) diff --git a/src/earn/EarnEnterAmount.tsx b/src/earn/EarnEnterAmount.tsx index bcb276970c8..1d95ff98ace 100644 --- a/src/earn/EarnEnterAmount.tsx +++ b/src/earn/EarnEnterAmount.tsx @@ -37,12 +37,14 @@ import { EarnPosition, Position } from 'src/positions/types' import { useSelector } from 'src/redux/hooks' import EnterAmountOptions from 'src/send/EnterAmountOptions' import { NETWORK_NAMES } from 'src/shared/conts' +import { getFeatureGate } from 'src/statsig' +import { StatsigFeatureGates } from 'src/statsig/types' import Colors from 'src/styles/colors' import { typeScale } from 'src/styles/fonts' import { Spacing } from 'src/styles/styles' import { SwapTransaction } from 'src/swap/types' -import { useTokenInfo } from 'src/tokens/hooks' -import { feeCurrenciesSelector, swappableFromTokensByNetworkIdSelector } from 'src/tokens/selectors' +import { useSwappableTokens, useTokenInfo } from 'src/tokens/hooks' +import { feeCurrenciesSelector } from 'src/tokens/selectors' import { TokenBalance } from 'src/tokens/slice' import Logger from 'src/utils/Logger' import { getFeeCurrencyAndAmounts, PreparedTransactionsResult } from 'src/viem/prepareTransactions' @@ -56,19 +58,39 @@ const TAG = 'EarnEnterAmount' function useTokens({ pool }: { pool: EarnPosition }) { const depositToken = useTokenInfo(pool.dataProps.depositTokenId) const withdrawToken = useTokenInfo(pool.dataProps.withdrawTokenId) - const swappableTokens = useSelector((state) => - swappableFromTokensByNetworkIdSelector(state, [pool.networkId]) + const { swappableFromTokens: swappableTokens } = useSwappableTokens() + const allowCrossChainSwapAndDeposit = getFeatureGate( + StatsigFeatureGates.ALLOW_CROSS_CHAIN_SWAP_AND_DEPOSIT ) const eligibleSwappableTokens = useMemo( () => - swappableTokens.filter( - ({ tokenId, balance }) => - tokenId !== pool.dataProps.depositTokenId && - tokenId !== pool.dataProps.withdrawTokenId && - balance.gt(0) - ), - [swappableTokens, pool.dataProps.depositTokenId, pool.dataProps.withdrawTokenId] + swappableTokens + .filter( + ({ tokenId, balance, networkId }) => + (allowCrossChainSwapAndDeposit || networkId === pool.networkId) && + tokenId !== pool.dataProps.depositTokenId && + tokenId !== pool.dataProps.withdrawTokenId && + balance.gt(0) + ) + .sort((token1, token2) => { + // Sort pool network tokens first, otherwise by USD balance (which + // should be the default already from the useSwappableTokens hook) + if (token1.networkId === pool.networkId && token2.networkId !== pool.networkId) { + return -1 + } + if (token1.networkId !== pool.networkId && token2.networkId === pool.networkId) { + return 1 + } + return 0 + }), + [ + swappableTokens, + pool.dataProps.depositTokenId, + pool.dataProps.withdrawTokenId, + pool.networkId, + allowCrossChainSwapAndDeposit, + ] ) if (!depositToken) { @@ -296,11 +318,13 @@ export default function EarnEnterAmount({ route }: Props) { amountInUsd: processedAmounts.token.bignum.multipliedBy(inputToken.priceUsd ?? 0).toFixed(2), amountEnteredIn: amountType, depositTokenId: pool.dataProps.depositTokenId, - networkId: inputToken.networkId, + networkId: pool.networkId, providerId: pool.appId, poolId: pool.positionId, fromTokenId: inputToken.tokenId, fromTokenAmount: processedAmounts.token.bignum.toString(), + fromNetworkId: inputToken.networkId, + swapType: swapTransaction?.swapType, mode, depositTokenAmount: isWithdrawal ? undefined diff --git a/src/earn/prepareTransactions.test.ts b/src/earn/prepareTransactions.test.ts index 446a7924f30..a1932192fe0 100644 --- a/src/earn/prepareTransactions.test.ts +++ b/src/earn/prepareTransactions.test.ts @@ -12,7 +12,7 @@ import { StatsigDynamicConfigs } from 'src/statsig/types' import { TokenBalance } from 'src/tokens/slice' import { NetworkId } from 'src/transactions/types' import { prepareTransactions } from 'src/viem/prepareTransactions' -import { mockEarnPositions, mockRewardsPositions } from 'test/values' +import { mockCeloTokenBalance, mockEarnPositions, mockRewardsPositions } from 'test/values' import { Address, encodeFunctionData } from 'viem' const mockFeeCurrency: TokenBalance = { @@ -143,6 +143,7 @@ describe('prepareTransactions', () => { it.each([ { isNative: true, testSuffix: 'native token', token: mockFeeCurrency }, { isNative: false, testSuffix: 'non native token', token: mockToken }, + { isNative: true, testSuffix: 'cross chain token', token: mockCeloTokenBalance }, ])( 'prepares transactions using swap-deposit shortcut ($testSuffix)', async ({ isNative, token }) => { @@ -206,7 +207,7 @@ describe('prepareTransactions', () => { isGasSubsidized: false, origin: 'earn-swap-deposit', }) - expect(isGasSubsidizedForNetwork).toHaveBeenCalledWith(mockToken.networkId) + expect(isGasSubsidizedForNetwork).toHaveBeenCalledWith(token.networkId) expect(triggerShortcutRequest).toHaveBeenCalledWith('https://hooks.api', { address: '0x1234', appId: mockEarnPositions[0].appId, @@ -219,6 +220,7 @@ describe('prepareTransactions', () => { decimals: token.decimals, address: token.address, isNative, + networkId: token.networkId, }, }) } diff --git a/src/earn/prepareTransactions.ts b/src/earn/prepareTransactions.ts index 9c9be4bd627..fb54bf06599 100644 --- a/src/earn/prepareTransactions.ts +++ b/src/earn/prepareTransactions.ts @@ -53,6 +53,7 @@ export async function prepareDepositTransactions({ decimals: token.decimals, address: token.address, isNative: token.isNative ?? false, + networkId: token.networkId, }, enableAppFee, } diff --git a/src/earn/saga.test.ts b/src/earn/saga.test.ts index 1d786738658..64d66f7cd77 100644 --- a/src/earn/saga.test.ts +++ b/src/earn/saga.test.ts @@ -23,12 +23,15 @@ import { Network, NetworkId, TokenTransactionTypeV2 } from 'src/transactions/typ import { publicClient } from 'src/viem' import { SerializableTransactionRequest } from 'src/viem/preparedTransactionSerialization' import { sendPreparedTransactions } from 'src/viem/saga' +import { networkIdToNetwork } from 'src/web3/networkConfig' import { createMockStore } from 'test/utils' import { mockAaveArbUsdcTokenId, mockArbArbAddress, mockArbArbTokenId, mockArbUsdcTokenId, + mockCusdAddress, + mockCusdTokenId, mockEarnPositions, mockRewardsPositions, mockTokenBalances, @@ -132,6 +135,14 @@ describe('depositSubmitSaga', () => { call([publicClient[Network.Arbitrum], 'waitForTransactionReceipt'], { hash: '0x3' }), mockTxReceipt2, ], + [ + call([publicClient[Network.Celo], 'waitForTransactionReceipt'], { hash: '0x1' }), + mockTxReceipt1, + ], + [ + call([publicClient[Network.Celo], 'waitForTransactionReceipt'], { hash: '0x2' }), + mockTxReceipt2, + ], ] const expectedAnalyticsProps = { @@ -143,6 +154,7 @@ describe('depositSubmitSaga', () => { mode: 'deposit', fromTokenAmount: '100', fromTokenId: mockArbUsdcTokenId, + fromNetworkId: NetworkId['arbitrum-sepolia'], } const expectedApproveStandbyTx = { @@ -351,68 +363,103 @@ describe('depositSubmitSaga', () => { expect(mockIsGasSubsidizedCheck).not.toHaveBeenCalledWith(true) }) - it('sends approve and swap-deposit transactions, navigates home and dispatches the success action (gas subsidy off)', async () => { - jest.mocked(decodeFunctionData).mockReturnValue({ - functionName: 'approve', - args: ['0xspenderAddress', BigInt(5e19)], - }) - await expectSaga(depositSubmitSaga, { - type: depositStart.type, - payload: { - amount: '100', - pool: mockEarnPositions[0], - preparedTransactions: [ - { ...serializableApproveTx, to: mockArbArbAddress as Address }, - serializableDepositTx, - ], + it.each([ + { + swapType: 'same-chain', + fromTokenId: mockArbArbTokenId, + fromNetworkId: NetworkId['arbitrum-sepolia'], + fromAddress: mockArbArbAddress, + }, + { + swapType: 'cross-chain', + fromTokenId: mockCusdTokenId, + fromNetworkId: NetworkId['celo-alfajores'], + fromAddress: mockCusdAddress, + }, + ])( + 'sends approve and swap-deposit ($swapType) transactions, navigates home and dispatches the success action (gas subsidy off)', + async ({ swapType, fromTokenId, fromNetworkId, fromAddress }) => { + jest.mocked(decodeFunctionData).mockReturnValue({ + functionName: 'approve', + args: ['0xspenderAddress', BigInt(5e19)], + }) + await expectSaga(depositSubmitSaga, { + type: depositStart.type, + payload: { + amount: '100', + pool: mockEarnPositions[0], + preparedTransactions: [ + { ...serializableApproveTx, to: fromAddress as Address }, + serializableDepositTx, + ], + mode: 'swap-deposit', + fromTokenAmount: '50', + fromTokenId, + }, + }) + .withState(createMockStore({ tokens: { tokenBalances: mockTokenBalances } }).getState()) + .provide(sagaProviders) + .put( + depositSuccess({ + tokenId: mockArbUsdcTokenId, + networkId: NetworkId['arbitrum-sepolia'], + transactionHash: '0x2', + }) + ) + .call.like({ fn: sendPreparedTransactions }) + .call([publicClient[networkIdToNetwork[fromNetworkId]], 'waitForTransactionReceipt'], { + hash: '0x1', + }) + .call([publicClient[networkIdToNetwork[fromNetworkId]], 'waitForTransactionReceipt'], { + hash: '0x2', + }) + .run() + expect(navigateHome).toHaveBeenCalled() + expect(decodeFunctionData).toHaveBeenCalledWith({ + abi: erc20Abi, + data: serializableApproveTx.data, + }) + expect(mockStandbyHandler).toHaveBeenCalledTimes(2) + expect(mockStandbyHandler).toHaveBeenNthCalledWith(1, { + ...expectedApproveStandbyTx, + approvedAmount: '50', + tokenId: fromTokenId, + networkId: fromNetworkId, + }) + expect(mockStandbyHandler).toHaveBeenNthCalledWith(2, { + ...expectedSwapDepositStandbyTx, + networkId: fromNetworkId, + swap: { + ...expectedSwapDepositStandbyTx.swap, + outAmount: { + value: '50', + tokenId: fromTokenId, + }, + }, + }) + expect(AppAnalytics.track).toHaveBeenCalledWith(EarnEvents.earn_deposit_submit_start, { + ...expectedAnalyticsProps, + fromTokenAmount: '50', + fromTokenId, + fromNetworkId, + swapType, mode: 'swap-deposit', + }) + expect(AppAnalytics.track).toHaveBeenCalledWith(EarnEvents.earn_deposit_submit_success, { + ...expectedAnalyticsProps, + ...expectedCumulativeGasAnalyticsProperties, fromTokenAmount: '50', - fromTokenId: mockArbArbTokenId, - }, - }) - .withState(createMockStore({ tokens: { tokenBalances: mockTokenBalances } }).getState()) - .provide(sagaProviders) - .put( - depositSuccess({ - tokenId: mockArbUsdcTokenId, - networkId: NetworkId['arbitrum-sepolia'], - transactionHash: '0x2', - }) - ) - .call.like({ fn: sendPreparedTransactions }) - .call([publicClient[Network.Arbitrum], 'waitForTransactionReceipt'], { hash: '0x1' }) - .call([publicClient[Network.Arbitrum], 'waitForTransactionReceipt'], { hash: '0x2' }) - .run() - expect(navigateHome).toHaveBeenCalled() - expect(decodeFunctionData).toHaveBeenCalledWith({ - abi: erc20Abi, - data: serializableApproveTx.data, - }) - expect(mockStandbyHandler).toHaveBeenCalledTimes(2) - expect(mockStandbyHandler).toHaveBeenNthCalledWith(1, { - ...expectedApproveStandbyTx, - approvedAmount: '50', - tokenId: mockArbArbTokenId, - }) - expect(mockStandbyHandler).toHaveBeenNthCalledWith(2, expectedSwapDepositStandbyTx) - expect(AppAnalytics.track).toHaveBeenCalledWith(EarnEvents.earn_deposit_submit_start, { - ...expectedAnalyticsProps, - fromTokenAmount: '50', - fromTokenId: mockArbArbTokenId, - mode: 'swap-deposit', - }) - expect(AppAnalytics.track).toHaveBeenCalledWith(EarnEvents.earn_deposit_submit_success, { - ...expectedAnalyticsProps, - ...expectedCumulativeGasAnalyticsProperties, - fromTokenAmount: '50', - fromTokenId: mockArbArbTokenId, - mode: 'swap-deposit', - }) - expect(mockIsGasSubsidizedCheck).toHaveBeenCalledWith(false) - expect(mockIsGasSubsidizedCheck).not.toHaveBeenCalledWith(true) - }) + fromTokenId, + fromNetworkId, + swapType, + mode: 'swap-deposit', + }) + expect(mockIsGasSubsidizedCheck).toHaveBeenCalledWith(false) + expect(mockIsGasSubsidizedCheck).not.toHaveBeenCalledWith(true) + } + ) - it('sends only swap-deposit transaction, navigates home and dispatches the success action (gas subsidy on)', async () => { + it('sends only swap-deposit (same-chain) transaction, navigates home and dispatches the success action (gas subsidy on)', async () => { jest.mocked(isGasSubsidizedForNetwork).mockReturnValue(true) await expectSaga(depositSubmitSaga, { type: depositStart.type, @@ -445,6 +492,7 @@ describe('depositSubmitSaga', () => { ...expectedAnalyticsProps, fromTokenAmount: '50', fromTokenId: mockArbArbTokenId, + swapType: 'same-chain', mode: 'swap-deposit', }) expect(AppAnalytics.track).toHaveBeenCalledWith(EarnEvents.earn_deposit_submit_success, { @@ -455,6 +503,7 @@ describe('depositSubmitSaga', () => { gasUsed: 371674, fromTokenAmount: '50', fromTokenId: mockArbArbTokenId, + swapType: 'same-chain', mode: 'swap-deposit', }) expect(mockIsGasSubsidizedCheck).toHaveBeenCalledWith(true) diff --git a/src/earn/saga.ts b/src/earn/saga.ts index 1565b647fdb..7b4d471a0dc 100644 --- a/src/earn/saga.ts +++ b/src/earn/saga.ts @@ -98,16 +98,24 @@ export function* depositSubmitSaga(action: PayloadAction) { ) const trackedTxs: TrackedTx[] = [] - const networkId = pool.networkId + const poolNetworkId = pool.networkId + const fromNetworkId = fromTokenInfo.networkId const commonAnalyticsProps = { depositTokenId, depositTokenAmount: amount, - networkId, + networkId: poolNetworkId, providerId: pool.appId, poolId: pool.positionId, fromTokenAmount, fromTokenId, + fromNetworkId, mode, + swapType: + mode === 'swap-deposit' + ? fromNetworkId === poolNetworkId + ? ('same-chain' as const) + : ('cross-chain' as const) + : undefined, } let submitted = false @@ -152,7 +160,7 @@ export function* depositSubmitSaga(action: PayloadAction) { ): BaseStandbyTransaction => { return { context: newTransactionContext(TAG, 'Earn/Approve'), - networkId, + networkId: fromNetworkId, type: TokenTransactionTypeV2.Approval, transactionHash, tokenId: fromTokenId, @@ -176,7 +184,7 @@ export function* depositSubmitSaga(action: PayloadAction) { ): BaseStandbyTransaction => { return { context: newTransactionContext(TAG, 'Earn/Deposit'), - networkId, + networkId: fromNetworkId, type: TokenTransactionTypeV2.EarnDeposit, inAmount: { value: amount, @@ -197,7 +205,7 @@ export function* depositSubmitSaga(action: PayloadAction) { ): BaseStandbyTransaction => { return { context: newTransactionContext(TAG, 'Earn/SwapDeposit'), - networkId, + networkId: fromNetworkId, type: TokenTransactionTypeV2.EarnSwapDeposit, swap: { inAmount: { value: amount, tokenId: depositTokenId }, @@ -225,9 +233,9 @@ export function* depositSubmitSaga(action: PayloadAction) { const txHashes = yield* call( sendPreparedTransactions, serializablePreparedTransactions, - networkId, + fromNetworkId, createDepositStandbyTxHandlers, - isGasSubsidizedForNetwork(networkId) + isGasSubsidizedForNetwork(fromNetworkId) ) txHashes.forEach((txHash, i) => { trackedTxs[i].txHash = txHash @@ -246,9 +254,12 @@ export function* depositSubmitSaga(action: PayloadAction) { Logger.debug(`${TAG}/depositSubmitSaga`, 'Waiting for transaction receipts') const txReceipts = yield* all( txHashes.map((txHash) => { - return call([publicClient[networkIdToNetwork[networkId]], 'waitForTransactionReceipt'], { - hash: txHash, - }) + return call( + [publicClient[networkIdToNetwork[fromNetworkId]], 'waitForTransactionReceipt'], + { + hash: txHash, + } + ) }) ) txReceipts.forEach((receipt, index) => { @@ -265,14 +276,17 @@ export function* depositSubmitSaga(action: PayloadAction) { throw new Error(`Deposit transaction reverted: ${depositTxReceipt?.transactionHash}`) } + // TODO(ACT-1514): for cross chain swaps, fire this when the tx feed + // confirms it, similar to swaps (or consider firing a new event, since we + // have some gas properties here that can be useful for all txs) AppAnalytics.track(EarnEvents.earn_deposit_submit_success, { ...commonAnalyticsProps, - ...getDepositTxsReceiptAnalyticsProperties(trackedTxs, networkId, tokensById), + ...getDepositTxsReceiptAnalyticsProperties(trackedTxs, poolNetworkId, tokensById), }) yield* put( depositSuccess({ tokenId: depositTokenInfo.tokenId, - networkId, + networkId: poolNetworkId, transactionHash: txHashes[txHashes.length - 1], }) ) @@ -290,7 +304,7 @@ export function* depositSubmitSaga(action: PayloadAction) { AppAnalytics.track(EarnEvents.earn_deposit_submit_error, { ...commonAnalyticsProps, error: error.message, - ...getDepositTxsReceiptAnalyticsProperties(trackedTxs, networkId, tokensById), + ...getDepositTxsReceiptAnalyticsProperties(trackedTxs, poolNetworkId, tokensById), }) // Only vibrate if we haven't already submitted the transaction diff --git a/src/statsig/types.ts b/src/statsig/types.ts index c97ddcdcb64..0fe7af1a661 100644 --- a/src/statsig/types.ts +++ b/src/statsig/types.ts @@ -34,6 +34,7 @@ export enum StatsigFeatureGates { SHOW_UK_COMPLIANT_VARIANT = 'show_uk_compliant_variant', ALLOW_EARN_PARTIAL_WITHDRAWAL = 'allow_earn_partial_withdrawal', SHOW_ZERION_TRANSACTION_FEED = 'show_zerion_transaction_feed', + ALLOW_CROSS_CHAIN_SWAP_AND_DEPOSIT = 'allow_cross_chain_swap_and_deposit', } export enum StatsigExperiments { From 623e6683e66fcc7aeaa0e9438a26639edafd1b79 Mon Sep 17 00:00:00 2001 From: Satish Ravi Date: Fri, 20 Dec 2024 11:33:40 -0800 Subject: [PATCH 2/2] fix tests --- src/earn/EarnEnterAmount.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/earn/EarnEnterAmount.test.tsx b/src/earn/EarnEnterAmount.test.tsx index b3881a2c969..c2e292e9585 100644 --- a/src/earn/EarnEnterAmount.test.tsx +++ b/src/earn/EarnEnterAmount.test.tsx @@ -492,7 +492,7 @@ describe('EarnEnterAmount', () => { jest.mocked(usePrepareEnterAmountTransactionsCallback).mockReturnValue({ prepareTransactionsResult: { prepareTransactionsResult: mockPreparedTransaction, - swapTransaction: mockCrossChainSwapTransaction, + swapTransaction: mockSwapTransaction, }, refreshPreparedTransactions: jest.fn(), clearPreparedTransactions: jest.fn(), @@ -539,7 +539,7 @@ describe('EarnEnterAmount', () => { fromNetworkId: NetworkId['arbitrum-sepolia'], depositTokenAmount: '0.99999', mode: 'swap-deposit', - swapType: 'cross-chain', + swapType: 'same-chain', }) await waitFor(() => expect(getByText('earnFlow.depositBottomSheet.title')).toBeVisible()) }) @@ -706,6 +706,7 @@ describe('EarnEnterAmount', () => { poolId: mockEarnPositions[0].positionId, fromTokenId: 'arbitrum-sepolia:0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8', fromTokenAmount: '8', + fromNetworkId: NetworkId['arbitrum-sepolia'], mode: 'withdraw', })