Skip to content

Commit

Permalink
feat(earn): Add EarnHome screen (#5656)
Browse files Browse the repository at this point in the history
### Description

Move over necessary multi-pool earn stuff from earnV2. Two pools shown
for testing purposes but the PR only includes the single Aave pool
currently supported.

Will add analytics and hook stuff up in follow-up PRs

### Test plan


https://github.com/user-attachments/assets/c6a76424-367f-4d26-84ec-7e0343dd50c0

| Open pools | My pools | Bottom sheet |
| ----- | ----- | ----- | 
|
![open](https://github.com/user-attachments/assets/4e9b6d0a-9bfb-40e3-bf96-44341ef116af)
|
![my](https://github.com/user-attachments/assets/d5ff397a-5f80-4d40-9523-5e5194cb48da)
|
![bottom-sheet](https://github.com/user-attachments/assets/6d739afc-0885-4eff-9ecc-273bdfeaf9aa)
|

### Related issues

- Part of ACT-1260

### Backwards compatibility

Yes
### Network scalability

If a new NetworkId and/or Network are added in the future, the changes
in this PR will:

- [X] Continue to work without code changes, OR trigger a compilation
error (guaranteeing we find it when a new network is added)

---------

Co-authored-by: Satish Ravi <satish.ravi@valoraapp.com>
  • Loading branch information
finnian0826 and satish-ravi authored Jul 24, 2024
1 parent 40c144e commit eee9d34
Show file tree
Hide file tree
Showing 16 changed files with 1,283 additions and 5 deletions.
29 changes: 28 additions & 1 deletion locales/base/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2015,7 +2015,8 @@
"network": "{{networkName}} Network",
"selectNetwork": "Network",
"stablecoins": "Stablecoins",
"gasTokens": "Gas Tokens"
"gasTokens": "Gas Tokens",
"tokens": "Token"
}
},
"homeActions": {
Expand Down Expand Up @@ -2571,6 +2572,32 @@
"earnWithdrawTitle": "Withdrew",
"earnWithdrawSubtitle": "Withdrew {{tokenSymbol}} from {{providerName}} Pool",
"earnWithdrawDetails": "Amount Withdrawn"
},
"home": {
"title": "Earn",
"learnMore": "<0>Learn more</0> about yield pools.",
"learnMoreBottomSheet": {
"bottomSheetTitle": "Learn more about yield pools",
"apySubtitle": "What is an APY?",
"apyDescription": "Annual percentage yield (APY) is a metric used to calculate the annualized return on crypto investments. It's a key indicator of a cryptocurrency's potential return and profitability.",
"tvlSubtitle": "What is TVL?",
"tvlDescription": "TVL stands for Total Value Locked, and it's a metric used in the crypto industry to measure the value of digital assets locked on a blockchain network. TVL is calculated by adding up the value of all digital assets locked in a DeFi protocol or smart contract. These assets can include cryptocurrencies, stablecoins, and other tokens."
}
},
"poolCard": {
"onNetwork": "on {{networkName}}",
"rate": "Rate (est.)",
"reward": "Reward",
"tvl": "TVL",
"exitPool": "Exit Pool",
"addToPool": "Add to Pool",
"apy": "{{apy}}% APY",
"poweredBy": "Powered by {{providerName}}",
"deposited": "Supplied"
},
"poolFilters": {
"openPools": "Open Pools",
"myPools": "My Pools"
}
}
}
16 changes: 14 additions & 2 deletions src/components/FilterChipsCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,28 @@ export interface NetworkFilterChip<T> extends BaseFilterChip {
selectedNetworkIds: NetworkId[]
}

export interface TokenSelectFilterChip<T> extends BaseFilterChip {
filterFn: (t: T, tokenId: string) => boolean
selectedTokenId: string
}

export function isNetworkChip<T>(chip: FilterChip<T>): chip is NetworkFilterChip<T> {
return 'allNetworkIds' in chip
}

