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(warning): Add generic gas fee warning component #6385

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 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
21 changes: 21 additions & 0 deletions locales/base/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2823,5 +2823,26 @@
"availableBalance": "Available: <0></0>",
"selectToken": "Select token",
"fiatPriceUnavailable": "Price unavailable"
},
"gasFeeWarning": {
"title": "You need more {{tokenSymbol}} for gas fees",
"descriptionMaxAmount": "Add {{tokenSymbol}} for gas fees or lower the amount you're {{action}}",
"descriptionNotEnoughGas": "Adjust the amount you're {{action}} or add {{tokenSymbol}} to continue",
"titleDapp": "You have an insufficient gas token balance",
"descriptionDapp": "Add {{tokenSymbol}} to complete this transaction",
"cta": "Buy {{tokenSymbol}}",
"ctaGasToken": "{{verb}} smaller amount",
"actions": {
"sending": "sending",
"swapping": "swapping",
"depositing": "depositing",
"withdrawing": "withdrawing"
},
"verb": {
MuckT marked this conversation as resolved.
Show resolved Hide resolved
"send": "Send",
"swap": "Swap",
"deposit": "Deposit",
"withdraw": "Withdraw"
}
}
}
2 changes: 2 additions & 0 deletions src/analytics/Events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export enum AppEvents {
in_app_review_error = 'in_app_review_error',

handle_deeplink = 'handle_deeplink',

show_gas_fee_warning = 'show_gas_fee_warning',
}

export enum HomeEvents {
Expand Down
6 changes: 6 additions & 0 deletions src/analytics/Properties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
} from 'src/analytics/types'
import { ErrorMessages } from 'src/app/ErrorMessages'
import { AddAssetsActionType } from 'src/components/AddAssetsBottomSheet'
import { GasFeeWarningFlow } from 'src/components/GasFeeWarning'
import { TokenPickerOrigin } from 'src/components/TokenBottomSheet'
import { DappSection } from 'src/dapps/types'
import { BeforeDepositActionName, EarnActiveMode, SerializableRewardsInfo } from 'src/earn/types'
Expand Down Expand Up @@ -152,6 +153,11 @@ interface AppEventsProperties {
fullPath: string | null
query: string | null
}
[AppEvents.show_gas_fee_warning]: {
flow: GasFeeWarningFlow
errorType: 'possible' | 'need-decrease-spend-amount-for-gas' | 'not-enough-balance-for-gas'
tokenNeeded: string
MuckT marked this conversation as resolved.
Show resolved Hide resolved
}
}

