From b253da393ca8bd9bf3a17e18def5d69fe908c0ce Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Tue, 30 Apr 2024 11:13:40 -0300 Subject: [PATCH 01/22] feat: add error banner --- apps/browser-extension-wallet/package.json | 1 + .../src/hooks/useInitializeTx.ts | 7 ++++++- .../send-transaction/components/Form/Form.tsx | 19 ++++++++++++++++--- .../raw/warning-icon-circle.component.svg | 5 +++++ .../browser-extension-wallet/en.json | 1 + .../ui/src/design-system/text/text.css.ts | 1 + .../src/design-system/text/text.stories.tsx | 1 + yarn.lock | 3 ++- 8 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 packages/icons/raw/warning-icon-circle.component.svg diff --git a/apps/browser-extension-wallet/package.json b/apps/browser-extension-wallet/package.json index ad4cdeb7c8..b1fd63594c 100644 --- a/apps/browser-extension-wallet/package.json +++ b/apps/browser-extension-wallet/package.json @@ -53,6 +53,7 @@ "@lace/cardano": "0.1.0", "@lace/common": "0.1.0", "@lace/core": "0.1.0", + "@lace/icons": "workspace:^", "@lace/staking": "0.1.0", "@lace/translation": "0.1.0", "@lace/ui": "^0.1.0", diff --git a/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts b/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts index ed914f6142..255d3d4ecf 100644 --- a/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts +++ b/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts @@ -1,3 +1,4 @@ +/* eslint-disable consistent-return */ import { useEffect, useCallback } from 'react'; import { Wallet } from '@lace/cardano'; import { BuiltTxData, OutputsMap } from '../views/browser-view/features/send-transaction/types'; @@ -29,7 +30,11 @@ export const coinSelectionErrors = new Map['t']) => - (key: COIN_SELECTION_ERRORS): string => { + (key: COIN_SELECTION_ERRORS): string | undefined => { + if (key === COIN_SELECTION_ERRORS.FULLY_DEPLETED_ERROR) { + return; + } + if (coinSelectionErrors.has(key)) { return t(coinSelectionErrors.get(key)); } diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx index 4c504eddb8..4e792a329f 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx @@ -2,11 +2,11 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Skeleton } from 'antd'; import { useTranslation } from 'react-i18next'; import { Tokens } from '@src/types'; -import { PriceResult, useCustomSubmitApi } from '@hooks'; +import { COIN_SELECTION_ERRORS, PriceResult, useCustomSubmitApi } from '@hooks'; import { useCurrencyStore } from '@providers'; import { Wallet } from '@lace/cardano'; import { SendTransactionCost } from '@lace/core'; -import { Button, useObservable, WarningBanner } from '@lace/common'; +import { Banner, Button, useObservable, WarningBanner } from '@lace/common'; import { useWalletStore } from '@src/stores'; import { useMaxAda } from '@hooks/useMaxAda'; import BundleIcon from '../../../../../../assets/icons/bundle-icon.component.svg'; @@ -16,6 +16,8 @@ import { getReachedMaxAmountList } from '../../helpers'; import { MetadataInput } from './MetadataInput'; import { BundlesList } from './BundlesList'; import { formatAdaAllocation, getNextBundleCoinId } from './util'; +import { Text } from '@lace/ui'; +import { ReactComponent as WarningIconCircle } from '@lace/icons/dist/WarningIconCircleComponent'; import styles from './Form.module.scss'; export interface Props { @@ -42,7 +44,7 @@ export const Form = ({ environmentName } = useWalletStore(); const balance = useObservable(inMemoryWallet.balance.utxo.total$); - const { builtTxData: { totalMinimumCoins, uiTx } = {} } = useBuiltTxState(); + const { builtTxData: { totalMinimumCoins, uiTx, error } = {} } = useBuiltTxState(); const [isBundle, setIsBundle] = useState(false); const tokensUsed = useSpentBalances(); @@ -108,6 +110,17 @@ export const Form = ({ {getCustomSubmitApiForNetwork(environmentName).status && ( )} + {error === COIN_SELECTION_ERRORS.FULLY_DEPLETED_ERROR && ( + {t('browserView.transaction.send.utxoDepletedBannerText')}} + customIcon={ + + + + } + /> + )} + + \ No newline at end of file diff --git a/packages/translation/src/lib/translations/browser-extension-wallet/en.json b/packages/translation/src/lib/translations/browser-extension-wallet/en.json index a276aac459..66535a4693 100644 --- a/packages/translation/src/lib/translations/browser-extension-wallet/en.json +++ b/packages/translation/src/lib/translations/browser-extension-wallet/en.json @@ -468,6 +468,7 @@ "browserView.transaction.send.advancedTransaction": "Advanced transaction", "browserView.transaction.send.confirmationTitle": "Transaction confirmation", "browserView.transaction.send.customSubmitApiBannerText": "Custom submit API enabled to send transactions. To disable, go to Settings >> Custom submit API", + "browserView.transaction.send.utxoDepletedBannerText": "Due to the minUTXO requirement for native assets, some ADA in your portfolio cannot be spent.", "browserView.transaction.send.drawer.addBundle": "Add bundle", "browserView.transaction.send.drawer.addressBook": "Address book", "browserView.transaction.send.drawer.addressBookEmpty": "You don't have saved addresses yet", diff --git a/packages/ui/src/design-system/text/text.css.ts b/packages/ui/src/design-system/text/text.css.ts index 7ff2b5778b..9e95d3f88f 100644 --- a/packages/ui/src/design-system/text/text.css.ts +++ b/packages/ui/src/design-system/text/text.css.ts @@ -44,6 +44,7 @@ export const typography = recipe({ highlight: sx({ color: '$data_blue' }), success: sx({ color: '$data_green' }), error: sx({ color: '$data_pink' }), + warning: sx({ color: '$data_orange' }), }, }, }); diff --git a/packages/ui/src/design-system/text/text.stories.tsx b/packages/ui/src/design-system/text/text.stories.tsx index 0b07cc7e39..6f29170211 100644 --- a/packages/ui/src/design-system/text/text.stories.tsx +++ b/packages/ui/src/design-system/text/text.stories.tsx @@ -86,6 +86,7 @@ const colors: TypographyVariants['color'][] = [ 'error', 'success', 'accent', + 'warning', ]; export const Overview = (): JSX.Element => ( diff --git a/yarn.lock b/yarn.lock index 87fc88d8ac..f7e12c23d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11114,6 +11114,7 @@ __metadata: "@lace/cardano": 0.1.0 "@lace/common": 0.1.0 "@lace/core": 0.1.0 + "@lace/icons": "workspace:^" "@lace/staking": 0.1.0 "@lace/translation": 0.1.0 "@lace/ui": ^0.1.0 @@ -11323,7 +11324,7 @@ __metadata: languageName: unknown linkType: soft -"@lace/icons@0.1.0, @lace/icons@^0.1.0, @lace/icons@workspace:packages/icons": +"@lace/icons@0.1.0, @lace/icons@^0.1.0, @lace/icons@workspace:^, @lace/icons@workspace:packages/icons": version: 0.0.0-use.local resolution: "@lace/icons@workspace:packages/icons" dependencies: From d734813e82de44d0caf9ab758a6fd408e66be4e7 Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Thu, 2 May 2024 14:18:12 -0300 Subject: [PATCH 02/22] feat: update copy --- .../src/lib/translations/browser-extension-wallet/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/translation/src/lib/translations/browser-extension-wallet/en.json b/packages/translation/src/lib/translations/browser-extension-wallet/en.json index 66535a4693..15b3fce8dd 100644 --- a/packages/translation/src/lib/translations/browser-extension-wallet/en.json +++ b/packages/translation/src/lib/translations/browser-extension-wallet/en.json @@ -468,7 +468,7 @@ "browserView.transaction.send.advancedTransaction": "Advanced transaction", "browserView.transaction.send.confirmationTitle": "Transaction confirmation", "browserView.transaction.send.customSubmitApiBannerText": "Custom submit API enabled to send transactions. To disable, go to Settings >> Custom submit API", - "browserView.transaction.send.utxoDepletedBannerText": "Due to the minUTXO requirement for native assets, some ADA in your portfolio cannot be spent.", + "browserView.transaction.send.utxoDepletedBannerText": "Due to the minUTXO requirement for native assets, some ADA cannot be spent. Use the 'Max' button to select the maximum spendable amount.", "browserView.transaction.send.drawer.addBundle": "Add bundle", "browserView.transaction.send.drawer.addressBook": "Address book", "browserView.transaction.send.drawer.addressBookEmpty": "You don't have saved addresses yet", From 4da1156d0587a5cce4b42e2b4cbb3c435e1a7c15 Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Thu, 2 May 2024 14:22:11 -0300 Subject: [PATCH 03/22] feat: display max button when utxo depleted error is present --- .../components/Form/CoinInput/useSelectedCoins.tsx | 1 + .../src/ui/components/AssetInput/AssetInput.module.scss | 4 ++++ packages/core/src/ui/components/AssetInput/AssetInput.tsx | 7 +++++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx index 9b31a6e691..be3ca7ad0b 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx @@ -157,6 +157,7 @@ export const useSelectedCoins = ({ compactValue: assetInputItem.compactValue || compactNumberWithUnit(assetInputItem.value), value: assetInputItem.value, hasMaxBtn: true, + displayMaxBtn: assetInputItem.id === cardanoCoin.id && error === COIN_SELECTION_ERRORS.FULLY_DEPLETED_ERROR, invalid: !!error, error, onBlurErrors: new Set([COIN_SELECTION_ERRORS.BUNDLE_AMOUNT_IS_EMPTY]), diff --git a/packages/core/src/ui/components/AssetInput/AssetInput.module.scss b/packages/core/src/ui/components/AssetInput/AssetInput.module.scss index 5b2c4a64d2..6a89f36cc2 100644 --- a/packages/core/src/ui/components/AssetInput/AssetInput.module.scss +++ b/packages/core/src/ui/components/AssetInput/AssetInput.module.scss @@ -132,6 +132,10 @@ } } +button.maxBtn.show { + display: initial !important; +} + button.maxBtn { display: none !important; padding: 4px 11px !important; diff --git a/packages/core/src/ui/components/AssetInput/AssetInput.tsx b/packages/core/src/ui/components/AssetInput/AssetInput.tsx index 774de2f8d1..46894d7426 100644 --- a/packages/core/src/ui/components/AssetInput/AssetInput.tsx +++ b/packages/core/src/ui/components/AssetInput/AssetInput.tsx @@ -9,6 +9,7 @@ import { validateNumericValue } from '@src/ui/utils/validate-numeric-value'; import { sanitizeNumber } from '@ui/utils/sanitize-number'; import { useTranslation } from 'react-i18next'; import { TranslationKey } from '@lace/translation'; +import cn from 'classnames'; const isSameNumberFormat = (num1: string, num2: string) => { if (!num1 || !num2) return false; @@ -36,6 +37,7 @@ export interface AssetInputProps { onNameClick?: (event: React.MouseEvent) => void; max?: string; hasMaxBtn?: boolean; + displayMaxBtn?: boolean; hasReachedMaxAmount?: boolean; focused?: boolean; onBlurErrors?: Set; @@ -67,6 +69,7 @@ export const AssetInput = ({ formattedFiatValue = fiatValue, max, hasMaxBtn = true, + displayMaxBtn = false, hasReachedMaxAmount, focused, setFocusInput, @@ -187,7 +190,7 @@ export const AssetInput = ({
- {hasMaxBtn && !Number.parseFloat(value) && ( + {hasMaxBtn && (!Number.parseFloat(value) || displayMaxBtn) && ( // There is a span element as children of the button that propagates the click event when the button is disabled, this makes the input element to focus adding 0 as value. // To fix this issue, I moved the button click event to a parent div, so, when the button is disabled the event propagation can be stopped.
@@ -195,7 +198,7 @@ export const AssetInput = ({ data-testid="max-bttn" size="small" color="secondary" - className={styles.maxBtn} + className={cn(styles.maxBtn, { [styles.show]: displayMaxBtn })} disabled={hasReachedMaxAmount} > {t('core.assetInput.maxButton')} From 780c2f73e0b99eebd9590d24ab2f55f3c69d0026 Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Thu, 9 May 2024 14:10:43 -0300 Subject: [PATCH 04/22] feat: max ada is value that doesnt throw utxo depleted error --- .../src/hooks/__tests__/useMaxAda.test.ts | 53 +++++++-- .../src/hooks/useInitializeTx.ts | 4 - .../src/hooks/useMaxAda.ts | 101 ++++++++++++------ .../components/AssetPicker.tsx | 4 +- .../Form/CoinInput/useSelectedCoins.tsx | 3 +- .../send-transaction/components/Form/Form.tsx | 3 +- .../features/send-transaction/helpers.ts | 6 +- 7 files changed, 120 insertions(+), 54 deletions(-) diff --git a/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts b/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts index c50249cc9c..51cd3e934e 100644 --- a/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts +++ b/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts @@ -1,5 +1,6 @@ +/* eslint-disable no-magic-numbers */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { useMaxAda } from '../useMaxAda'; +import { useMaxAda, UTXO_DEPLETED_ADA_BUFFER } from '../useMaxAda'; import { renderHook } from '@testing-library/react-hooks'; import { mockWalletInfoTestnet } from '@src/utils/mocks/test-helpers'; import { Subject, of } from 'rxjs'; @@ -7,7 +8,13 @@ import { Wallet } from '@lace/cardano'; import { waitFor } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; const mockInitializeTx = jest.fn(); - +const inspect = jest.fn().mockReturnThis(); +const mockCreateTxBuilder = jest.fn().mockReturnValue({ + inspect, + build: jest.fn().mockReturnThis(), + addOutput: jest.fn().mockReturnThis() +}); +const TX_FEE = 155_381; const inMemoryWallet = { balance: { utxo: { @@ -18,7 +25,8 @@ const inMemoryWallet = { } }, protocolParameters$: of({ coinsPerUtxoByte: 4310, maxValueSize: 5000 }), - initializeTx: mockInitializeTx + initializeTx: mockInitializeTx, + createTxBuilder: mockCreateTxBuilder }; jest.mock('../../stores', () => ({ @@ -35,12 +43,12 @@ describe('Testing useMaxAda hook', () => { mockInitializeTx.mockImplementationOnce(() => ({ inputSelection: { // eslint-disable-next-line no-magic-numbers - fee: BigInt(155_381) + fee: BigInt(TX_FEE) } })); }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); test('should return 0 in case balance is empty', async () => { @@ -74,19 +82,46 @@ describe('Testing useMaxAda hook', () => { }); }); - test('should return 7874869', async () => { + test('should return 0 if balance is minimum for coins', async () => { + const { result } = renderHook(() => useMaxAda()); + + act(() => { + inMemoryWallet.balance.utxo.available$.next({ + coins: BigInt('1155080') + BigInt(TX_FEE), + assets: new Map([ + [ + Wallet.Cardano.AssetId('659f2917fb63f12b33667463ee575eeac1845bbc736b9c0bbc40ba8254534c41'), + BigInt('100000000') + ] + ]) + }); + }); + + await waitFor(() => { + expect(result.current.toString()).toBe('0'); + }); + }); + + test('should return 8874869', async () => { const { result } = renderHook(() => useMaxAda()); act(() => { inMemoryWallet.balance.utxo.available$.next({ coins: BigInt('10000000') }); }); await waitFor(() => { - expect(result.current.toString()).toBe('7874869'); + expect(result.current.toString()).toBe('8874869'); }); }); - test('should return 7689539', async () => { + test.each([[1], [3], [7]])('should return 8689539 minus adaErrorBuffer*%i', async (errorCount) => { const { result } = renderHook(() => useMaxAda()); + + Array.from({ length: errorCount }).forEach(() => { + inspect.mockImplementationOnce(() => { + throw new Error('Error'); + }); + }); + act(() => { inMemoryWallet.balance.utxo.available$.next({ coins: BigInt('10000000'), @@ -100,7 +135,7 @@ describe('Testing useMaxAda hook', () => { }); await waitFor(() => { - expect(result.current.toString()).toBe('7689539'); + expect(result.current).toBe(BigInt('8689539') - BigInt(UTXO_DEPLETED_ADA_BUFFER * errorCount)); }); }); }); diff --git a/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts b/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts index 255d3d4ecf..221b822a38 100644 --- a/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts +++ b/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts @@ -6,7 +6,6 @@ import { useSpentBalances } from '../views/browser-view/features/send-transactio import { useObservable } from '@lace/common'; import { getReachedMaxAmountList } from '@src/views/browser-view/features/send-transaction/helpers'; import { useWalletStore } from '@src/stores'; -import { useMaxAda } from './useMaxAda'; import { UseTranslationResponse } from 'react-i18next'; import type { TranslationKey } from '@lace/translation'; @@ -54,13 +53,11 @@ export const useInitializeTx = ( const assetsInfo = useObservable(inMemoryWallet.assetInfo$); const balance = useObservable(inMemoryWallet.balance.utxo.total$); const tokensUsed = useSpentBalances(); - const spendableCoin = useMaxAda(); const buildTransaction = useCallback(async () => { const reachedMaxAmountList = getReachedMaxAmountList({ assets: assetsInfo, tokensUsed, - spendableCoin, balance, exceed: true, cardanoCoin @@ -112,7 +109,6 @@ export const useInitializeTx = ( }, [ assetsInfo, tokensUsed, - spendableCoin, balance, cardanoCoin, hasInvalidOutputs, diff --git a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts index aed9681a98..f769207008 100644 --- a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts +++ b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts @@ -3,9 +3,48 @@ import { subtractValueQuantities } from '@cardano-sdk/core'; import { Wallet } from '@lace/cardano'; import { useWalletStore } from '@src/stores'; import { useObservable } from '@lace/common'; +import { COIN_SELECTION_ERRORS } from './useInitializeTx'; const { getTotalMinimumCoins, setMissingCoins } = Wallet; +export const UTXO_DEPLETED_ADA_BUFFER = 1_000_000; + +interface GetAdaErrorBuffer { + inMemoryWallet: Wallet.ObservableWallet; + address: Wallet.Cardano.PaymentAddress; + maxAdaAmount: bigint; +} + +const getAdaErrorBuffer = async ({ inMemoryWallet, address, maxAdaAmount }: GetAdaErrorBuffer): Promise => { + const isTxBuilt = async (adaAmount: bigint): Promise => { + try { + const outputWithMaxAda = { + address, + value: { + coins: adaAmount, + assets: new Map() + } + }; + const txBuilder = inMemoryWallet.createTxBuilder(); + txBuilder.addOutput(outputWithMaxAda); + await txBuilder.build().inspect(); + return true; + } catch { + return false; + } + }; + + let adaErrorBuffer = BigInt(0); + while (!(await isTxBuilt(maxAdaAmount - adaErrorBuffer))) { + adaErrorBuffer += BigInt(UTXO_DEPLETED_ADA_BUFFER); + if (adaErrorBuffer > maxAdaAmount) { + throw new Error(COIN_SELECTION_ERRORS.FULLY_DEPLETED_ERROR); + } + } + + return adaErrorBuffer; +}; + export const useMaxAda = (): bigint => { const [maxADA, setMaxADA] = useState(); const { walletInfo, inMemoryWallet } = useWalletStore(); @@ -13,43 +52,43 @@ export const useMaxAda = (): bigint => { const availableRewards = useObservable(inMemoryWallet?.balance?.rewardAccounts?.rewards$); const calculateMaxAda = useCallback(async () => { - if (!balance) { + if (!balance?.coins) { setMaxADA(BigInt(0)); return; } - if (balance.coins) { - const util = Wallet.createWalletUtil(inMemoryWallet); - // create and output with only the wallet tokens and nfts so we can calculate the mising coins for feature txs - const outputs = new Set([ - { - address: walletInfo.addresses[0].address, - value: { - coins: BigInt(0), - assets: balance.assets || new Map() - } + const util = Wallet.createWalletUtil(inMemoryWallet); + // create and output with only the wallet tokens and nfts so we can calculate the mising coins for feature txs + const outputs = new Set([ + { + address: walletInfo.addresses[0].address, + value: { + coins: BigInt(0), + assets: balance.assets || new Map() } - ]); - const minimumCoinQuantities = await util.validateOutputs(outputs); - const totalMinimumCoins = getTotalMinimumCoins(minimumCoinQuantities); - const props = setMissingCoins(minimumCoinQuantities, outputs); - try { - // build a tx get an approximate of the fee - const tx = await inMemoryWallet.initializeTx(props); - // substract the fee and the missing coins from the wallet balances - const spendableBalance = subtractValueQuantities([ - { coins: balance.coins + BigInt(availableRewards || 0) }, // wallet balance - { coins: BigInt(totalMinimumCoins.coinMissing) }, // this is the minimun coins needed for all the wallet tokens - { coins: tx.inputSelection.fee }, // this is an approximate fee - // eslint-disable-next-line no-magic-numbers - { coins: BigInt(1_000_000) } // the wallet needs to have at least 1 utxo with 1000000 lovelaces/1 ADA - ]); - - setMaxADA(spendableBalance.coins); - } catch { - setMaxADA(BigInt(0)); } - } else { + ]); + const minimumCoinQuantities = await util.validateOutputs(outputs); + const totalMinimumCoins = getTotalMinimumCoins(minimumCoinQuantities); + const props = setMissingCoins(minimumCoinQuantities, outputs); + try { + // build a tx get an approximate of the fee + const tx = await inMemoryWallet.initializeTx(props); + // substract the fee and the missing coins from the wallet balances + const spendableBalance = subtractValueQuantities([ + { coins: balance.coins + BigInt(availableRewards || 0) }, // wallet balance + { coins: BigInt(totalMinimumCoins.coinMissing) }, // this is the minimun coins needed for all the wallet tokens + { coins: tx.inputSelection.fee } // this is an approximate fee + ]); + + const errorBuffer = await getAdaErrorBuffer({ + inMemoryWallet, + address: walletInfo.addresses[0].address, + maxAdaAmount: spendableBalance.coins + }); + + setMaxADA(spendableBalance.coins - errorBuffer); + } catch { setMaxADA(BigInt(0)); } }, [inMemoryWallet, walletInfo.addresses, balance, availableRewards]); diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/AssetPicker.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/AssetPicker.tsx index 67c8694efe..abdade3c2c 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/AssetPicker.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/AssetPicker.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react'; import { AssetSelectorOverlay, AssetSelectorOverlayProps } from '@lace/core'; import { Wallet } from '@lace/cardano'; import CardanoLogo from '../../../../../assets/icons/browser-view/cardano-logo.svg'; -import { useFetchCoinPrice, PriceResult, useMaxAda, AssetOrHandleInfoMap, useAssetInfo } from '@hooks'; +import { useFetchCoinPrice, PriceResult, AssetOrHandleInfoMap, useAssetInfo } from '@hooks'; import { EnvironmentTypes, useWalletStore } from '@src/stores'; import { useCoinStateSelector, @@ -140,7 +140,6 @@ export const AssetPicker = ({ isPopupView }: AssetPickerProps): React.ReactEleme const coinId = useCurrentCoinIdToChange(); const { setPrevSection } = useSections(); const tokensUsed = useSpentBalances(); - const spendableCoin = useMaxAda(); const { setSelectedTokenList, selectedTokenList, removeTokenFromList } = useSelectedTokenList(); const [multipleSelectionAvailable] = useMultipleSelection(); const { fiatCurrency } = useCurrencyStore(); @@ -159,7 +158,6 @@ export const AssetPicker = ({ isPopupView }: AssetPickerProps): React.ReactEleme const reachedMaxAmountList = getReachedMaxAmountList({ assets, tokensUsed, - spendableCoin, balance, cardanoCoin }); diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx index be3ca7ad0b..a2f147ff54 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx @@ -169,7 +169,7 @@ export const useSelectedCoins = ({ // Asset is cardano coin if (assetInputItem.id === cardanoCoin.id) { - const { availableADA, ...adaCoinProps } = getADACoinProperties( + const { availableADA, hasReachedMaxAmount, ...adaCoinProps } = getADACoinProperties( coinBalance, spendableCoin?.toString(), tokensUsed[cardanoCoin.id] || '0', @@ -182,6 +182,7 @@ export const useSelectedCoins = ({ return { ...commonCoinProps, ...adaCoinProps, + hasReachedMaxAmount: hasReachedMaxAmount && error !== COIN_SELECTION_ERRORS.FULLY_DEPLETED_ERROR, coin: { id: cardanoCoin.id, ticker: cardanoCoin.symbol, diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx index 4e792a329f..6b4f46c980 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx @@ -68,13 +68,12 @@ export const Form = ({ getReachedMaxAmountList({ assets, tokensUsed, - spendableCoin, balance, cardanoCoin, exceed: true }) || [] ), - [assets, balance, cardanoCoin, spendableCoin, tokensUsed] + [assets, balance, cardanoCoin, tokensUsed] ); useEffect(() => { diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/helpers.ts b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/helpers.ts index c4fb1d1b50..5c84899e67 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/helpers.ts +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/helpers.ts @@ -55,22 +55,20 @@ export const getOutputValues = (assets: Array, cardanoCoin: Wallet.Co export const getReachedMaxAmountList = ({ assets = new Map(), tokensUsed, - spendableCoin, balance, exceed = false, cardanoCoin }: { assets: Wallet.Assets; tokensUsed: SpentBalances; - spendableCoin: BigInt; balance: Wallet.Cardano.Value; exceed?: boolean; cardanoCoin: Wallet.CoinId; }): (string | Wallet.Cardano.AssetId)[] => { const reachedMaxAmountAda = - tokensUsed[cardanoCoin.id] && spendableCoin + tokensUsed[cardanoCoin.id] && balance?.coins ? new BigNumber(tokensUsed[cardanoCoin.id])[exceed ? 'gt' : 'gte']( - Wallet.util.lovelacesToAdaString(spendableCoin.toString()) + Wallet.util.lovelacesToAdaString(balance.coins.toString()) ) : false; From 89dd76c49d2b48a014c50577a5e96d3f137a31f1 Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Mon, 13 May 2024 09:41:41 -0300 Subject: [PATCH 05/22] feat: only display max button suggestion if there is spendable amount --- .../components/Form/CoinInput/useSelectedCoins.tsx | 6 +++++- .../send-transaction/components/Form/Form.tsx | 11 ++++++++++- .../lib/translations/browser-extension-wallet/en.json | 3 ++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx index a2f147ff54..d961d92a33 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ import { Wallet } from '@lace/cardano'; import { AssetInputListProps, AssetInputProps } from '@lace/core'; import { @@ -157,7 +158,10 @@ export const useSelectedCoins = ({ compactValue: assetInputItem.compactValue || compactNumberWithUnit(assetInputItem.value), value: assetInputItem.value, hasMaxBtn: true, - displayMaxBtn: assetInputItem.id === cardanoCoin.id && error === COIN_SELECTION_ERRORS.FULLY_DEPLETED_ERROR, + displayMaxBtn: + assetInputItem.id === cardanoCoin.id && + error === COIN_SELECTION_ERRORS.FULLY_DEPLETED_ERROR && + spendableCoin > 0, invalid: !!error, error, onBlurErrors: new Set([COIN_SELECTION_ERRORS.BUNDLE_AMOUNT_IS_EMPTY]), diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx index 6b4f46c980..51a4b4781b 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx @@ -104,6 +104,15 @@ export const Form = ({ !spendableCoin || !getNextBundleCoinId(spendableCoin?.toString(), assetBalances, tokensUsed, assets, cardanoCoin)?.length; + const utxoDepletedMsg = ( + <> + {t('browserView.transaction.send.utxoDepletedBannerErrorText')} + {spendableCoin > 0 && ( + {t('browserView.transaction.send.utxoDepletedBannerMaxButtonText')} + )} + + ); + return ( {getCustomSubmitApiForNetwork(environmentName).status && ( @@ -112,7 +121,7 @@ export const Form = ({ {error === COIN_SELECTION_ERRORS.FULLY_DEPLETED_ERROR && ( {t('browserView.transaction.send.utxoDepletedBannerText')}} + message={utxoDepletedMsg} customIcon={ diff --git a/packages/translation/src/lib/translations/browser-extension-wallet/en.json b/packages/translation/src/lib/translations/browser-extension-wallet/en.json index 15b3fce8dd..d3727cb8f8 100644 --- a/packages/translation/src/lib/translations/browser-extension-wallet/en.json +++ b/packages/translation/src/lib/translations/browser-extension-wallet/en.json @@ -468,7 +468,8 @@ "browserView.transaction.send.advancedTransaction": "Advanced transaction", "browserView.transaction.send.confirmationTitle": "Transaction confirmation", "browserView.transaction.send.customSubmitApiBannerText": "Custom submit API enabled to send transactions. To disable, go to Settings >> Custom submit API", - "browserView.transaction.send.utxoDepletedBannerText": "Due to the minUTXO requirement for native assets, some ADA cannot be spent. Use the 'Max' button to select the maximum spendable amount.", + "browserView.transaction.send.utxoDepletedBannerErrorText": "Due to the minUTXO requirement for native assets, some ADA cannot be spent.", + "browserView.transaction.send.utxoDepletedBannerMaxButtonText": "Use the 'Max' button to select the maximum spendable amount.", "browserView.transaction.send.drawer.addBundle": "Add bundle", "browserView.transaction.send.drawer.addressBook": "Address book", "browserView.transaction.send.drawer.addressBookEmpty": "You don't have saved addresses yet", From 5c69e8d6f2a3f6f728a12d99001956c301c79efb Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Wed, 22 May 2024 16:48:23 -0300 Subject: [PATCH 06/22] fix: add margin --- .../send-transaction/components/Form/Form.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx index 51a4b4781b..8edda6df09 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx @@ -16,7 +16,7 @@ import { getReachedMaxAmountList } from '../../helpers'; import { MetadataInput } from './MetadataInput'; import { BundlesList } from './BundlesList'; import { formatAdaAllocation, getNextBundleCoinId } from './util'; -import { Text } from '@lace/ui'; +import { Box, Text } from '@lace/ui'; import { ReactComponent as WarningIconCircle } from '@lace/icons/dist/WarningIconCircleComponent'; import styles from './Form.module.scss'; @@ -119,15 +119,17 @@ export const Form = ({ )} {error === COIN_SELECTION_ERRORS.FULLY_DEPLETED_ERROR && ( - - - - } - /> + + + + + } + /> + )} Date: Wed, 22 May 2024 16:49:13 -0300 Subject: [PATCH 07/22] feat: recalculate max ada value on output changes --- .../src/hooks/useMaxAda.ts | 263 +++++++++++++----- .../src/wallet/lib/build-transaction-props.ts | 2 +- 2 files changed, 198 insertions(+), 67 deletions(-) diff --git a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts index f769207008..2a467191aa 100644 --- a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts +++ b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts @@ -1,48 +1,198 @@ -import { useCallback, useEffect, useState } from 'react'; +/* eslint-disable no-magic-numbers */ +import { useEffect, useState } from 'react'; import { subtractValueQuantities } from '@cardano-sdk/core'; import { Wallet } from '@lace/cardano'; import { useWalletStore } from '@src/stores'; import { useObservable } from '@lace/common'; import { COIN_SELECTION_ERRORS } from './useInitializeTx'; +import { OutputsMap, useTransactionProps } from '@src/views/browser-view/features/send-transaction'; +import { TxBuilder } from '@cardano-sdk/tx-construction'; +import { Assets, WalletUtil } from '@cardano-sdk/wallet'; const { getTotalMinimumCoins, setMissingCoins } = Wallet; export const UTXO_DEPLETED_ADA_BUFFER = 1_000_000; +export const ADA_BUFFER_LIMIT = UTXO_DEPLETED_ADA_BUFFER * 10; -interface GetAdaErrorBuffer { - inMemoryWallet: Wallet.ObservableWallet; +interface CreateTestOutputs { address: Wallet.Cardano.PaymentAddress; - maxAdaAmount: bigint; + adaAmount: bigint; + outputMap?: OutputsMap; + assetInfo: Assets; + validateOutput: WalletUtil['validateOutput']; } -const getAdaErrorBuffer = async ({ inMemoryWallet, address, maxAdaAmount }: GetAdaErrorBuffer): Promise => { - const isTxBuilt = async (adaAmount: bigint): Promise => { - try { - const outputWithMaxAda = { - address, +const createTestOutputs = async ({ + address, + adaAmount, + assetInfo, + outputMap = new Map(), + validateOutput +}: CreateTestOutputs) => { + const outputs: Wallet.Cardano.TxOut[] = [ + { + address, + value: { + coins: adaAmount + } + } + ]; + + for (const [, output] of outputMap) { + if (output.value.assets) { + const assets = Wallet.convertAssetsToBigInt(output.value.assets, assetInfo); + const txOut = { + address: output.address, value: { - coins: adaAmount, - assets: new Map() + coins: BigInt(0), + assets } }; - const txBuilder = inMemoryWallet.createTxBuilder(); - txBuilder.addOutput(outputWithMaxAda); - await txBuilder.build().inspect(); - return true; - } catch { - return false; + const minimumCoinQuantities = await validateOutput(txOut); + outputs.push({ + address: txOut.address, + value: { + ...txOut.value, + coins: BigInt(minimumCoinQuantities.minimumCoin) + } + }); } - }; + } - let adaErrorBuffer = BigInt(0); - while (!(await isTxBuilt(maxAdaAmount - adaErrorBuffer))) { - adaErrorBuffer += BigInt(UTXO_DEPLETED_ADA_BUFFER); - if (adaErrorBuffer > maxAdaAmount) { - throw new Error(COIN_SELECTION_ERRORS.FULLY_DEPLETED_ERROR); - } + return outputs; +}; + +interface IsTransactionBuildable { + outputs: Wallet.Cardano.TxOut[]; + txBuilder: TxBuilder; +} + +const isTransactionBuildable = async ({ outputs, txBuilder }: IsTransactionBuildable): Promise => { + try { + outputs.forEach((output) => txBuilder.addOutput(output)); + await txBuilder.build().inspect(); + return true; + } catch { + return false; + } finally { + outputs.forEach((output) => txBuilder.removeOutput(output)); + } +}; + +interface GetAdaErrorBuffer { + txBuilder: TxBuilder; + address: Wallet.Cardano.PaymentAddress; + maxAdaAmount: bigint; + assetInfo: Assets; + outputMap?: OutputsMap; + signal: AbortSignal; + validateOutput: WalletUtil['validateOutput']; +} + +const getAdaErrorBuffer = async ({ + txBuilder, + address, + outputMap, + maxAdaAmount, + assetInfo, + signal, + validateOutput, + adaErrorBuffer = BigInt(0) +}: GetAdaErrorBuffer & { adaErrorBuffer?: bigint }): Promise => { + if (signal.aborted) { + throw new Error('Aborted'); + } + if (adaErrorBuffer > maxAdaAmount || adaErrorBuffer > ADA_BUFFER_LIMIT) { + throw new Error(COIN_SELECTION_ERRORS.FULLY_DEPLETED_ERROR); + } + + const adaAmount = maxAdaAmount - adaErrorBuffer; + const outputs = await createTestOutputs({ + address, + adaAmount, + assetInfo, + outputMap, + validateOutput + }); + const canBuildTx = await isTransactionBuildable({ outputs, txBuilder }); + + if (canBuildTx) { + return adaErrorBuffer; } - return adaErrorBuffer; + return getAdaErrorBuffer({ + txBuilder, + validateOutput, + address, + outputMap, + maxAdaAmount, + assetInfo, + signal, + adaErrorBuffer: adaErrorBuffer + BigInt(UTXO_DEPLETED_ADA_BUFFER) + }); +}; + +interface CalculateMaxAda { + inMemoryWallet: Wallet.ObservableWallet; + address: Wallet.Cardano.PaymentAddress; + balance: Wallet.Cardano.Value; + availableRewards: bigint; + assetInfo: Assets; + signal: AbortSignal; + outputMap: OutputsMap; +} + +const calculateMaxAda = async ({ + balance, + inMemoryWallet, + address, + availableRewards, + outputMap, + assetInfo, + signal +}: CalculateMaxAda) => { + if (!balance?.coins || !address) { + return BigInt(0); + } + const txBuilder = inMemoryWallet.createTxBuilder(); + const { validateOutput, validateOutputs } = Wallet.createWalletUtil(inMemoryWallet); + // create and output with only the wallet tokens and nfts so we can calculate the mising coins for feature txs + const outputs = new Set([ + { + address, + value: { + coins: BigInt(0), + assets: balance.assets || new Map() + } + } + ]); + const minimumCoinQuantities = await validateOutputs(outputs); + const totalMinimumCoins = getTotalMinimumCoins(minimumCoinQuantities); + const props = setMissingCoins(minimumCoinQuantities, outputs); + try { + // build a tx get an approximate of the fee + const tx = await inMemoryWallet.initializeTx(props); + // substract the fee and the missing coins from the wallet balances + const spendableBalance = subtractValueQuantities([ + { coins: balance.coins + BigInt(availableRewards || 0) }, // wallet balance + { coins: BigInt(totalMinimumCoins.coinMissing) }, // this is the minimun coins needed for all the wallet tokens + { coins: tx.inputSelection.fee } // this is an approximate fee + ]); + + const errorBuffer = await getAdaErrorBuffer({ + address, + txBuilder, + maxAdaAmount: spendableBalance.coins, + assetInfo, + outputMap, + validateOutput, + signal + }); + + return spendableBalance.coins - errorBuffer; + } catch { + return BigInt(0); + } }; export const useMaxAda = (): bigint => { @@ -50,52 +200,33 @@ export const useMaxAda = (): bigint => { const { walletInfo, inMemoryWallet } = useWalletStore(); const balance = useObservable(inMemoryWallet?.balance?.utxo.available$); const availableRewards = useObservable(inMemoryWallet?.balance?.rewardAccounts?.rewards$); + const assetInfo = useObservable(inMemoryWallet?.assetInfo$); + const { outputMap } = useTransactionProps(); + const address = walletInfo.addresses[0].address; - const calculateMaxAda = useCallback(async () => { - if (!balance?.coins) { - setMaxADA(BigInt(0)); - return; - } - - const util = Wallet.createWalletUtil(inMemoryWallet); - // create and output with only the wallet tokens and nfts so we can calculate the mising coins for feature txs - const outputs = new Set([ - { - address: walletInfo.addresses[0].address, - value: { - coins: BigInt(0), - assets: balance.assets || new Map() - } - } - ]); - const minimumCoinQuantities = await util.validateOutputs(outputs); - const totalMinimumCoins = getTotalMinimumCoins(minimumCoinQuantities); - const props = setMissingCoins(minimumCoinQuantities, outputs); - try { - // build a tx get an approximate of the fee - const tx = await inMemoryWallet.initializeTx(props); - // substract the fee and the missing coins from the wallet balances - const spendableBalance = subtractValueQuantities([ - { coins: balance.coins + BigInt(availableRewards || 0) }, // wallet balance - { coins: BigInt(totalMinimumCoins.coinMissing) }, // this is the minimun coins needed for all the wallet tokens - { coins: tx.inputSelection.fee } // this is an approximate fee - ]); - - const errorBuffer = await getAdaErrorBuffer({ + useEffect(() => { + const abortController = new AbortController(); + const calculate = async () => { + const result = await calculateMaxAda({ + address, + availableRewards, + balance, + assetInfo, inMemoryWallet, - address: walletInfo.addresses[0].address, - maxAdaAmount: spendableBalance.coins + signal: abortController.signal, + outputMap }); - setMaxADA(spendableBalance.coins - errorBuffer); - } catch { - setMaxADA(BigInt(0)); - } - }, [inMemoryWallet, walletInfo.addresses, balance, availableRewards]); + if (!abortController.signal.aborted) { + setMaxADA(result); + } + }; - useEffect(() => { - calculateMaxAda(); - }, [calculateMaxAda]); + calculate(); + return () => { + abortController.abort(); + }; + }, [availableRewards, assetInfo, balance, inMemoryWallet, address, outputMap]); return maxADA; }; diff --git a/packages/cardano/src/wallet/lib/build-transaction-props.ts b/packages/cardano/src/wallet/lib/build-transaction-props.ts index 5142e1ea19..858b20be77 100644 --- a/packages/cardano/src/wallet/lib/build-transaction-props.ts +++ b/packages/cardano/src/wallet/lib/build-transaction-props.ts @@ -17,7 +17,7 @@ type OutputsMap = Map; type TokenBalanceMap = Map; -const convertAssetsToBigInt = ( +export const convertAssetsToBigInt = ( assets: CardanoOutput['value']['assets'], assetsInfo: Assets = new Map() ): TokenBalanceMap => { From a47e4f04075e209582233e209778cd222fe9e959 Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Sun, 26 May 2024 23:42:00 -0300 Subject: [PATCH 08/22] fix: only add metadata if not empty --- apps/browser-extension-wallet/src/hooks/useInitializeTx.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts b/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts index 221b822a38..ef6814b063 100644 --- a/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts +++ b/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts @@ -80,7 +80,9 @@ export const useInitializeTx = ( const txBuilder = inMemoryWallet.createTxBuilder(); outputsWithMissingCoins.outputs.forEach((output) => txBuilder.addOutput(output)); - txBuilder.metadata(partialTxProps?.auxiliaryData?.blob || new Map()); + if (partialTxProps?.auxiliaryData?.blob) { + txBuilder.metadata(partialTxProps.auxiliaryData.blob); + } const tx = txBuilder.build(); const inspection = await tx.inspect(); setBuiltTxData({ From c5532a27e732765d44d29faf544799cd9517ef5c Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Sun, 26 May 2024 23:43:29 -0300 Subject: [PATCH 09/22] fix: round spendable value down --- .../send-transaction/components/Form/CoinInput/util.ts | 2 +- packages/cardano/src/wallet/util/unit-converters.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/util.ts b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/util.ts index 011d62a539..563a7d363f 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/util.ts +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/util.ts @@ -47,7 +47,7 @@ export const getADACoinProperties = ( ): ADARow => { // Convert to ADA const availableADA = Wallet.util.lovelacesToAdaString(balance); - const spendableCoinInAda = Wallet.util.lovelacesToAdaString(spendableCoin); + const spendableCoinInAda = Wallet.util.lovelacesToAdaString(spendableCoin, undefined, BigNumber.ROUND_DOWN); // Calculate max amount in ADA const max = getMaxSpendableAmount(spendableCoinInAda, spentCoins, currentSpendingAmount); return { diff --git a/packages/cardano/src/wallet/util/unit-converters.ts b/packages/cardano/src/wallet/util/unit-converters.ts index 804a5630c8..b657fcf276 100644 --- a/packages/cardano/src/wallet/util/unit-converters.ts +++ b/packages/cardano/src/wallet/util/unit-converters.ts @@ -4,8 +4,11 @@ import { CoinId } from '../types'; const LOVELACE_VALUE = 1_000_000; const DEFAULT_DECIMALS = 2; -export const lovelacesToAdaString = (lovelaces: string, decimalValues: number = DEFAULT_DECIMALS): string => - new BigNumber(lovelaces).dividedBy(LOVELACE_VALUE).toFixed(decimalValues); +export const lovelacesToAdaString = ( + lovelaces: string, + decimalValues: number = DEFAULT_DECIMALS, + roudingMode: BigNumber.RoundingMode = BigNumber.ROUND_HALF_UP +): string => new BigNumber(lovelaces).dividedBy(LOVELACE_VALUE).toFixed(decimalValues, roudingMode); export const adaToLovelacesString = (ada: string): string => new BigNumber(ada).multipliedBy(LOVELACE_VALUE).toString(); From ac0766c6f16614d52435a71b331a65130de06cc8 Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Mon, 27 May 2024 14:37:41 -0300 Subject: [PATCH 10/22] fix: only call useMaxAda once --- .../components/Form/BundlesList.tsx | 5 ++- .../components/Form/CoinInput/CoinInput.tsx | 4 +- .../__tests__/useSelectedCoins.test.ts | 45 +++++++++++-------- .../Form/CoinInput/useSelectedCoins.tsx | 7 ++- .../send-transaction/components/Form/Form.tsx | 1 + 5 files changed, 38 insertions(+), 24 deletions(-) diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/BundlesList.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/BundlesList.tsx index b40ee32449..9171a3118b 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/BundlesList.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/BundlesList.tsx @@ -22,6 +22,7 @@ interface Props { assets: Map; setIsBundle: (value: boolean) => void; assetBalances: Tokens; + spendableCoin: bigint; } export const BundlesList = ({ @@ -32,7 +33,8 @@ export const BundlesList = ({ insufficientBalanceInputs, reachedMaxAmountList, assets, - assetBalances + assetBalances, + spendableCoin }: Props): React.ReactElement => { const { t } = useTranslation(); const { setSection } = useSections(); @@ -89,6 +91,7 @@ export const BundlesList = ({ onAddAsset={() => handleAssetPicker(bundleId)} openAssetPicker={(coinId) => handleAssetPicker(bundleId, coinId)} canAddMoreAssets={canAddMoreAssets(bundleId)} + spendableCoin={spendableCoin} /> ))} diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/CoinInput.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/CoinInput.tsx index eb23a2d74c..62463e93c6 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/CoinInput.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/CoinInput.tsx @@ -11,6 +11,7 @@ export type CoinInputProps = { assetBalances: Wallet.Cardano.Value['assets']; canAddMoreAssets?: boolean; onAddAsset?: () => void; + spendableCoin: bigint; } & Omit; export const CoinInput = ({ @@ -18,11 +19,12 @@ export const CoinInput = ({ assetBalances, onAddAsset, canAddMoreAssets, + spendableCoin, ...selectedCoinsProps }: CoinInputProps): React.ReactElement => { const { t } = useTranslation(); const { setCoinValues } = useCoinStateSelector(bundleId); - const { selectedCoins } = useSelectedCoins({ bundleId, assetBalances, ...selectedCoinsProps }); + const { selectedCoins } = useSelectedCoins({ bundleId, assetBalances, spendableCoin, ...selectedCoinsProps }); useEffect(() => { const { tempOutputs } = getTemporaryTxDataFromStorage(); diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/useSelectedCoins.test.ts b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/useSelectedCoins.test.ts index 984d57d3e0..2cbc16e242 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/useSelectedCoins.test.ts +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/useSelectedCoins.test.ts @@ -26,7 +26,6 @@ import { UseSelectedCoinsProps, useSelectedCoins } from '../useSelectedCoins'; import { COIN_SELECTION_ERRORS } from '@hooks/useInitializeTx'; import { mockAsset } from '@src/utils/mocks/test-helpers'; import * as UseFetchCoinPrice from '@hooks/useFetchCoinPrice'; -import * as UseMaxAda from '@hooks/useMaxAda'; import * as CurrencyProvider from '@providers/currency'; import * as Stores from '@stores'; import * as SendTransactionStore from '../../../../store'; @@ -35,10 +34,6 @@ jest.mock('@hooks/useFetchCoinPrice', (): typeof UseFetchCoinPrice => ({ ...jest.requireActual('@hooks/useFetchCoinPrice'), useFetchCoinPrice: mockUseFetchCoinPrice })); -jest.mock('@hooks/useMaxAda', (): typeof UseMaxAda => ({ - ...jest.requireActual('@hooks/useMaxAda'), - useMaxAda: mockUseMaxAda -})); jest.mock('@providers/currency', (): typeof CurrencyProvider => ({ ...jest.requireActual('@providers/currency'), useCurrencyStore: mockUseCurrencyStore @@ -82,6 +77,7 @@ describe('useSelectedCoin', () => { assets: new Map([[mockAsset.assetId, mockAsset]]), bundleId: 'bundleId', coinBalance: '1000000000', + spendableCoin: BigInt(100), openAssetPicker: jest.fn() }; const { result, waitFor, rerender } = renderUseSelectedCoins(props); @@ -188,7 +184,8 @@ describe('useSelectedCoin', () => { assetBalances: new Map(), assets: new Map(), bundleId: 'bundleId', - coinBalance: '1000000000000' + coinBalance: '1000000000000', + spendableCoin: BigInt(100) }; const { result } = renderUseSelectedCoins(props); @@ -205,7 +202,8 @@ describe('useSelectedCoin', () => { assetBalances: new Map(), assets: new Map(), bundleId: 'bundleId', - coinBalance: '1000000000000' + coinBalance: '1000000000000', + spendableCoin: BigInt(100) }; const { result } = renderUseSelectedCoins(props); @@ -228,7 +226,8 @@ describe('useSelectedCoin', () => { assetBalances: new Map(), assets: new Map(), bundleId: 'bundleId', - coinBalance: '12000000' + coinBalance: '12000000', + spendableCoin: BigInt(100) }; const { result } = renderUseSelectedCoins(props); @@ -249,7 +248,8 @@ describe('useSelectedCoin', () => { assetBalances: new Map(), assets: new Map(), bundleId: 'bundleId', - coinBalance: '12000000' + coinBalance: '12000000', + spendableCoin: BigInt(100) }; const { result } = renderUseSelectedCoins(props); @@ -270,7 +270,8 @@ describe('useSelectedCoin', () => { assetBalances: new Map(), assets: new Map(), bundleId: 'bundleId', - coinBalance: '12000000' + coinBalance: '12000000', + spendableCoin: BigInt(100) }; const { result } = renderUseSelectedCoins(props); @@ -295,7 +296,8 @@ describe('useSelectedCoin', () => { [mockAsset.assetId, { ...mockAsset, tokenMetadata: { ...mockAsset.tokenMetadata, ticker: 'TestTicker' } }] ]), bundleId: 'bundleId', - coinBalance: '0' + coinBalance: '0', + spendableCoin: BigInt(100) }; const { result } = renderUseSelectedCoins(props); @@ -326,7 +328,8 @@ describe('useSelectedCoin', () => { [mockAsset.assetId, { ...mockAsset, tokenMetadata: { ...mockAsset.tokenMetadata, decimals: 4 } }] ]), bundleId: 'bundleId', - coinBalance: '0' + coinBalance: '0', + spendableCoin: BigInt(100) }; const { result } = renderUseSelectedCoins(props); @@ -347,7 +350,8 @@ describe('useSelectedCoin', () => { assetBalances: new Map([[mockAsset.assetId, BigInt(10)]]), assets: new Map([[mockAsset.assetId, mockAsset]]), bundleId: 'bundleId', - coinBalance: '0' + coinBalance: '0', + spendableCoin: BigInt(100) }; const { result } = renderUseSelectedCoins(props); @@ -367,7 +371,8 @@ describe('useSelectedCoin', () => { assetBalances: new Map([[mockAsset.assetId, BigInt(10)]]), assets: new Map([[mockAsset.assetId, mockAsset]]), bundleId: 'bundleId', - coinBalance: '0' + coinBalance: '0', + spendableCoin: BigInt(100) }; const { result } = renderUseSelectedCoins(props); @@ -387,7 +392,8 @@ describe('useSelectedCoin', () => { assetBalances: new Map([[mockAsset.assetId, BigInt(10)]]), assets: new Map([[mockAsset.assetId, mockAsset]]), bundleId: 'bundleId', - coinBalance: '0' + coinBalance: '0', + spendableCoin: BigInt(100) }; const { result } = renderUseSelectedCoins(props); @@ -410,7 +416,8 @@ describe('useSelectedCoin', () => { assetBalances: new Map(), assets: new Map(), bundleId: 'bundleId', - coinBalance: '1000000000' + coinBalance: '1000000000', + spendableCoin: BigInt(100) }; const { result } = renderUseSelectedCoins(props); expect(result.current.selectedCoins[0].invalid).toEqual(true); @@ -429,7 +436,8 @@ describe('useSelectedCoin', () => { assets: new Map(), bundleId: 'bundleId', coinBalance: '1000000000', - insufficientBalanceInputs: ['bundleId.1'] + insufficientBalanceInputs: ['bundleId.1'], + spendableCoin: BigInt(100) }; const { result } = renderUseSelectedCoins(props); expect(result.current.selectedCoins[0].invalid).toEqual(true); @@ -450,7 +458,8 @@ describe('useSelectedCoin', () => { assetBalances: new Map(), assets: new Map(), bundleId: 'bundleId', - coinBalance: '1000000000' + coinBalance: '1000000000', + spendableCoin: BigInt(100) }; const { result } = renderUseSelectedCoins(props); expect(result.current.selectedCoins[0].invalid).toEqual(true); diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx index d961d92a33..501aa3f817 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/useSelectedCoins.tsx @@ -12,7 +12,6 @@ import { } from '../../../store'; import { COIN_SELECTION_ERRORS, getErrorMessage } from '@hooks/useInitializeTx'; import { useFetchCoinPrice } from '@hooks/useFetchCoinPrice'; -import { useMaxAda } from '@hooks/useMaxAda'; import { useTranslation } from 'react-i18next'; import { useWalletStore } from '@src/stores'; import { useCurrencyStore } from '@providers/currency'; @@ -40,6 +39,7 @@ export interface UseSelectedCoinsProps { coinBalance: string; insufficientBalanceInputs?: Array; openAssetPicker?: (id: string) => void; + spendableCoin: bigint; } export interface SelectedCoins { @@ -55,7 +55,8 @@ export const useSelectedCoins = ({ coinBalance, insufficientBalanceInputs, openAssetPicker, - bundleId + bundleId, + spendableCoin }: UseSelectedCoinsProps): SelectedCoins => { const { t } = useTranslation(); const { priceResult: prices } = useFetchCoinPrice(); @@ -67,8 +68,6 @@ export const useSelectedCoins = ({ const { address } = useAddressState(bundleId); const { builtTxData: { error: builtTxError } = {} } = useBuiltTxState(); const tokensUsed = useSpentBalances(); - // Max spendable ADA in lovelaces - const spendableCoin = useMaxAda(); const currentCoinToChange = useCurrentCoinIdToChange(); const { setLastFocusedInput } = useLastFocusedInput(); // TODO: change "rows" for "bundleIds" in the send transaction store [LW-7353] diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx index 8edda6df09..843c925fd0 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx @@ -140,6 +140,7 @@ export const Form = ({ reachedMaxAmountList={reachedMaxAmountList} assets={assets} assetBalances={assetBalances} + spendableCoin={spendableCoin} /> {!isPopupView && ( From 0a1c9fc377466242cb03c0c08e17dc45c0a86de4 Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Mon, 27 May 2024 14:43:28 -0300 Subject: [PATCH 11/22] refactor: debounce for output changes re renders --- .../src/hooks/useMaxAda.ts | 244 +++++++++--------- 1 file changed, 121 insertions(+), 123 deletions(-) diff --git a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts index 2a467191aa..b5ea46d830 100644 --- a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts +++ b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts @@ -1,69 +1,106 @@ /* eslint-disable no-magic-numbers */ import { useEffect, useState } from 'react'; -import { subtractValueQuantities } from '@cardano-sdk/core'; import { Wallet } from '@lace/cardano'; import { useWalletStore } from '@src/stores'; import { useObservable } from '@lace/common'; -import { COIN_SELECTION_ERRORS } from './useInitializeTx'; import { OutputsMap, useTransactionProps } from '@src/views/browser-view/features/send-transaction'; import { TxBuilder } from '@cardano-sdk/tx-construction'; import { Assets, WalletUtil } from '@cardano-sdk/wallet'; +import pDebounce from 'p-debounce'; +import { subtractValueQuantities } from '@cardano-sdk/core'; const { getTotalMinimumCoins, setMissingCoins } = Wallet; export const UTXO_DEPLETED_ADA_BUFFER = 1_000_000; export const ADA_BUFFER_LIMIT = UTXO_DEPLETED_ADA_BUFFER * 10; +const outputMapToSet = (outputMap: OutputsMap, assetInfo: Assets) => + [...outputMap].reduce((acc, [, output]) => { + if (output.value.assets) { + const assets = Wallet.convertAssetsToBigInt(output.value.assets, assetInfo); + acc.add({ + address: output.address, + value: { + coins: BigInt(output.value.coins), + assets + } + }); + } else { + acc.add({ + address: output.address, + value: { + coins: BigInt(output.value.coins) + } + }); + } + return acc; + }, new Set()); + +interface GetCoinsForTokens { + address: Wallet.Cardano.PaymentAddress; + balance: Wallet.Cardano.Value; + txBuilder: TxBuilder; + validateOutputs: WalletUtil['validateOutputs']; +} + +const getMinimunCoinsAndFee = async ({ address, balance, txBuilder, validateOutputs }: GetCoinsForTokens) => { + const outputs = new Set([ + { + address, + value: { + coins: BigInt(0), + assets: balance.assets || new Map() + } + } + ]); + + const minimumCoinQuantities = await validateOutputs(outputs); + const props = setMissingCoins(minimumCoinQuantities, outputs); + const totalMinimumCoins = getTotalMinimumCoins(minimumCoinQuantities); + props.outputs.forEach((output) => txBuilder.addOutput(output)); + const tx = await txBuilder.build().inspect(); + props.outputs.forEach((output) => txBuilder.removeOutput(output)); + + return { fee: tx.inputSelection.fee, minimumCoins: totalMinimumCoins.coinMissing }; +}; + interface CreateTestOutputs { address: Wallet.Cardano.PaymentAddress; adaAmount: bigint; outputMap?: OutputsMap; assetInfo: Assets; - validateOutput: WalletUtil['validateOutput']; + validateOutputs: WalletUtil['validateOutputs']; } -const createTestOutputs = async ({ +const createMaxAmountOutputs = async ({ address, adaAmount, assetInfo, outputMap = new Map(), - validateOutput + validateOutputs }: CreateTestOutputs) => { - const outputs: Wallet.Cardano.TxOut[] = [ - { - address, - value: { - coins: adaAmount - } - } - ]; - - for (const [, output] of outputMap) { - if (output.value.assets) { - const assets = Wallet.convertAssetsToBigInt(output.value.assets, assetInfo); - const txOut = { - address: output.address, - value: { - coins: BigInt(0), - assets - } - }; - const minimumCoinQuantities = await validateOutput(txOut); - outputs.push({ - address: txOut.address, + if (outputMap.size < 2) { + return new Set([ + { + address, value: { - ...txOut.value, - coins: BigInt(minimumCoinQuantities.minimumCoin) + coins: adaAmount } - }); - } + } + ]); } - return outputs; + const outputSet = outputMapToSet(outputMap, assetInfo); + const { outputs: outputsWithMissingCoins } = setMissingCoins(await validateOutputs(outputSet), outputSet); + const [firstOutput, ...rest] = outputsWithMissingCoins; + const totalAdaInOutputs = rest.reduce((acc, output) => acc + output.value.coins, BigInt(0)); + firstOutput.value.coins = adaAmount - totalAdaInOutputs; + + return outputsWithMissingCoins; }; interface IsTransactionBuildable { - outputs: Wallet.Cardano.TxOut[]; + outputs: Set; txBuilder: TxBuilder; } @@ -79,50 +116,48 @@ const isTransactionBuildable = async ({ outputs, txBuilder }: IsTransactionBuild } }; -interface GetAdaErrorBuffer { +interface CalculateMaxAda { txBuilder: TxBuilder; address: Wallet.Cardano.PaymentAddress; maxAdaAmount: bigint; assetInfo: Assets; outputMap?: OutputsMap; signal: AbortSignal; - validateOutput: WalletUtil['validateOutput']; + adaErrorBuffer?: bigint; + validateOutputs: WalletUtil['validateOutputs']; } -const getAdaErrorBuffer = async ({ +const calculateMaxAda = async ({ txBuilder, address, outputMap, maxAdaAmount, assetInfo, signal, - validateOutput, + validateOutputs, adaErrorBuffer = BigInt(0) -}: GetAdaErrorBuffer & { adaErrorBuffer?: bigint }): Promise => { - if (signal.aborted) { +}: CalculateMaxAda): Promise => { + if (signal.aborted || adaErrorBuffer > maxAdaAmount || adaErrorBuffer > ADA_BUFFER_LIMIT) { throw new Error('Aborted'); } - if (adaErrorBuffer > maxAdaAmount || adaErrorBuffer > ADA_BUFFER_LIMIT) { - throw new Error(COIN_SELECTION_ERRORS.FULLY_DEPLETED_ERROR); - } - const adaAmount = maxAdaAmount - adaErrorBuffer; - const outputs = await createTestOutputs({ + const outputs = await createMaxAmountOutputs({ address, adaAmount, assetInfo, outputMap, - validateOutput + validateOutputs }); + const canBuildTx = await isTransactionBuildable({ outputs, txBuilder }); if (canBuildTx) { - return adaErrorBuffer; + return adaAmount; } - return getAdaErrorBuffer({ + return calculateMaxAda({ txBuilder, - validateOutput, + validateOutputs, address, outputMap, maxAdaAmount, @@ -132,68 +167,7 @@ const getAdaErrorBuffer = async ({ }); }; -interface CalculateMaxAda { - inMemoryWallet: Wallet.ObservableWallet; - address: Wallet.Cardano.PaymentAddress; - balance: Wallet.Cardano.Value; - availableRewards: bigint; - assetInfo: Assets; - signal: AbortSignal; - outputMap: OutputsMap; -} - -const calculateMaxAda = async ({ - balance, - inMemoryWallet, - address, - availableRewards, - outputMap, - assetInfo, - signal -}: CalculateMaxAda) => { - if (!balance?.coins || !address) { - return BigInt(0); - } - const txBuilder = inMemoryWallet.createTxBuilder(); - const { validateOutput, validateOutputs } = Wallet.createWalletUtil(inMemoryWallet); - // create and output with only the wallet tokens and nfts so we can calculate the mising coins for feature txs - const outputs = new Set([ - { - address, - value: { - coins: BigInt(0), - assets: balance.assets || new Map() - } - } - ]); - const minimumCoinQuantities = await validateOutputs(outputs); - const totalMinimumCoins = getTotalMinimumCoins(minimumCoinQuantities); - const props = setMissingCoins(minimumCoinQuantities, outputs); - try { - // build a tx get an approximate of the fee - const tx = await inMemoryWallet.initializeTx(props); - // substract the fee and the missing coins from the wallet balances - const spendableBalance = subtractValueQuantities([ - { coins: balance.coins + BigInt(availableRewards || 0) }, // wallet balance - { coins: BigInt(totalMinimumCoins.coinMissing) }, // this is the minimun coins needed for all the wallet tokens - { coins: tx.inputSelection.fee } // this is an approximate fee - ]); - - const errorBuffer = await getAdaErrorBuffer({ - address, - txBuilder, - maxAdaAmount: spendableBalance.coins, - assetInfo, - outputMap, - validateOutput, - signal - }); - - return spendableBalance.coins - errorBuffer; - } catch { - return BigInt(0); - } -}; +export const dCalculateMaxAda = pDebounce.promise(calculateMaxAda); export const useMaxAda = (): bigint => { const [maxADA, setMaxADA] = useState(); @@ -206,23 +180,47 @@ export const useMaxAda = (): bigint => { useEffect(() => { const abortController = new AbortController(); - const calculate = async () => { - const result = await calculateMaxAda({ - address, - availableRewards, - balance, - assetInfo, - inMemoryWallet, - signal: abortController.signal, - outputMap - }); - if (!abortController.signal.aborted) { - setMaxADA(result); + const calculate = async () => { + try { + const { validateOutputs } = Wallet.createWalletUtil(inMemoryWallet); + const txBuilder = inMemoryWallet.createTxBuilder(); + const { fee, minimumCoins } = await getMinimunCoinsAndFee({ + address, + balance, + txBuilder, + validateOutputs + }); + // substract the fee and the missing coins from the wallet balances + const spendableBalance = subtractValueQuantities([ + { coins: balance.coins + BigInt(availableRewards || 0) }, // wallet balance + { coins: BigInt(minimumCoins) }, // this is the minimun coins needed for all the wallet tokens + { coins: fee } // this is an approximate fee + ]); + + const result = await dCalculateMaxAda({ + address, + assetInfo, + maxAdaAmount: spendableBalance.coins, + txBuilder, + validateOutputs, + signal: abortController.signal, + outputMap + }); + + if (!abortController.signal.aborted) { + setMaxADA(result); + } + } catch { + if (!abortController.signal.aborted) { + setMaxADA(BigInt(0)); + } } }; - calculate(); + if (balance) { + calculate(); + } return () => { abortController.abort(); }; From f0cf533588325a0df630599082bee0d8496b5c16 Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Mon, 27 May 2024 21:59:27 -0300 Subject: [PATCH 12/22] fix: update tests --- .../src/hooks/__tests__/useMaxAda.test.ts | 56 +++++++++++++------ .../src/hooks/useMaxAda.ts | 5 +- .../__tests__/useSelectedCoins.test.ts | 10 +--- 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts b/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts index 51cd3e934e..37b9b15330 100644 --- a/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts +++ b/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts @@ -7,14 +7,17 @@ import { Subject, of } from 'rxjs'; import { Wallet } from '@lace/cardano'; import { waitFor } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; -const mockInitializeTx = jest.fn(); -const inspect = jest.fn().mockReturnThis(); + +const MIN_COINS_FOR_TOKENS = 1_155_080; +const TX_FEE = 155_381; + +const inspect = jest.fn(); const mockCreateTxBuilder = jest.fn().mockReturnValue({ inspect, build: jest.fn().mockReturnThis(), - addOutput: jest.fn().mockReturnThis() + addOutput: jest.fn().mockReturnThis(), + removeOutput: jest.fn().mockReturnThis() }); -const TX_FEE = 155_381; const inMemoryWallet = { balance: { utxo: { @@ -25,7 +28,6 @@ const inMemoryWallet = { } }, protocolParameters$: of({ coinsPerUtxoByte: 4310, maxValueSize: 5000 }), - initializeTx: mockInitializeTx, createTxBuilder: mockCreateTxBuilder }; @@ -38,14 +40,23 @@ jest.mock('../../stores', () => ({ }) })); +const outputMap = new Map(); + +jest.mock('../../views/browser-view/features/send-transaction', () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...jest.requireActual('../../views/browser-view/features/send-transaction'), + useTransactionProps: () => ({ + outputMap + }) +})); + describe('Testing useMaxAda hook', () => { beforeEach(() => { - mockInitializeTx.mockImplementationOnce(() => ({ + inspect.mockResolvedValue({ inputSelection: { - // eslint-disable-next-line no-magic-numbers fee: BigInt(TX_FEE) } - })); + }); }); afterEach(() => { jest.clearAllMocks(); @@ -68,8 +79,8 @@ describe('Testing useMaxAda hook', () => { }); test('should return 0 in case there is an error', async () => { - mockInitializeTx.mockReset(); - mockInitializeTx.mockImplementation(async () => { + inspect.mockReset(); + inspect.mockImplementation(async () => { throw new Error('init tx error'); }); const { result } = renderHook(() => useMaxAda()); @@ -84,10 +95,9 @@ describe('Testing useMaxAda hook', () => { test('should return 0 if balance is minimum for coins', async () => { const { result } = renderHook(() => useMaxAda()); - act(() => { inMemoryWallet.balance.utxo.available$.next({ - coins: BigInt('1155080') + BigInt(TX_FEE), + coins: BigInt(MIN_COINS_FOR_TOKENS) + BigInt(TX_FEE), assets: new Map([ [ Wallet.Cardano.AssetId('659f2917fb63f12b33667463ee575eeac1845bbc736b9c0bbc40ba8254534c41'), @@ -102,26 +112,31 @@ describe('Testing useMaxAda hook', () => { }); }); - test('should return 8874869', async () => { + test('should return balance minus fee', async () => { const { result } = renderHook(() => useMaxAda()); act(() => { inMemoryWallet.balance.utxo.available$.next({ coins: BigInt('10000000') }); }); await waitFor(() => { - expect(result.current.toString()).toBe('8874869'); + expect(result.current).toBe(BigInt('10000000') - BigInt(TX_FEE)); }); }); - test.each([[1], [3], [7]])('should return 8689539 minus adaErrorBuffer*%i', async (errorCount) => { - const { result } = renderHook(() => useMaxAda()); - + test.each([[1]])('should return balance minus fee and adaErrorBuffer times %i', async (errorCount) => { + inspect.mockResolvedValueOnce({ + inputSelection: { + fee: BigInt(TX_FEE) + } + }); Array.from({ length: errorCount }).forEach(() => { inspect.mockImplementationOnce(() => { throw new Error('Error'); }); }); + const { result } = renderHook(() => useMaxAda()); + act(() => { inMemoryWallet.balance.utxo.available$.next({ coins: BigInt('10000000'), @@ -135,7 +150,12 @@ describe('Testing useMaxAda hook', () => { }); await waitFor(() => { - expect(result.current).toBe(BigInt('8689539') - BigInt(UTXO_DEPLETED_ADA_BUFFER * errorCount)); + expect(result.current).toBe( + BigInt('10000000') - + BigInt(MIN_COINS_FOR_TOKENS) - + BigInt(TX_FEE) - + BigInt(UTXO_DEPLETED_ADA_BUFFER * errorCount) + ); }); }); }); diff --git a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts index b5ea46d830..7603f6bc21 100644 --- a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts +++ b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts @@ -61,7 +61,7 @@ const getMinimunCoinsAndFee = async ({ address, balance, txBuilder, validateOutp const tx = await txBuilder.build().inspect(); props.outputs.forEach((output) => txBuilder.removeOutput(output)); - return { fee: tx.inputSelection.fee, minimumCoins: totalMinimumCoins.coinMissing }; + return { fee: tx.inputSelection.fee, minimumCoins: balance.assets ? totalMinimumCoins.coinMissing : BigInt(0) }; }; interface CreateTestOutputs { @@ -170,7 +170,7 @@ const calculateMaxAda = async ({ export const dCalculateMaxAda = pDebounce.promise(calculateMaxAda); export const useMaxAda = (): bigint => { - const [maxADA, setMaxADA] = useState(); + const [maxADA, setMaxADA] = useState(BigInt(0)); const { walletInfo, inMemoryWallet } = useWalletStore(); const balance = useObservable(inMemoryWallet?.balance?.utxo.available$); const availableRewards = useObservable(inMemoryWallet?.balance?.rewardAccounts?.rewards$); @@ -207,7 +207,6 @@ export const useMaxAda = (): bigint => { signal: abortController.signal, outputMap }); - if (!abortController.signal.aborted) { setMaxADA(result); } diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/useSelectedCoins.test.ts b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/useSelectedCoins.test.ts index 2cbc16e242..9aee39a5b0 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/useSelectedCoins.test.ts +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/CoinInput/__tests__/useSelectedCoins.test.ts @@ -10,7 +10,6 @@ const mockUseCurrencyStore = jest.fn().mockReturnValue({ fiatCurrency: { code: ' const mockUseWalletStore = jest.fn().mockReturnValue({ walletUI: { cardanoCoin: { id: '1', name: 'Cardano', decimals: 6, symbol: 'ADA' }, appMode: 'popup' } }); -const mockUseMaxAda = jest.fn().mockReturnValue(BigInt(100)); const mockUseCoinStateSelector = jest.fn().mockReturnValue(mockCoinStateSelector); const mockUseBuiltTxState = jest.fn().mockReturnValue({ builtTxData: { error: undefined } }); const mockUseAddressState = jest.fn().mockReturnValue({ address: undefined }); @@ -220,14 +219,13 @@ describe('useSelectedCoin', () => { ...mockCoinStateSelector, uiOutputs: [{ id: '1', value: '0' }] }); - mockUseMaxAda.mockReturnValueOnce(BigInt(10_000_000)); mockUseSpentBalances.mockReturnValueOnce({}); const props: UseSelectedCoinsProps = { assetBalances: new Map(), assets: new Map(), bundleId: 'bundleId', coinBalance: '12000000', - spendableCoin: BigInt(100) + spendableCoin: BigInt(10_000_000) }; const { result } = renderUseSelectedCoins(props); @@ -242,14 +240,13 @@ describe('useSelectedCoin', () => { ...mockCoinStateSelector, uiOutputs: [{ id: '1', value: '2' }] }); - mockUseMaxAda.mockReturnValueOnce(BigInt(10_000_000)); mockUseSpentBalances.mockReturnValueOnce({ '1': '5' }); const props: UseSelectedCoinsProps = { assetBalances: new Map(), assets: new Map(), bundleId: 'bundleId', coinBalance: '12000000', - spendableCoin: BigInt(100) + spendableCoin: BigInt(10_000_000) }; const { result } = renderUseSelectedCoins(props); @@ -264,14 +261,13 @@ describe('useSelectedCoin', () => { ...mockCoinStateSelector, uiOutputs: [{ id: '1', value: '3' }] }); - mockUseMaxAda.mockReturnValueOnce(BigInt(10_000_000)); mockUseSpentBalances.mockReturnValueOnce({ '1': '13' }); const props: UseSelectedCoinsProps = { assetBalances: new Map(), assets: new Map(), bundleId: 'bundleId', coinBalance: '12000000', - spendableCoin: BigInt(100) + spendableCoin: BigInt(10_000_000) }; const { result } = renderUseSelectedCoins(props); From e7b43ef6f7c51a1ed2b860f75e6c13ded86e6363 Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Tue, 28 May 2024 22:30:31 -0300 Subject: [PATCH 13/22] fix: use max amount on first output --- .../src/hooks/__tests__/useMaxAda.test.ts | 34 +++++++----- .../src/hooks/useMaxAda.ts | 54 +++++++++---------- 2 files changed, 46 insertions(+), 42 deletions(-) diff --git a/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts b/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts index 37b9b15330..fec822d798 100644 --- a/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts +++ b/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts @@ -123,6 +123,25 @@ describe('Testing useMaxAda hook', () => { }); }); + test('should return balance minus fee and minimun ada for tokens', async () => { + const { result } = renderHook(() => useMaxAda()); + + act(() => { + inMemoryWallet.balance.utxo.available$.next({ + coins: BigInt('10000000'), + assets: new Map([ + [ + Wallet.Cardano.AssetId('659f2917fb63f12b33667463ee575eeac1845bbc736b9c0bbc40ba8254534c41'), + BigInt('100000000') + ] + ]) + }); + }); + await waitFor(() => { + expect(result.current).toBe(BigInt('10000000') - BigInt(TX_FEE) - BigInt(MIN_COINS_FOR_TOKENS)); + }); + }); + test.each([[1]])('should return balance minus fee and adaErrorBuffer times %i', async (errorCount) => { inspect.mockResolvedValueOnce({ inputSelection: { @@ -139,23 +158,12 @@ describe('Testing useMaxAda hook', () => { act(() => { inMemoryWallet.balance.utxo.available$.next({ - coins: BigInt('10000000'), - assets: new Map([ - [ - Wallet.Cardano.AssetId('659f2917fb63f12b33667463ee575eeac1845bbc736b9c0bbc40ba8254534c41'), - BigInt('100000000') - ] - ]) + coins: BigInt('10000000') }); }); await waitFor(() => { - expect(result.current).toBe( - BigInt('10000000') - - BigInt(MIN_COINS_FOR_TOKENS) - - BigInt(TX_FEE) - - BigInt(UTXO_DEPLETED_ADA_BUFFER * errorCount) - ); + expect(result.current).toBe(BigInt('10000000') - BigInt(TX_FEE) - BigInt(UTXO_DEPLETED_ADA_BUFFER * errorCount)); }); }); }); diff --git a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts index 7603f6bc21..339c7953cd 100644 --- a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts +++ b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts @@ -14,28 +14,6 @@ const { getTotalMinimumCoins, setMissingCoins } = Wallet; export const UTXO_DEPLETED_ADA_BUFFER = 1_000_000; export const ADA_BUFFER_LIMIT = UTXO_DEPLETED_ADA_BUFFER * 10; -const outputMapToSet = (outputMap: OutputsMap, assetInfo: Assets) => - [...outputMap].reduce((acc, [, output]) => { - if (output.value.assets) { - const assets = Wallet.convertAssetsToBigInt(output.value.assets, assetInfo); - acc.add({ - address: output.address, - value: { - coins: BigInt(output.value.coins), - assets - } - }); - } else { - acc.add({ - address: output.address, - value: { - coins: BigInt(output.value.coins) - } - }); - } - return acc; - }, new Set()); - interface GetCoinsForTokens { address: Wallet.Cardano.PaymentAddress; balance: Wallet.Cardano.Value; @@ -72,7 +50,7 @@ interface CreateTestOutputs { validateOutputs: WalletUtil['validateOutputs']; } -const createMaxAmountOutputs = async ({ +const createOutputsWithMaxAmount = async ({ address, adaAmount, assetInfo, @@ -90,12 +68,30 @@ const createMaxAmountOutputs = async ({ ]); } - const outputSet = outputMapToSet(outputMap, assetInfo); - const { outputs: outputsWithMissingCoins } = setMissingCoins(await validateOutputs(outputSet), outputSet); - const [firstOutput, ...rest] = outputsWithMissingCoins; - const totalAdaInOutputs = rest.reduce((acc, output) => acc + output.value.coins, BigInt(0)); - firstOutput.value.coins = adaAmount - totalAdaInOutputs; + const outputSet = [...outputMap].reduce((acc, [, output]) => { + if (output.value.assets) { + const assets = Wallet.convertAssetsToBigInt(output.value.assets, assetInfo); + acc.add({ + address: output.address, + value: { + coins: BigInt(0), + assets + } + }); + } else { + acc.add({ + address: output.address, + value: { + coins: BigInt(0) + } + }); + } + return acc; + }, new Set()); + const { outputs: outputsWithMissingCoins } = setMissingCoins(await validateOutputs(outputSet), outputSet); + const [firstOutput] = outputsWithMissingCoins; + firstOutput.value.coins = adaAmount; return outputsWithMissingCoins; }; @@ -141,7 +137,7 @@ const calculateMaxAda = async ({ throw new Error('Aborted'); } const adaAmount = maxAdaAmount - adaErrorBuffer; - const outputs = await createMaxAmountOutputs({ + const outputs = await createOutputsWithMaxAmount({ address, adaAmount, assetInfo, From 9397230dc8c769cccba6f69d9405997d65084fd6 Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Wed, 29 May 2024 14:44:41 -0300 Subject: [PATCH 14/22] fix: consider rewards on send flow max amount allowed --- apps/browser-extension-wallet/src/hooks/useInitializeTx.ts | 5 ++++- .../features/send-transaction/components/AssetPicker.tsx | 3 ++- .../features/send-transaction/components/Form/Form.tsx | 4 +++- .../views/browser-view/features/send-transaction/helpers.ts | 6 ++++-- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts b/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts index ef6814b063..a4b3dfbd5d 100644 --- a/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts +++ b/apps/browser-extension-wallet/src/hooks/useInitializeTx.ts @@ -53,6 +53,7 @@ export const useInitializeTx = ( const assetsInfo = useObservable(inMemoryWallet.assetInfo$); const balance = useObservable(inMemoryWallet.balance.utxo.total$); const tokensUsed = useSpentBalances(); + const availableRewards = useObservable(inMemoryWallet.balance.rewardAccounts.rewards$); const buildTransaction = useCallback(async () => { const reachedMaxAmountList = getReachedMaxAmountList({ @@ -60,7 +61,8 @@ export const useInitializeTx = ( tokensUsed, balance, exceed: true, - cardanoCoin + cardanoCoin, + availableRewards }); if (hasInvalidOutputs || reachedMaxAmountList.length > 0) { setBuiltTxData({ @@ -113,6 +115,7 @@ export const useInitializeTx = ( tokensUsed, balance, cardanoCoin, + availableRewards, hasInvalidOutputs, setBuiltTxData, metadata, diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/AssetPicker.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/AssetPicker.tsx index abdade3c2c..9932239473 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/AssetPicker.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/AssetPicker.tsx @@ -159,7 +159,8 @@ export const AssetPicker = ({ isPopupView }: AssetPickerProps): React.ReactEleme assets, tokensUsed, balance, - cardanoCoin + cardanoCoin, + availableRewards }); const usedAssetsIds = new Set([...list.map(({ id }) => id), ...reachedMaxAmountList]); diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx index 843c925fd0..dfa61d70e7 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/Form/Form.tsx @@ -53,6 +53,7 @@ export const Form = ({ const { lastFocusedInput } = useLastFocusedInput(); const { fiatCurrency } = useCurrencyStore(); const { getCustomSubmitApiForNetwork } = useCustomSubmitApi(); + const availableRewards = useObservable(inMemoryWallet?.balance?.rewardAccounts?.rewards$); const { setNewOutput } = useOutputs(); @@ -70,10 +71,11 @@ export const Form = ({ tokensUsed, balance, cardanoCoin, + availableRewards, exceed: true }) || [] ), - [assets, balance, cardanoCoin, tokensUsed] + [assets, availableRewards, balance, cardanoCoin, tokensUsed] ); useEffect(() => { diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/helpers.ts b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/helpers.ts index 5c84899e67..a5dcc8d1e1 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/helpers.ts +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/helpers.ts @@ -57,18 +57,20 @@ export const getReachedMaxAmountList = ({ tokensUsed, balance, exceed = false, - cardanoCoin + cardanoCoin, + availableRewards = BigInt(0) }: { assets: Wallet.Assets; tokensUsed: SpentBalances; balance: Wallet.Cardano.Value; exceed?: boolean; cardanoCoin: Wallet.CoinId; + availableRewards?: bigint; }): (string | Wallet.Cardano.AssetId)[] => { const reachedMaxAmountAda = tokensUsed[cardanoCoin.id] && balance?.coins ? new BigNumber(tokensUsed[cardanoCoin.id])[exceed ? 'gt' : 'gte']( - Wallet.util.lovelacesToAdaString(balance.coins.toString()) + Wallet.util.lovelacesToAdaString((balance.coins + availableRewards).toString()) ) : false; From ffa28bddebba96c5a26cc59057c501ae67c90dc8 Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Wed, 29 May 2024 14:46:15 -0300 Subject: [PATCH 15/22] feat: disable button while max ada is calculated --- apps/browser-extension-wallet/src/hooks/useMaxAda.ts | 8 ++++++-- .../components/SendTransactionDrawer/Footer.tsx | 7 +++++-- .../features/send-transaction/store/store.ts | 12 ++++++++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts index 339c7953cd..f7b5aa1422 100644 --- a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts +++ b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { Wallet } from '@lace/cardano'; import { useWalletStore } from '@src/stores'; import { useObservable } from '@lace/common'; -import { OutputsMap, useTransactionProps } from '@src/views/browser-view/features/send-transaction'; +import { OutputsMap, useMaxAdaStatus, useTransactionProps } from '@src/views/browser-view/features/send-transaction'; import { TxBuilder } from '@cardano-sdk/tx-construction'; import { Assets, WalletUtil } from '@cardano-sdk/wallet'; import pDebounce from 'p-debounce'; @@ -172,6 +172,7 @@ export const useMaxAda = (): bigint => { const availableRewards = useObservable(inMemoryWallet?.balance?.rewardAccounts?.rewards$); const assetInfo = useObservable(inMemoryWallet?.assetInfo$); const { outputMap } = useTransactionProps(); + const { setMaxAdaLoading } = useMaxAdaStatus(); const address = walletInfo.addresses[0].address; useEffect(() => { @@ -179,6 +180,7 @@ export const useMaxAda = (): bigint => { const calculate = async () => { try { + setMaxAdaLoading(true); const { validateOutputs } = Wallet.createWalletUtil(inMemoryWallet); const txBuilder = inMemoryWallet.createTxBuilder(); const { fee, minimumCoins } = await getMinimunCoinsAndFee({ @@ -210,6 +212,8 @@ export const useMaxAda = (): bigint => { if (!abortController.signal.aborted) { setMaxADA(BigInt(0)); } + } finally { + setMaxAdaLoading(false); } }; @@ -219,7 +223,7 @@ export const useMaxAda = (): bigint => { return () => { abortController.abort(); }; - }, [availableRewards, assetInfo, balance, inMemoryWallet, address, outputMap]); + }, [availableRewards, assetInfo, balance, inMemoryWallet, address, outputMap, setMaxAdaLoading]); return maxADA; }; diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/SendTransactionDrawer/Footer.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/SendTransactionDrawer/Footer.tsx index 96c3beb5b5..94684e3b55 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/SendTransactionDrawer/Footer.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/components/SendTransactionDrawer/Footer.tsx @@ -19,7 +19,8 @@ import { useTransactionProps, usePassword, useMetadata, - useAnalyticsSendFlowTriggerPoint + useAnalyticsSendFlowTriggerPoint, + useMaxAdaStatus } from '../../store'; import { useHandleClose } from './Header'; import { useWalletStore } from '@src/stores'; @@ -85,6 +86,7 @@ export const Footer = withAddressBookContext( const { list: addressList, utils } = useAddressBookContext(); const { updateRecord: updateAddress, deleteRecord: deleteAddress } = utils; const handleResolver = useHandleResolver(); + const { isMaxAdaLoading } = useMaxAdaStatus(); const isSummaryStep = currentSection.currentSection === Sections.SUMMARY; @@ -310,7 +312,8 @@ export const Footer = withAddressBookContext( }, [t, currentSection.currentSection]); const isConfirmButtonDisabled = - (confirmDisable || isSubmitDisabled) && currentSection.currentSection !== Sections.ADDRESS_CHANGE; + (confirmDisable || isSubmitDisabled || isMaxAdaLoading) && + currentSection.currentSection !== Sections.ADDRESS_CHANGE; const submitHwFormStep = useCallback(() => { triggerSubmit(); diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/store/store.ts b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/store/store.ts index 98ac394b6f..79f91e3766 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/store/store.ts +++ b/apps/browser-extension-wallet/src/views/browser-view/features/send-transaction/store/store.ts @@ -41,7 +41,8 @@ const initialState = { isPasswordValid: true, isMultipleSelectionAvailable: false, selectedTokenList: [] as Array, - selectedNFTs: [] as Array + selectedNFTs: [] as Array, + isMaxAdaLoading: false }; const MAX_TOKEN_SELECTED_ALLOW = 30; @@ -108,6 +109,9 @@ export interface Store { // Analytics specific properties triggerPoint?: SendFlowTriggerPoints; setTriggerPoint: (param: SendFlowTriggerPoints) => void; + // ====== Max Ada calculation ====== + isMaxAdaLoading: boolean; + setMaxAdaLoading: (maxAdaInProgress?: boolean) => void; } // ====== state setters ====== @@ -345,7 +349,8 @@ const useStore = create((set, get) => ({ resetTokenList: () => set({ selectedTokenList: [], selectedNFTs: [] }), setIsRestaking: (isRestaking) => set({ isRestaking }), setLastFocusedInput: (lastFocusedInput) => set({ lastFocusedInput }), // keep track of the last focused input element, this way we know where to display the error - setTriggerPoint: (triggerPoint) => set({ triggerPoint }) + setTriggerPoint: (triggerPoint) => set({ triggerPoint }), + setMaxAdaLoading: (isMaxAdaLoading) => set({ isMaxAdaLoading }) })); // ====== selectors ====== @@ -567,4 +572,7 @@ export const useAnalyticsSendFlowTriggerPoint = (): { setTriggerPoint: Store['setTriggerPoint']; } => useStore(({ triggerPoint, setTriggerPoint }) => ({ triggerPoint, setTriggerPoint })); +export const useMaxAdaStatus = (): Pick => + useStore(({ isMaxAdaLoading, setMaxAdaLoading }) => ({ isMaxAdaLoading, setMaxAdaLoading })); + export { useStore as sendTransactionStore }; From b18b93a1e9e1bf42b3252dc5a9836f8d59d12501 Mon Sep 17 00:00:00 2001 From: Lukasz Jagiela Date: Tue, 4 Jun 2024 18:29:50 +0200 Subject: [PATCH 16/22] test(extension): fix for smoke tests --- packages/e2e-tests/src/steps/sendTransactionSimpleSteps.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/e2e-tests/src/steps/sendTransactionSimpleSteps.ts b/packages/e2e-tests/src/steps/sendTransactionSimpleSteps.ts index 15e96169b1..dae1067853 100644 --- a/packages/e2e-tests/src/steps/sendTransactionSimpleSteps.ts +++ b/packages/e2e-tests/src/steps/sendTransactionSimpleSteps.ts @@ -601,8 +601,9 @@ When( ); When(/^I click "Confirm" button on "Transaction summary" page$/, async () => { - await TransactionSummaryPage.confirmButton.waitForEnabled(); + await TransactionSummaryPage.confirmButton.waitForClickable(); await TransactionSummaryPage.confirmButton.click(); + await TransactionSummaryPage.confirmButton.click(); // workaround because single click for some reason is not enough }); When(/^I click "(Save|Cancel)" button on "Add address" drawer in send flow$/, async (button: 'Save' | 'Cancel') => { From a172f710fd16fef498ed6ee80088a7e88eab6ae7 Mon Sep 17 00:00:00 2001 From: Lukasz Jagiela Date: Fri, 14 Jun 2024 12:51:44 +0200 Subject: [PATCH 17/22] test(extension): e2e test adjustments --- packages/e2e-tests/src/assert/topNavigationAssert.ts | 2 +- packages/e2e-tests/src/pageobject/nftsPageObject.ts | 4 ++++ packages/e2e-tests/src/steps/sendTransactionSimpleSteps.ts | 5 ++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/e2e-tests/src/assert/topNavigationAssert.ts b/packages/e2e-tests/src/assert/topNavigationAssert.ts index e94815ca38..e80d2856b9 100644 --- a/packages/e2e-tests/src/assert/topNavigationAssert.ts +++ b/packages/e2e-tests/src/assert/topNavigationAssert.ts @@ -71,7 +71,7 @@ class TopNavigationAssert { async assertSyncStatusValid(expectedStatus: string) { expectedStatus = (await t(expectedStatus)) ?? expectedStatus; - await MenuHeader.menuUserDetailsButton.waitForDisplayed(); + await MenuHeader.menuUserDetailsButton.waitForDisplayed({ timeout: 180_000 }); await browser.waitUntil(async () => (await MenuHeader.menuWalletStatus.getText()) === expectedStatus, { timeout: 180_000, interval: 500, diff --git a/packages/e2e-tests/src/pageobject/nftsPageObject.ts b/packages/e2e-tests/src/pageobject/nftsPageObject.ts index 53cb0264ab..a2afe0dfeb 100644 --- a/packages/e2e-tests/src/pageobject/nftsPageObject.ts +++ b/packages/e2e-tests/src/pageobject/nftsPageObject.ts @@ -48,6 +48,10 @@ class NftsPageObject { await TransactionNewPage.reviewTransactionButton.click(); await TransactionSummaryPage.confirmButton.waitForClickable(); await TransactionSummaryPage.confirmButton.click(); + await browser.pause(500); + if (await TransactionSummaryPage.confirmButton.isDisplayed()) { + await TransactionSummaryPage.confirmButton.click(); // workaround because single click for some reason may not be enough + } } async isNftDisplayed(nftName: string): Promise { diff --git a/packages/e2e-tests/src/steps/sendTransactionSimpleSteps.ts b/packages/e2e-tests/src/steps/sendTransactionSimpleSteps.ts index dae1067853..a0d9867c8b 100644 --- a/packages/e2e-tests/src/steps/sendTransactionSimpleSteps.ts +++ b/packages/e2e-tests/src/steps/sendTransactionSimpleSteps.ts @@ -603,7 +603,10 @@ When( When(/^I click "Confirm" button on "Transaction summary" page$/, async () => { await TransactionSummaryPage.confirmButton.waitForClickable(); await TransactionSummaryPage.confirmButton.click(); - await TransactionSummaryPage.confirmButton.click(); // workaround because single click for some reason is not enough + await browser.pause(500); + if (await TransactionSummaryPage.confirmButton.isDisplayed()) { + await TransactionSummaryPage.confirmButton.click(); // workaround because single click for some reason may not be enough + } }); When(/^I click "(Save|Cancel)" button on "Add address" drawer in send flow$/, async (button: 'Save' | 'Cancel') => { From bdb54e33b2dc9e552df65bade41be09fdeedea91 Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Tue, 18 Jun 2024 11:58:41 -0300 Subject: [PATCH 18/22] fix: merge conflict --- .../src/lib/translations/browser-extension-wallet/en.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/translation/src/lib/translations/browser-extension-wallet/en.json b/packages/translation/src/lib/translations/browser-extension-wallet/en.json index 62f1c64ed0..57882bf84b 100644 --- a/packages/translation/src/lib/translations/browser-extension-wallet/en.json +++ b/packages/translation/src/lib/translations/browser-extension-wallet/en.json @@ -458,7 +458,6 @@ "browserView.transaction.send.advancedFlowText": "Click 'Add bundle' to send assets to another recipient in this transaction.", "browserView.transaction.send.advancedTransaction": "Advanced transaction", "browserView.transaction.send.confirmationTitle": "Transaction confirmation", - "browserView.transaction.send.customSubmitApiBannerText": "Custom submit API enabled to send transactions. To disable, go to Settings >> Custom submit API", "browserView.transaction.send.utxoDepletedBannerErrorText": "Due to the minUTXO requirement for native assets, some ADA cannot be spent.", "browserView.transaction.send.utxoDepletedBannerMaxButtonText": "Use the 'Max' button to select the maximum spendable amount.", "browserView.transaction.send.drawer.addBundle": "Add bundle", From eae325a59c20862e3c96a125ea12e5320725d768 Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Tue, 18 Jun 2024 16:55:53 -0300 Subject: [PATCH 19/22] fix: unfocus input before clicking button --- packages/e2e-tests/src/assert/topNavigationAssert.ts | 2 +- .../e2e-tests/src/elements/newTransaction/coinConfigure.ts | 2 ++ packages/e2e-tests/src/pageobject/nftsPageObject.ts | 4 ---- packages/e2e-tests/src/steps/sendTransactionSimpleSteps.ts | 6 +----- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/e2e-tests/src/assert/topNavigationAssert.ts b/packages/e2e-tests/src/assert/topNavigationAssert.ts index e80d2856b9..e94815ca38 100644 --- a/packages/e2e-tests/src/assert/topNavigationAssert.ts +++ b/packages/e2e-tests/src/assert/topNavigationAssert.ts @@ -71,7 +71,7 @@ class TopNavigationAssert { async assertSyncStatusValid(expectedStatus: string) { expectedStatus = (await t(expectedStatus)) ?? expectedStatus; - await MenuHeader.menuUserDetailsButton.waitForDisplayed({ timeout: 180_000 }); + await MenuHeader.menuUserDetailsButton.waitForDisplayed(); await browser.waitUntil(async () => (await MenuHeader.menuWalletStatus.getText()) === expectedStatus, { timeout: 180_000, interval: 500, diff --git a/packages/e2e-tests/src/elements/newTransaction/coinConfigure.ts b/packages/e2e-tests/src/elements/newTransaction/coinConfigure.ts index 00cb7e2ef3..e0f536d8eb 100644 --- a/packages/e2e-tests/src/elements/newTransaction/coinConfigure.ts +++ b/packages/e2e-tests/src/elements/newTransaction/coinConfigure.ts @@ -66,6 +66,8 @@ export class CoinConfigure { fillTokenValue = async (valueToEnter: number): Promise => { await this.input.waitForClickable(); await setInputFieldValue(await this.input, String(valueToEnter)); + // Clicking on the container to unfocus the input + await $(`${this.CONTAINER}`).click(); }; fillTokenValueUsingKeys = async (valueToEnter: number): Promise => { diff --git a/packages/e2e-tests/src/pageobject/nftsPageObject.ts b/packages/e2e-tests/src/pageobject/nftsPageObject.ts index a2afe0dfeb..53cb0264ab 100644 --- a/packages/e2e-tests/src/pageobject/nftsPageObject.ts +++ b/packages/e2e-tests/src/pageobject/nftsPageObject.ts @@ -48,10 +48,6 @@ class NftsPageObject { await TransactionNewPage.reviewTransactionButton.click(); await TransactionSummaryPage.confirmButton.waitForClickable(); await TransactionSummaryPage.confirmButton.click(); - await browser.pause(500); - if (await TransactionSummaryPage.confirmButton.isDisplayed()) { - await TransactionSummaryPage.confirmButton.click(); // workaround because single click for some reason may not be enough - } } async isNftDisplayed(nftName: string): Promise { diff --git a/packages/e2e-tests/src/steps/sendTransactionSimpleSteps.ts b/packages/e2e-tests/src/steps/sendTransactionSimpleSteps.ts index a0d9867c8b..15e96169b1 100644 --- a/packages/e2e-tests/src/steps/sendTransactionSimpleSteps.ts +++ b/packages/e2e-tests/src/steps/sendTransactionSimpleSteps.ts @@ -601,12 +601,8 @@ When( ); When(/^I click "Confirm" button on "Transaction summary" page$/, async () => { - await TransactionSummaryPage.confirmButton.waitForClickable(); + await TransactionSummaryPage.confirmButton.waitForEnabled(); await TransactionSummaryPage.confirmButton.click(); - await browser.pause(500); - if (await TransactionSummaryPage.confirmButton.isDisplayed()) { - await TransactionSummaryPage.confirmButton.click(); // workaround because single click for some reason may not be enough - } }); When(/^I click "(Save|Cancel)" button on "Add address" drawer in send flow$/, async (button: 'Save' | 'Cancel') => { From 868f2f35800f8e9f2e0a2a634f7c4fe61aa4ccfc Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Wed, 19 Jun 2024 13:38:34 -0300 Subject: [PATCH 20/22] fix: default address if not present yet --- apps/browser-extension-wallet/src/hooks/useMaxAda.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts index f7b5aa1422..cf5ad19468 100644 --- a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts +++ b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts @@ -72,7 +72,7 @@ const createOutputsWithMaxAmount = async ({ if (output.value.assets) { const assets = Wallet.convertAssetsToBigInt(output.value.assets, assetInfo); acc.add({ - address: output.address, + address: output.address || address, value: { coins: BigInt(0), assets @@ -80,7 +80,7 @@ const createOutputsWithMaxAmount = async ({ }); } else { acc.add({ - address: output.address, + address: output.address || address, value: { coins: BigInt(0) } From 7bb3a07a0bc3a112f8df5670a93a82e3f365fab3 Mon Sep 17 00:00:00 2001 From: Lucas Araujo Date: Wed, 19 Jun 2024 13:39:36 -0300 Subject: [PATCH 21/22] feat: increment test --- .../src/hooks/__tests__/useMaxAda.test.ts | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts b/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts index fec822d798..f3eb9e4542 100644 --- a/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts +++ b/apps/browser-extension-wallet/src/hooks/__tests__/useMaxAda.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable sonarjs/no-identical-functions */ /* eslint-disable no-magic-numbers */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { useMaxAda, UTXO_DEPLETED_ADA_BUFFER } from '../useMaxAda'; @@ -142,7 +143,7 @@ describe('Testing useMaxAda hook', () => { }); }); - test.each([[1]])('should return balance minus fee and adaErrorBuffer times %i', async (errorCount) => { + test.each([1, 2, 3, 10])('should return balance minus fee and adaErrorBuffer times %i', async (errorCount) => { inspect.mockResolvedValueOnce({ inputSelection: { fee: BigInt(TX_FEE) @@ -158,12 +159,37 @@ describe('Testing useMaxAda hook', () => { act(() => { inMemoryWallet.balance.utxo.available$.next({ - coins: BigInt('10000000') + coins: BigInt('20000000') }); }); await waitFor(() => { - expect(result.current).toBe(BigInt('10000000') - BigInt(TX_FEE) - BigInt(UTXO_DEPLETED_ADA_BUFFER * errorCount)); + expect(result.current).toBe(BigInt('20000000') - BigInt(TX_FEE) - BigInt(UTXO_DEPLETED_ADA_BUFFER * errorCount)); + }); + }); + + test('should return balance minus fee and adaErrorBuffer times %i', async () => { + inspect.mockResolvedValueOnce({ + inputSelection: { + fee: BigInt(TX_FEE) + } + }); + Array.from({ length: 11 }).forEach(() => { + inspect.mockImplementationOnce(() => { + throw new Error('Error'); + }); + }); + + const { result } = renderHook(() => useMaxAda()); + + act(() => { + inMemoryWallet.balance.utxo.available$.next({ + coins: BigInt('20000000') + }); + }); + + await waitFor(() => { + expect(result.current).toBe(BigInt(0)); }); }); }); From f222985ab3332e13d4c2e7fa62b9f170ba67594e Mon Sep 17 00:00:00 2001 From: Bartlomiej Slabiak Date: Thu, 20 Jun 2024 12:07:23 +0200 Subject: [PATCH 22/22] test(extension): add test automation fixes for sending objects --- .../src/elements/newTransaction/coinConfigure.ts | 10 +++++++--- .../src/elements/newTransaction/tokenSelectionPage.ts | 3 +++ .../SendTransactionSimpleExtended.part3.feature | 2 +- .../features/SendTransactionSimplePopup.part3.feature | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/e2e-tests/src/elements/newTransaction/coinConfigure.ts b/packages/e2e-tests/src/elements/newTransaction/coinConfigure.ts index e0f536d8eb..1e05194ad1 100644 --- a/packages/e2e-tests/src/elements/newTransaction/coinConfigure.ts +++ b/packages/e2e-tests/src/elements/newTransaction/coinConfigure.ts @@ -6,6 +6,7 @@ import { browser } from '@wdio/globals'; export class CoinConfigure { protected CONTAINER_BUNDLE = '//div[@data-testid="asset-bundle-container"]'; protected CONTAINER = '//div[@data-testid="coin-configure"]'; + protected DRAWER_NAVIGATION_HEADER = '//div[@data-testid="drawer-navigation"]'; private TOKEN_NAME = '//div[@data-testid="coin-configure-text"]'; private TOKEN_VALUE = '//p[@data-testid="coin-configure-balance"]'; private TOKEN_INPUT = '//input[@data-testid="coin-configure-input"]'; @@ -58,6 +59,10 @@ export class CoinConfigure { return $(this.TOOLTIP); } + get drawerNavigationHeader(): ChainablePromiseElement { + return $(this.DRAWER_NAVIGATION_HEADER); + } + getAmount = async (): Promise => { const value = await $(`${this.CONTAINER}${this.TOKEN_INPUT}`).getValue(); return Number(value); @@ -66,8 +71,7 @@ export class CoinConfigure { fillTokenValue = async (valueToEnter: number): Promise => { await this.input.waitForClickable(); await setInputFieldValue(await this.input, String(valueToEnter)); - // Clicking on the container to unfocus the input - await $(`${this.CONTAINER}`).click(); + await this.clickToLoseFocus(); }; fillTokenValueUsingKeys = async (valueToEnter: number): Promise => { @@ -108,7 +112,7 @@ export class CoinConfigure { }; clickToLoseFocus = async (): Promise => { - await this.container.click(); + await this.drawerNavigationHeader.click(); }; clickCoinSelectorName = async (): Promise => { diff --git a/packages/e2e-tests/src/elements/newTransaction/tokenSelectionPage.ts b/packages/e2e-tests/src/elements/newTransaction/tokenSelectionPage.ts index 7e52d0471d..19fd312cf2 100644 --- a/packages/e2e-tests/src/elements/newTransaction/tokenSelectionPage.ts +++ b/packages/e2e-tests/src/elements/newTransaction/tokenSelectionPage.ts @@ -116,14 +116,17 @@ class TokenSelectionPage extends CommonDrawerElements { clickNftItemInAssetSelector = async (nftName: string) => { const nftNameElement = await this.getNftName(nftName); + await nftNameElement.waitForClickable(); await nftNameElement.click(); }; clickTokensButton = async () => { + await this.tokensButton.waitForClickable(); await this.tokensButton.click(); }; clickNFTsButton = async () => { + await this.nftsButton.waitForClickable(); await this.nftsButton.click(); }; diff --git a/packages/e2e-tests/src/features/SendTransactionSimpleExtended.part3.feature b/packages/e2e-tests/src/features/SendTransactionSimpleExtended.part3.feature index efd300400d..bd9ca94369 100644 --- a/packages/e2e-tests/src/features/SendTransactionSimpleExtended.part3.feature +++ b/packages/e2e-tests/src/features/SendTransactionSimpleExtended.part3.feature @@ -120,7 +120,7 @@ Feature: LW-484: Send & Receive - Extended Browser View (Simple Tx) When I click on NFT with name: "Ibilecoin" in asset selector Then the "Ibilecoin" asset is displayed in bundle 1 When I enter a value of: 1 to the "Ibilecoin" asset in bundle 1 - Then the NFT displays 1 in the value field + Then the NFT displays 1.00 in the value field And "Review transaction" button is enabled on "Send" page @LW-2374 @Testnet diff --git a/packages/e2e-tests/src/features/SendTransactionSimplePopup.part3.feature b/packages/e2e-tests/src/features/SendTransactionSimplePopup.part3.feature index 3d905a2b2e..686bf8872d 100644 --- a/packages/e2e-tests/src/features/SendTransactionSimplePopup.part3.feature +++ b/packages/e2e-tests/src/features/SendTransactionSimplePopup.part3.feature @@ -98,7 +98,7 @@ Feature: LW-484: Send & Receive - Popup View (Simple Tx) When I click on NFT with name: "Ibilecoin" in asset selector Then the "Ibilecoin" asset is displayed in bundle 1 When I enter a value of: 1 to the "Ibilecoin" asset in bundle 1 - Then the NFT displays 1 in the value field + Then the NFT displays 1.00 in the value field And "Review transaction" button is enabled on "Send" page @LW-2408 @Testnet