export type FilterChip<T> = BooleanFilterChip<T> | NetworkFilterChip<T>
export function isTokenSelectChip<T>(chip: FilterChip<T>): chip is TokenSelectFilterChip<T> {
return 'selectedTokenId' in chip
}

export type FilterChip<T> = BooleanFilterChip<T> | NetworkFilterChip<T> | TokenSelectFilterChip<T>

interface Props<T> {
chips: FilterChip<T>[]
onSelectChip(chip: FilterChip<T>): void
primaryColor: colors
secondaryColor: colors
style?: StyleProp<ViewStyle>
contentContainerStyle?: StyleProp<ViewStyle>
forwardedRef?: React.RefObject<ScrollView>
scrollEnabled?: boolean
}
Expand All @@ -45,6 +55,7 @@ function FilterChipsCarousel<T>({
primaryColor,
secondaryColor,
style,
contentContainerStyle,
forwardedRef,
scrollEnabled = true,
}: Props<T>) {
Expand All @@ -57,6 +68,7 @@ function FilterChipsCarousel<T>({
contentContainerStyle={[
styles.contentContainer,
{ flexWrap: scrollEnabled ? 'nowrap' : 'wrap', width: scrollEnabled ? 'auto' : '100%' },
contentContainerStyle,
]}
ref={forwardedRef}
testID="FilterChipsCarousel"
Expand Down Expand Up @@ -87,7 +99,7 @@ function FilterChipsCarousel<T>({
>
{chip.name}
</Text>
{isNetworkChip(chip) && (
{(isNetworkChip(chip) || isTokenSelectChip(chip)) && (
<DownArrowIcon
color={chip.isSelected ? secondaryColor : primaryColor}
strokeWidth={2}
Expand Down
5 changes: 5 additions & 0 deletions src/components/TokenBottomSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import FilterChipsCarousel, {
FilterChip,
NetworkFilterChip,
isNetworkChip,
isTokenSelectChip,
} from 'src/components/FilterChipsCarousel'
import SearchInput from 'src/components/SearchInput'
import NetworkMultiSelectBottomSheet from 'src/components/multiSelect/NetworkMultiSelectBottomSheet'
Expand All @@ -34,6 +35,7 @@ export enum TokenPickerOrigin {
CashIn = 'CashIn',
CashOut = 'CashOut',
Spend = 'Spend',
Earn = 'Earn',
}

export const DEBOUNCE_WAIT_TIME = 200
Expand Down Expand Up @@ -215,6 +217,9 @@ function TokenBottomSheet({
if (isNetworkChip(filter)) {
return filter.filterFn(token, filter.selectedNetworkIds)
}
if (isTokenSelectChip(filter)) {
return filter.filterFn(token, filter.selectedTokenId)
}
return filter.filterFn(token)
})
) {
Expand Down
268 changes: 268 additions & 0 deletions src/earn/EarnHome.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import { fireEvent, render } from '@testing-library/react-native'
import React from 'react'
import { Provider } from 'react-redux'
import EarnHome from 'src/earn/EarnHome'
import { getPools } from 'src/earn/pools'
import { EarnTabType, Pool } from 'src/earn/types'
import { NetworkId } from 'src/transactions/types'
import MockedNavigator from 'test/MockedNavigator'
import { createMockStore } from 'test/utils'
import {
mockArbEthTokenId,
mockArbUsdcTokenId,
mockEthTokenId,
mockTokenBalances,
} from 'test/values'

const mockPools: Pool[] = [
{
poolId: 'aArbUSDCn',
networkId: NetworkId['arbitrum-sepolia'],
tokens: [mockArbUsdcTokenId],
depositTokenId: mockArbUsdcTokenId,
poolTokenId: `${NetworkId['arbitrum-sepolia']}:0x724dc807b04555b71ed48a6896b6f41593b8c637`,
poolAddress: '0x794a61358D6845594F94dc1DB02A252b5b4814aD',
apy: 0.0555,
reward: 0,
tvl: 349_940_000,
provider: 'Aave',
},
{
poolId: 'aArbWETH',
networkId: NetworkId['arbitrum-sepolia'],
tokens: [mockArbEthTokenId],
depositTokenId: mockArbEthTokenId,
poolTokenId: `${NetworkId['arbitrum-sepolia']}:0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8`,
poolAddress: '0x794a61358D6845594F94dc1DB02A252b5b4814aD',
apy: 0.023,
reward: 0,
tvl: 411_630_000,
provider: 'Aave',
},
]

const mockPoolTokenUSDC = {
name: 'Aave Arbitrum USDC',
networkId: NetworkId['arbitrum-sepolia'],
tokenId: mockPools[0].poolTokenId,
address: '0x724dc807b04555b71ed48a6896b6f41593b8c637',
symbol: 'aArbUSDCn',
decimals: 18,
imageUrl:
'https://raw.githubusercontent.com/valora-inc/address-metadata/main/assets/tokens/ARB.png',
balance: '0',
priceUsd: '1.2',
priceFetchedAt: Date.now(),
}

const mockPoolTokenWETH = {
name: 'Aave Arbitrum WETH',
networkId: NetworkId['arbitrum-sepolia'],
tokenId: mockPools[1].poolTokenId,
address: '0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8',
symbol: 'aArbWETH',
decimals: 18,
imageUrl:
'https://raw.githubusercontent.com/valora-inc/address-metadata/main/assets/tokens/ETH.png',
balance: '0',
priceUsd: '250',
priceFetchedAt: Date.now(),
}

const mockPoolTokenEthWETH = {
name: 'Aave Ethereum WETH',
networkId: NetworkId['ethereum-sepolia'],
tokenId: `${NetworkId['ethereum-sepolia']}:0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8`,
address: '0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8',
symbol: 'aEthWETH',
decimals: 18,
imageUrl:
'https://raw.githubusercontent.com/valora-inc/address-metadata/main/assets/tokens/ETH.png',
balance: '0',
priceUsd: '250',
priceFetchedAt: Date.now(),
}

jest.mock('src/earn/pools')

describe('EarnHome', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('renders open pools correctly', () => {
jest.mocked(getPools).mockReturnValue(mockPools)
const mockPoolToken = mockPoolTokenUSDC
const { getByTestId, getAllByTestId } = render(
<Provider
store={createMockStore({
tokens: {
tokenBalances: {
...mockTokenBalances,
[mockPools[0].poolTokenId]: mockPoolToken,
[mockPools[1].poolTokenId]: mockPoolTokenWETH,
},
},
})}
>
<MockedNavigator
component={EarnHome}
params={{
activeEarnTab: EarnTabType.OpenPools,
}}
/>
</Provider>
)

expect(getByTestId(`PoolCard/${mockPools[0].poolId}`)).toBeTruthy()
expect(getByTestId(`PoolCard/${mockPools[1].poolId}`)).toBeTruthy()

const tabItems = getAllByTestId('Earn/TabBarItem')
expect(tabItems).toHaveLength(2)
expect(tabItems[0]).toHaveTextContent('openPools')
expect(tabItems[1]).toHaveTextContent('myPools')
})

it('correctly shows pool under my pools if has balance', () => {
jest.mocked(getPools).mockReturnValue(mockPools)
const mockPoolToken = {
...mockPoolTokenUSDC,
balance: '10',
}
const { getByTestId, queryByTestId, getByText } = render(
<Provider
store={createMockStore({
tokens: {
tokenBalances: {
...mockTokenBalances,
[mockPools[0].poolTokenId]: mockPoolToken,
[mockPools[1].poolTokenId]: mockPoolTokenWETH,
},
},
})}
>
<MockedNavigator
component={EarnHome}
params={{
activeEarnTab: EarnTabType.OpenPools,
}}
/>
</Provider>
)

expect(queryByTestId(`PoolCard/${mockPools[0].poolId}`)).toBeFalsy()
fireEvent.press(getByText('earnFlow.poolFilters.myPools'))
expect(getByTestId(`PoolCard/${mockPools[0].poolId}`)).toBeTruthy()
})

it('correctly shows correct networks, tokens under filters', () => {
jest.mocked(getPools).mockReturnValue(mockPools)
const mockPoolToken = mockPoolTokenUSDC
const { getByTestId, getAllByTestId, getByText } = render(
<Provider
store={createMockStore({
tokens: {
tokenBalances: {
...mockTokenBalances,
[mockPools[0].poolTokenId]: mockPoolToken,
[mockPools[1].poolTokenId]: mockPoolTokenWETH,
},
},
})}
>
<MockedNavigator
component={EarnHome}
params={{
activeEarnTab: EarnTabType.OpenPools,
}}
/>
</Provider>
)

fireEvent.press(getByText('tokenBottomSheet.filters.selectNetwork'))
expect(getByTestId('Arbitrum Sepolia-icon')).toBeTruthy()

fireEvent.press(getByText('tokenBottomSheet.filters.tokens'))
expect(getAllByTestId('TokenBalanceItem')).toHaveLength(2)
})

it('shows correct pool when filtering by token', () => {
jest.mocked(getPools).mockReturnValue(mockPools)
const { getByTestId, getByText, queryByTestId } = render(
<Provider
store={createMockStore({
tokens: {
tokenBalances: {
...mockTokenBalances,
[mockPools[0].poolTokenId]: mockPoolTokenUSDC,
[mockPools[1].poolTokenId]: mockPoolTokenWETH,
},
},
})}
>
<MockedNavigator
component={EarnHome}
params={{
activeEarnTab: EarnTabType.OpenPools,
}}
/>
</Provider>
)

expect(getByTestId(`PoolCard/${mockPools[0].poolId}`)).toBeTruthy()
expect(getByTestId(`PoolCard/${mockPools[1].poolId}`)).toBeTruthy()

fireEvent.press(getByText('tokenBottomSheet.filters.tokens'))
fireEvent.press(getByTestId('USDCSymbol'))

expect(getByTestId(`PoolCard/${mockPools[0].poolId}`)).toBeTruthy()
expect(queryByTestId(`PoolCard/${mockPools[1].poolId}`)).toBeFalsy()
})

it('shows correct pool when filtering by network', () => {
const mockPoolsForNetworkFilter: Pool[] = [
mockPools[0],
{
poolId: 'aEthWETH',
networkId: NetworkId['ethereum-sepolia'],
tokens: [mockEthTokenId],
depositTokenId: mockEthTokenId,
poolTokenId: `${NetworkId['ethereum-sepolia']}:0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8`,
poolAddress: '0x794a61358D6845594F94dc1DB02A252b5b4814aD',
apy: 0.023,
reward: 0,
tvl: 411_630_000,
provider: 'Aave',
},
]
jest.mocked(getPools).mockReturnValue(mockPoolsForNetworkFilter)
const { getByTestId, getByText, queryByTestId } = render(
<Provider
store={createMockStore({
tokens: {
tokenBalances: {
...mockTokenBalances,
[mockPoolsForNetworkFilter[0].poolTokenId]: mockPoolTokenUSDC,
[mockPoolsForNetworkFilter[1].poolTokenId]: mockPoolTokenEthWETH,
},
},
})}
>
<MockedNavigator
component={EarnHome}
params={{
activeEarnTab: EarnTabType.OpenPools,
}}
/>
</Provider>
)

expect(getByTestId(`PoolCard/${mockPoolsForNetworkFilter[0].poolId}`)).toBeTruthy()
expect(getByTestId(`PoolCard/${mockPoolsForNetworkFilter[1].poolId}`)).toBeTruthy()

fireEvent.press(getByText('tokenBottomSheet.filters.selectNetwork'))
fireEvent.press(getByTestId('Arbitrum Sepolia-icon'))

expect(getByTestId(`PoolCard/${mockPoolsForNetworkFilter[0].poolId}`)).toBeTruthy()
expect(queryByTestId(`PoolCard/${mockPoolsForNetworkFilter[1].poolId}`)).toBeFalsy()
})
})
Loading

0 comments on commit eee9d34

Please sign in to comment.