interface HomeEventsProperties {
Expand Down
1 change: 1 addition & 0 deletions src/analytics/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const eventDocs: Record<AnalyticsEventType, string> = {
[AppEvents.in_app_review_impression]: `User sees an in-app review request`,
[AppEvents.in_app_review_error]: `Error while attempting to display in-app review`,
[AppEvents.handle_deeplink]: `When a deeplink that leads into the app is detected and handled`,
[AppEvents.show_gas_fee_warning]: `When the gas fee warning is shown to the user`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
[AppEvents.show_gas_fee_warning]: `When the gas fee warning is shown to the user`,
[AppEvents.gas_fee_warning_impression]: `When the gas fee warning is shown to the user`,

?

[HomeEvents.account_circle_tapped]: `When the account circle used in the tab navigation is tapped`,
[HomeEvents.profile_address_copy]: `When a user copies their wallet address from the profile screen`,
[HomeEvents.notification_scroll]: ``,
Expand Down
161 changes: 161 additions & 0 deletions src/components/GasFeeWarning.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { fireEvent, render } from '@testing-library/react-native'
import BigNumber from 'bignumber.js'
import React from 'react'
import { Provider } from 'react-redux'
import AppAnalytics from 'src/analytics/AppAnalytics'
import { AppEvents } from 'src/analytics/Events'
import GasFeeWarning, { GasFeeWarningFlow } from 'src/components/GasFeeWarning'
import {
PreparedTransactionsNeedDecreaseSpendAmountForGas,
PreparedTransactionsNotEnoughBalanceForGas,
PreparedTransactionsPossible,
} from 'src/viem/prepareTransactions'
import { createMockStore } from 'test/utils'
import { mockArbEthTokenId, mockCeloTokenId, mockTokenBalances } from 'test/values'

const mockPreparedTransactionPossible: PreparedTransactionsPossible = {
type: 'possible' as const,
transactions: [],
feeCurrency: {
...mockTokenBalances[mockArbEthTokenId],
isNative: true,
balance: new BigNumber(10),
priceUsd: new BigNumber(1),
lastKnownPriceUsd: new BigNumber(1),
},
}

const mockPreparedTransactionNotEnoughCelo: PreparedTransactionsNotEnoughBalanceForGas = {
type: 'not-enough-balance-for-gas' as const,
feeCurrencies: [
{
...mockTokenBalances[mockCeloTokenId],
isNative: true,
balance: new BigNumber(0),
priceUsd: new BigNumber(1500),
lastKnownPriceUsd: new BigNumber(1500),
},
],
}

const mockPreparedTransactionNeedDecreaseCelo: PreparedTransactionsNeedDecreaseSpendAmountForGas = {
type: 'need-decrease-spend-amount-for-gas' as const,
feeCurrency: {
...mockTokenBalances[mockCeloTokenId],
isNative: true,
balance: new BigNumber(0),
priceUsd: new BigNumber(1500),
lastKnownPriceUsd: new BigNumber(1500),
},
maxGasFeeInDecimal: new BigNumber(1),
estimatedGasFeeInDecimal: new BigNumber(1),
decreasedSpendAmount: new BigNumber(1),
}

const mockPreparedTransactionNotEnoughEth: PreparedTransactionsNotEnoughBalanceForGas = {
type: 'not-enough-balance-for-gas' as const,
feeCurrencies: [
{
...mockTokenBalances[mockArbEthTokenId],
isNative: true,
balance: new BigNumber(0),
priceUsd: new BigNumber(1500),
lastKnownPriceUsd: new BigNumber(1500),
},
],
}

const mockPreparedTransactionNeedDecreaseEth: PreparedTransactionsNeedDecreaseSpendAmountForGas = {
type: 'need-decrease-spend-amount-for-gas' as const,
feeCurrency: {
...mockTokenBalances[mockArbEthTokenId],
isNative: true,
balance: new BigNumber(0),
priceUsd: new BigNumber(1500),
lastKnownPriceUsd: new BigNumber(1500),
},
maxGasFeeInDecimal: new BigNumber(1),
estimatedGasFeeInDecimal: new BigNumber(1),
decreasedSpendAmount: new BigNumber(1),
}

describe('GasFeeWarning', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should return null if prepareTransactionsResult is undefined', () => {
const store = createMockStore()
const { queryByTestId } = render(
<Provider store={store}>
<GasFeeWarning flow={GasFeeWarningFlow.Send} testIdPrefix={'test'} />
</Provider>
)
expect(queryByTestId('test/GasFeeWarning')).toBeNull()
})
it('should return null if prepareTransactionsResult.type is possible', () => {
const store = createMockStore()
const { queryByTestId } = render(
<Provider store={store}>
<GasFeeWarning
flow={GasFeeWarningFlow.Send}
testIdPrefix={'test'}
prepareTransactionsResult={mockPreparedTransactionPossible}
/>
</Provider>
)
expect(queryByTestId('test/GasFeeWarning')).toBeNull()
})
it.each`
scenario | flow | prepareTransactionsResult | feeCurrencySymbol | title | description | ctaLabel
${'sending max amount of CELO'} | ${GasFeeWarningFlow.Send} | ${mockPreparedTransactionNeedDecreaseCelo} | ${'CELO'} | ${'gasFeeWarning.title, {"tokenSymbol":"CELO"}'} | ${'gasFeeWarning.descriptionMaxAmount, {"action":"gasFeeWarning.actions.sending","tokenSymbol":"CELO"}'} | ${'gasFeeWarning.ctaGasToken, {"verb":"gasFeeWarning.verb.send"}'}
${'sending max amount of ETH'} | ${GasFeeWarningFlow.Send} | ${mockPreparedTransactionNeedDecreaseEth} | ${'ETH'} | ${'gasFeeWarning.title, {"tokenSymbol":"ETH"}'} | ${'gasFeeWarning.descriptionMaxAmount, {"action":"gasFeeWarning.actions.sending","tokenSymbol":"ETH"}'} | ${'gasFeeWarning.ctaGasToken, {"verb":"gasFeeWarning.verb.send"}'}
${'sending with insufficient CELO'} | ${GasFeeWarningFlow.Send} | ${mockPreparedTransactionNotEnoughCelo} | ${'CELO'} | ${'gasFeeWarning.title, {"tokenSymbol":"CELO"}'} | ${'gasFeeWarning.descriptionNotEnoughGas, {"action":"gasFeeWarning.actions.sending","tokenSymbol":"CELO"}'} | ${'gasFeeWarning.cta, {"tokenSymbol":"CELO"}'}
${'sending with insufficient ETH'} | ${GasFeeWarningFlow.Send} | ${mockPreparedTransactionNotEnoughEth} | ${'ETH'} | ${'gasFeeWarning.title, {"tokenSymbol":"ETH"}'} | ${'gasFeeWarning.descriptionNotEnoughGas, {"action":"gasFeeWarning.actions.sending","tokenSymbol":"ETH"}'} | ${'gasFeeWarning.cta, {"tokenSymbol":"ETH"}'}
${'swapping max amount of CELO'} | ${GasFeeWarningFlow.Swap} | ${mockPreparedTransactionNeedDecreaseCelo} | ${'CELO'} | ${'gasFeeWarning.title, {"tokenSymbol":"CELO"}'} | ${'gasFeeWarning.descriptionMaxAmount, {"action":"gasFeeWarning.actions.swapping","tokenSymbol":"CELO"}'} | ${'gasFeeWarning.ctaGasToken, {"verb":"gasFeeWarning.verb.swap"}'}
${'swapping max amount of ETH'} | ${GasFeeWarningFlow.Swap} | ${mockPreparedTransactionNeedDecreaseEth} | ${'ETH'} | ${'gasFeeWarning.title, {"tokenSymbol":"ETH"}'} | ${'gasFeeWarning.descriptionMaxAmount, {"action":"gasFeeWarning.actions.swapping","tokenSymbol":"ETH"}'} | ${'gasFeeWarning.ctaGasToken, {"verb":"gasFeeWarning.verb.swap"}'}
${'swapping with insufficient CELO'} | ${GasFeeWarningFlow.Swap} | ${mockPreparedTransactionNotEnoughCelo} | ${'CELO'} | ${'gasFeeWarning.title, {"tokenSymbol":"CELO"}'} | ${'gasFeeWarning.descriptionNotEnoughGas, {"action":"gasFeeWarning.actions.swapping","tokenSymbol":"CELO"}'} | ${'gasFeeWarning.cta, {"tokenSymbol":"CELO"}'}
${'swapping with insufficient ETH'} | ${GasFeeWarningFlow.Swap} | ${mockPreparedTransactionNotEnoughEth} | ${'ETH'} | ${'gasFeeWarning.title, {"tokenSymbol":"ETH"}'} | ${'gasFeeWarning.descriptionNotEnoughGas, {"action":"gasFeeWarning.actions.swapping","tokenSymbol":"ETH"}'} | ${'gasFeeWarning.cta, {"tokenSymbol":"ETH"}'}
${'withdrawing max amount of CELO'} | ${GasFeeWarningFlow.Withdraw} | ${mockPreparedTransactionNeedDecreaseCelo} | ${'CELO'} | ${'gasFeeWarning.title, {"tokenSymbol":"CELO"}'} | ${'gasFeeWarning.descriptionMaxAmount, {"action":"gasFeeWarning.actions.withdrawing","tokenSymbol":"CELO"}'} | ${'gasFeeWarning.ctaGasToken, {"verb":"gasFeeWarning.verb.withdraw"}'}
${'withdrawing max amount of ETH'} | ${GasFeeWarningFlow.Withdraw} | ${mockPreparedTransactionNeedDecreaseEth} | ${'ETH'} | ${'gasFeeWarning.title, {"tokenSymbol":"ETH"}'} | ${'gasFeeWarning.descriptionMaxAmount, {"action":"gasFeeWarning.actions.withdrawing","tokenSymbol":"ETH"}'} | ${'gasFeeWarning.ctaGasToken, {"verb":"gasFeeWarning.verb.withdraw"}'}
${'withdrawing with insufficient CELO'} | ${GasFeeWarningFlow.Withdraw} | ${mockPreparedTransactionNotEnoughCelo} | ${'CELO'} | ${'gasFeeWarning.title, {"tokenSymbol":"CELO"}'} | ${'gasFeeWarning.descriptionNotEnoughGas, {"action":"gasFeeWarning.actions.withdrawing","tokenSymbol":"CELO"}'} | ${'gasFeeWarning.cta, {"tokenSymbol":"CELO"}'}
${'withdrawing with insufficient ETH'} | ${GasFeeWarningFlow.Withdraw} | ${mockPreparedTransactionNotEnoughEth} | ${'ETH'} | ${'gasFeeWarning.title, {"tokenSymbol":"ETH"}'} | ${'gasFeeWarning.descriptionNotEnoughGas, {"action":"gasFeeWarning.actions.withdrawing","tokenSymbol":"ETH"}'} | ${'gasFeeWarning.cta, {"tokenSymbol":"ETH"}'}
${'depositing max amount of CELO'} | ${GasFeeWarningFlow.Deposit} | ${mockPreparedTransactionNeedDecreaseCelo} | ${'CELO'} | ${'gasFeeWarning.title, {"tokenSymbol":"CELO"}'} | ${'gasFeeWarning.descriptionMaxAmount, {"action":"gasFeeWarning.actions.depositing","tokenSymbol":"CELO"}'} | ${'gasFeeWarning.ctaGasToken, {"verb":"gasFeeWarning.verb.deposit"}'}
${'depositing max amount of ETH'} | ${GasFeeWarningFlow.Deposit} | ${mockPreparedTransactionNeedDecreaseEth} | ${'ETH'} | ${'gasFeeWarning.title, {"tokenSymbol":"ETH"}'} | ${'gasFeeWarning.descriptionMaxAmount, {"action":"gasFeeWarning.actions.depositing","tokenSymbol":"ETH"}'} | ${'gasFeeWarning.ctaGasToken, {"verb":"gasFeeWarning.verb.deposit"}'}
${'depositing with insufficient CELO'} | ${GasFeeWarningFlow.Deposit} | ${mockPreparedTransactionNotEnoughCelo} | ${'CELO'} | ${'gasFeeWarning.title, {"tokenSymbol":"CELO"}'} | ${'gasFeeWarning.descriptionNotEnoughGas, {"action":"gasFeeWarning.actions.depositing","tokenSymbol":"CELO"}'} | ${'gasFeeWarning.cta, {"tokenSymbol":"CELO"}'}
${'depositing with insufficient ETH'} | ${GasFeeWarningFlow.Deposit} | ${mockPreparedTransactionNotEnoughEth} | ${'ETH'} | ${'gasFeeWarning.title, {"tokenSymbol":"ETH"}'} | ${'gasFeeWarning.descriptionNotEnoughGas, {"action":"gasFeeWarning.actions.depositing","tokenSymbol":"ETH"}'} | ${'gasFeeWarning.cta, {"tokenSymbol":"ETH"}'}
${'dapp transaction with max amount of CELO'} | ${GasFeeWarningFlow.Dapp} | ${mockPreparedTransactionNeedDecreaseCelo} | ${'CELO'} | ${'gasFeeWarning.titleDapp'} | ${'gasFeeWarning.descriptionDapp, {"tokenSymbol":"CELO"}'} | ${undefined}
${'dapp transaction with max amount of ETH'} | ${GasFeeWarningFlow.Dapp} | ${mockPreparedTransactionNeedDecreaseEth} | ${'ETH'} | ${'gasFeeWarning.titleDapp'} | ${'gasFeeWarning.descriptionDapp, {"tokenSymbol":"ETH"}'} | ${undefined}
${'dapp transaction with insufficient CELO'} | ${GasFeeWarningFlow.Dapp} | ${mockPreparedTransactionNotEnoughCelo} | ${'CELO'} | ${'gasFeeWarning.titleDapp'} | ${'gasFeeWarning.descriptionDapp, {"tokenSymbol":"CELO"}'} | ${undefined}
${'dapp transaction with insufficient ETH'} | ${GasFeeWarningFlow.Dapp} | ${mockPreparedTransactionNotEnoughEth} | ${'ETH'} | ${'gasFeeWarning.titleDapp'} | ${'gasFeeWarning.descriptionDapp, {"tokenSymbol":"ETH"}'} | ${undefined}
MuckT marked this conversation as resolved.
Show resolved Hide resolved
`(
'renders error correctly when $scenario',
({ flow, prepareTransactionsResult, feeCurrencySymbol, title, description, ctaLabel }) => {
const store = createMockStore()
const onPressCta = jest.fn()
const { getByTestId, getByText } = render(
<Provider store={store}>
<GasFeeWarning
flow={flow}
testIdPrefix={'test'}
prepareTransactionsResult={prepareTransactionsResult}
onPressCta={ctaLabel ? onPressCta : undefined}
/>
</Provider>
)
expect(getByTestId('test/GasFeeWarning')).toBeTruthy()
expect(AppAnalytics.track).toHaveBeenCalledTimes(1)
expect(AppAnalytics.track).toHaveBeenCalledWith(AppEvents.show_gas_fee_warning, {
flow,
errorType: prepareTransactionsResult.type,
tokenNeeded: feeCurrencySymbol,
})
expect(getByText(title)).toBeTruthy()
expect(getByText(description)).toBeTruthy()
if (ctaLabel) {
fireEvent.press(getByText(ctaLabel))
}
expect(ctaLabel ? getByText(ctaLabel) : true).toBeTruthy()
expect(onPressCta).toHaveBeenCalledTimes(ctaLabel ? 1 : 0)
}
)
})
111 changes: 111 additions & 0 deletions src/components/GasFeeWarning.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
import AppAnalytics from 'src/analytics/AppAnalytics'
import { AppEvents } from 'src/analytics/Events'
import InLineNotification, { NotificationVariant } from 'src/components/InLineNotification'
import { Spacing } from 'src/styles/styles'
import { PreparedTransactionsResult } from 'src/viem/prepareTransactions'

export enum GasFeeWarningFlow {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you consider string literals over enum for this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no but I can change it to that, in Valora should we prefer string literals over enums? Or what is the convention?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think we have a defined convention, but we did talk about this in an eng sync and I think we've been favoring literals over enums recently

Send = 'Send',
Swap = 'Swap',
Deposit = 'Deposit',
Withdraw = 'Withdraw',
Dapp = 'Dapp',
}

function GasFeeWarning({
prepareTransactionsResult,
flow,
onPressCta,
testIdPrefix,
}: {
prepareTransactionsResult?: PreparedTransactionsResult
flow: GasFeeWarningFlow
onPressCta?: () => void
testIdPrefix?: string
}) {
const { t } = useTranslation()

const flowToActionString = {
[GasFeeWarningFlow.Send]: t('gasFeeWarning.actions.sending'),
[GasFeeWarningFlow.Swap]: t('gasFeeWarning.actions.swapping'),
[GasFeeWarningFlow.Deposit]: t('gasFeeWarning.actions.depositing'),
[GasFeeWarningFlow.Withdraw]: t('gasFeeWarning.actions.withdrawing'),
[GasFeeWarningFlow.Dapp]: undefined,
}

const flowToVerbString = {
[GasFeeWarningFlow.Send]: t('gasFeeWarning.verb.send'),
[GasFeeWarningFlow.Swap]: t('gasFeeWarning.verb.swap'),
[GasFeeWarningFlow.Deposit]: t('gasFeeWarning.verb.deposit'),
[GasFeeWarningFlow.Withdraw]: t('gasFeeWarning.verb.withdraw'),
[GasFeeWarningFlow.Dapp]: undefined,
}

useEffect(() => {
if (prepareTransactionsResult && prepareTransactionsResult.type !== 'possible') {
AppAnalytics.track(AppEvents.show_gas_fee_warning, {
flow,
errorType: prepareTransactionsResult.type,
tokenNeeded: feeCurrencySymbol,
})
}
}, [prepareTransactionsResult])

if (!prepareTransactionsResult || prepareTransactionsResult.type === 'possible') {
return null
MuckT marked this conversation as resolved.
Show resolved Hide resolved
}

const feeCurrencySymbol =
prepareTransactionsResult.type === 'not-enough-balance-for-gas'
? prepareTransactionsResult.feeCurrencies[0].symbol
Copy link
Contributor Author

@finnian0826 finnian0826 Dec 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the designs, it seems like it leans towards hardcoding this to be CELO or ETH (this is cases 2-4), however in all of the use cases currently it is done like this (using prepareTransactionsResult.feeCurrencies[0]). I think it is easier to keep it this way for two reasons:

  • ETH tokenId is not available in NetworkConfig, and would be needed for CTA (in order to prefill in CICO flow), so would need to be added for all networks, increasing amount of hardcoded things
  • The CTA is implemented in the component that uses this warning, so the implementer would have to get the hardcoded tokenId based on the network of the prepared transaction, rather than just looking at the first feeCurrencies.

I can add in the ETH tokenId's if we want but wanted to check first about doing it this way instead.

: prepareTransactionsResult.feeCurrency.symbol

const title =
flow === GasFeeWarningFlow.Dapp
? t('gasFeeWarning.titleDapp')
: t('gasFeeWarning.title', { tokenSymbol: feeCurrencySymbol })
const description =
flow === GasFeeWarningFlow.Dapp
? t('gasFeeWarning.descriptionDapp', { tokenSymbol: feeCurrencySymbol })
: prepareTransactionsResult.type === 'not-enough-balance-for-gas'
? t('gasFeeWarning.descriptionNotEnoughGas', {
action: flowToActionString[flow],
tokenSymbol: feeCurrencySymbol,
})
: t('gasFeeWarning.descriptionMaxAmount', {
action: flowToActionString[flow],
tokenSymbol: feeCurrencySymbol,
})
const ctaLabel =
flow === GasFeeWarningFlow.Dapp
? undefined
: prepareTransactionsResult.type === 'not-enough-balance-for-gas'
? t('gasFeeWarning.cta', { tokenSymbol: feeCurrencySymbol })
: t('gasFeeWarning.ctaGasToken', {
verb: flowToVerbString[flow],
})
return (
<InLineNotification
variant={NotificationVariant.Warning}
title={title}
description={description}
ctaLabel={ctaLabel}
onPressCta={onPressCta}
style={styles.warning}
testID={`${testIdPrefix}/GasFeeWarning`}
/>
)
}

const styles = StyleSheet.create({
warning: {
marginTop: Spacing.Regular16,
paddingHorizontal: Spacing.Regular16,
borderRadius: 16,
},
})

export default GasFeeWarning
Loading
Loading