diff --git a/__tests__/reselect.ts b/__tests__/reselect.ts index 15481cc56..30858fe4d 100644 --- a/__tests__/reselect.ts +++ b/__tests__/reselect.ts @@ -5,11 +5,7 @@ import '../src/utils/i18n'; import store, { RootState } from '../src/store'; import { dispatch } from '../src/store/helpers'; import { updateWallet } from '../src/store/slices/wallet'; -import { - TBalance, - balanceSelector, - transferLimitsSelector, -} from '../src/store/reselect/aggregations'; +import { TBalance, balanceSelector } from '../src/store/reselect/aggregations'; import { EChannelClosureReason, EChannelStatus, @@ -121,83 +117,4 @@ describe('Reselect', () => { assert.deepEqual(balanceSelector(state), balance); }); }); - - describe('transferLimitsSelector', () => { - it('should calculate limits without LN channels', () => { - // max value is limited by maxChannelSize / 2 - const s1 = cloneDeep(s); - s1.wallet.wallets.wallet0.balance.bitcoinRegtest = 1000; - s1.blocktank.info.options = { - ...s1.blocktank.info.options, - minChannelSizeSat: 10, - maxChannelSizeSat: 200, - maxClientBalanceSat: 100, - }; - - const received1 = transferLimitsSelector(s1); - const expected1 = { - minChannelSize: 11, - maxChannelSize: 190, - maxClientBalance: 95, - }; - - expect(received1).toMatchObject(expected1); - - // max value is limited by onchain balance - const s2 = cloneDeep(s); - s2.wallet.wallets.wallet0.balance.bitcoinRegtest = 50; - s2.blocktank.info.options = { - ...s2.blocktank.info.options, - minChannelSizeSat: 10, - maxChannelSizeSat: 200, - maxClientBalanceSat: 100, - }; - - const received2 = transferLimitsSelector(s2); - const expected2 = { - minChannelSize: 11, - maxChannelSize: 190, - maxClientBalance: 40, - }; - - expect(received2).toMatchObject(expected2); - }); - - it('should calculate limits with existing LN channels', () => { - const btNodeId = - '03b9a456fb45d5ac98c02040d39aec77fa3eeb41fd22cf40b862b393bcfc43473a'; - // max value is limited by leftover node capacity - const s1 = cloneDeep(s); - s1.wallet.wallets.wallet0.balance.bitcoinRegtest = 1000; - s1.blocktank.info.nodes = [ - { alias: 'node1', pubkey: btNodeId, connectionStrings: [] }, - ]; - s1.blocktank.info.options = { - ...s1.blocktank.info.options, - minChannelSizeSat: 10, - maxChannelSizeSat: 200, - }; - - const channel1 = { - channel_id: 'channel1', - status: EChannelStatus.open, - is_channel_ready: true, - outbound_capacity_sat: 1, - balance_sat: 2, - channel_value_satoshis: 100, - counterparty_node_id: btNodeId, - } as TChannel; - const lnWallet = s1.lightning.nodes.wallet0; - lnWallet.channels.bitcoinRegtest = { channel1 }; - - const received1 = transferLimitsSelector(s1); - const expected1 = { - minChannelSize: 11, - maxChannelSize: 90, - maxClientBalance: 45, - }; - - expect(received1).toMatchObject(expected1); - }); - }); }); diff --git a/package.json b/package.json index b01473189..38f9f90fb 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@react-navigation/native-stack": "6.10.1", "@reduxjs/toolkit": "2.2.6", "@shopify/react-native-skia": "1.3.11", - "@synonymdev/blocktank-lsp-http-client": "2.0.0", + "@synonymdev/blocktank-lsp-http-client": "2.2.0", "@synonymdev/feeds": "3.0.0", "@synonymdev/react-native-ldk": "0.0.154", "@synonymdev/react-native-lnurl": "0.0.10", diff --git a/src/components/ActivityIndicator.tsx b/src/components/ActivityIndicator.tsx index 02f734103..d067b504b 100644 --- a/src/components/ActivityIndicator.tsx +++ b/src/components/ActivityIndicator.tsx @@ -16,7 +16,11 @@ import Animated, { withTiming, } from 'react-native-reanimated'; -export const ActivityIndicator = ({ size }: { size: number }): ReactElement => { +export const ActivityIndicator = ({ + size = 32, +}: { + size?: number; +}): ReactElement => { const strokeWidth = size / 12; const radius = (size - strokeWidth) / 2; const canvasSize = size + 30; diff --git a/src/hooks/transfer.ts b/src/hooks/transfer.ts new file mode 100644 index 000000000..c52446c79 --- /dev/null +++ b/src/hooks/transfer.ts @@ -0,0 +1,90 @@ +import { useAppSelector } from './redux'; +import { onChainBalanceSelector } from '../store/reselect/wallet'; +import { blocktankInfoSelector } from '../store/reselect/blocktank'; +import { blocktankChannelsSizeSelector } from '../store/reselect/lightning'; +import { fiatToBitcoinUnit } from '../utils/conversion'; + +type TTransferValues = { + maxClientBalance: number; + defaultLspBalance: number; + minLspBalance: number; + maxLspBalance: number; +}; + +const getDefaultLspBalance = ( + clientBalance: number, + maxLspBalance: number, +): number => { + const threshold1 = fiatToBitcoinUnit({ amount: 225, currency: 'EUR' }); + const threshold2 = fiatToBitcoinUnit({ amount: 495, currency: 'EUR' }); + const defaultLspBalance = fiatToBitcoinUnit({ amount: 450, currency: 'EUR' }); + + let lspBalance = defaultLspBalance - clientBalance; + + if (clientBalance > threshold1) { + lspBalance = clientBalance; + } + + if (clientBalance > threshold2) { + lspBalance = maxLspBalance; + } + + return Math.min(lspBalance, maxLspBalance); +}; + +const getMinLspBalance = ( + clientBalance: number, + minChannelSize: number, +): number => { + // LSP balance must be at least 2% of the channel size for LDK to accept (reserve balance) + const ldkMinimum = Math.round(clientBalance * 0.02); + // Channel size must be at least minChannelSize + const lspMinimum = Math.max(minChannelSize - clientBalance, 0); + + return Math.max(ldkMinimum, lspMinimum); +}; + +const getMaxClientBalance = ( + onchainBalance: number, + maxChannelSize: number, +): number => { + // Remote balance must be at least 2% of the channel size for LDK to accept (reserve balance) + const minRemoteBalance = Math.round(maxChannelSize * 0.02); + // Cap client balance to 80% to leave buffer for fees + const feeMaximum = Math.round(onchainBalance * 0.8); + const ldkMaximum = maxChannelSize - minRemoteBalance; + + return Math.min(feeMaximum, ldkMaximum); +}; + +/** + * Returns limits and default values for channel orders with the LSP + * @param {number} clientBalance + * @returns {TTransferValues} + */ +export const useTransfer = (clientBalance: number): TTransferValues => { + const blocktankInfo = useAppSelector(blocktankInfoSelector); + const onchainBalance = useAppSelector(onChainBalanceSelector); + const channelsSize = useAppSelector(blocktankChannelsSizeSelector); + + const { minChannelSizeSat, maxChannelSizeSat } = blocktankInfo.options; + + // Because LSP limits constantly change depending on network fees + // add a 2% buffer to avoid fluctuations while making the order + const maxChannelSize1 = Math.round(maxChannelSizeSat * 0.98); + // The maximum channel size the user can open including existing channels + const maxChannelSize2 = Math.max(0, maxChannelSize1 - channelsSize); + const maxChannelSize = Math.min(maxChannelSize1, maxChannelSize2); + + const minLspBalance = getMinLspBalance(clientBalance, minChannelSizeSat); + const maxLspBalance = maxChannelSize - clientBalance; + const defaultLspBalance = getDefaultLspBalance(clientBalance, maxLspBalance); + const maxClientBalance = getMaxClientBalance(onchainBalance, maxChannelSize); + + return { + defaultLspBalance, + minLspBalance, + maxLspBalance, + maxClientBalance, + }; +}; diff --git a/src/screens/Transfer/SpendingAdvanced.tsx b/src/screens/Transfer/SpendingAdvanced.tsx index 97edaf867..6e441d871 100644 --- a/src/screens/Transfer/SpendingAdvanced.tsx +++ b/src/screens/Transfer/SpendingAdvanced.tsx @@ -15,12 +15,12 @@ import Button from '../../components/buttons/Button'; import TransferNumberPad from './TransferNumberPad'; import { useAppSelector } from '../../hooks/redux'; import { useSwitchUnit } from '../../hooks/wallet'; +import { useTransfer } from '../../hooks/transfer'; import { convertToSats } from '../../utils/conversion'; import { showToast } from '../../utils/notifications'; import { estimateOrderFee } from '../../utils/blocktank'; import { getNumberPadText } from '../../utils/numberpad'; import type { TransferScreenProps } from '../../navigation/types'; -import { transferLimitsSelector } from '../../store/reselect/aggregations'; import { startChannelPurchase } from '../../store/utils/blocktank'; import { nextUnitSelector, @@ -40,18 +40,14 @@ const SpendingAdvanced = ({ const nextUnit = useAppSelector(nextUnitSelector); const conversionUnit = useAppSelector(conversionUnitSelector); const denomination = useAppSelector(denominationSelector); - const limits = useAppSelector(transferLimitsSelector); + const transferValues = useTransfer(order.clientBalanceSat); + const { defaultLspBalance, minLspBalance, maxLspBalance } = transferValues; const [textFieldValue, setTextFieldValue] = useState(''); const [loading, setLoading] = useState(false); const [feeEstimate, setFeeEstimate] = useState<{ [key: string]: number }>({}); const clientBalance = order.clientBalanceSat; - const { minChannelSize, maxChannelSize } = limits; - // LSP balance should be at least half of the channel size - // TODO: get exact requirements from LSP - const minLspBalance = Math.max(minChannelSize, clientBalance); - const maxLspBalance = Math.round(maxChannelSize - clientBalance); const lspBalance = useMemo((): number => { return convertToSats(textFieldValue, conversionUnit); @@ -80,9 +76,11 @@ const SpendingAdvanced = ({ return; } + const fee = result.value.feeSat; + setFeeEstimate((value) => ({ ...value, - [`${clientBalance}-${lspBalance}`]: result.value, + [`${clientBalance}-${lspBalance}`]: fee, })); }; @@ -98,7 +96,6 @@ const SpendingAdvanced = ({ }; const onDefault = (): void => { - const defaultLspBalance = Math.round(maxChannelSize / 2); const result = getNumberPadText(defaultLspBalance, denomination, unit); setTextFieldValue(result); }; diff --git a/src/screens/Transfer/SpendingAmount.tsx b/src/screens/Transfer/SpendingAmount.tsx index efc29ae23..45554c577 100644 --- a/src/screens/Transfer/SpendingAmount.tsx +++ b/src/screens/Transfer/SpendingAmount.tsx @@ -22,6 +22,7 @@ import Button from '../../components/buttons/Button'; import UnitButton from '../Wallets/UnitButton'; import TransferNumberPad from './TransferNumberPad'; import type { TransferScreenProps } from '../../navigation/types'; +import { useTransfer } from '../../hooks/transfer'; import { useAppSelector } from '../../hooks/redux'; import { useBalance, useSwitchUnit } from '../../hooks/wallet'; import { convertToSats } from '../../utils/conversion'; @@ -30,7 +31,6 @@ import { getNumberPadText } from '../../utils/numberpad'; import { getDisplayValues } from '../../utils/displayValues'; import { getMaxSendAmount } from '../../utils/wallet/transactions'; import { transactionSelector } from '../../store/reselect/wallet'; -import { transferLimitsSelector } from '../../store/reselect/aggregations'; import { resetSendTransaction, setupOnChainTransaction, @@ -57,7 +57,6 @@ const SpendingAmount = ({ const nextUnit = useAppSelector(nextUnitSelector); const conversionUnit = useAppSelector(conversionUnitSelector); const denomination = useAppSelector(denominationSelector); - const limits = useAppSelector(transferLimitsSelector); const [textFieldValue, setTextFieldValue] = useState(''); const [loading, setLoading] = useState(false); @@ -73,12 +72,13 @@ const SpendingAmount = ({ }, []), ); - const { maxChannelSize, maxClientBalance } = limits; - const clientBalance = useMemo((): number => { return convertToSats(textFieldValue, conversionUnit); }, [textFieldValue, conversionUnit]); + const transferValues = useTransfer(clientBalance); + const { defaultLspBalance, maxClientBalance } = transferValues; + const availableAmount = useMemo(() => { const maxAmountResponse = getMaxSendAmount(); if (maxAmountResponse.isOk()) { @@ -120,7 +120,7 @@ const SpendingAmount = ({ const onContinue = async (): Promise => { setLoading(true); - const lspBalance = Math.round(maxChannelSize / 2); + const lspBalance = defaultLspBalance; const response = await startChannelPurchase({ clientBalance, lspBalance }); setLoading(false); diff --git a/src/screens/Transfer/SpendingConfirm.tsx b/src/screens/Transfer/SpendingConfirm.tsx index 1cbdf07d9..c9a232819 100644 --- a/src/screens/Transfer/SpendingConfirm.tsx +++ b/src/screens/Transfer/SpendingConfirm.tsx @@ -13,10 +13,10 @@ import Money from '../../components/Money'; import LightningChannel from '../../components/LightningChannel'; import { sleep } from '../../utils/helpers'; import { showToast } from '../../utils/notifications'; +import { useTransfer } from '../../hooks/transfer'; import { useAppSelector } from '../../hooks/redux'; import { TransferScreenProps } from '../../navigation/types'; import { transactionFeeSelector } from '../../store/reselect/wallet'; -import { transferLimitsSelector } from '../../store/reselect/aggregations'; import { confirmChannelPurchase, startChannelPurchase, @@ -32,7 +32,7 @@ const SpendingConfirm = ({ const { t } = useTranslation('lightning'); const [loading, setLoading] = useState(false); const transactionFee = useAppSelector(transactionFeeSelector); - const limits = useAppSelector(transferLimitsSelector); + const { defaultLspBalance } = useTransfer(order.clientBalanceSat); const clientBalance = order.clientBalanceSat; const lspBalance = order.lspBalanceSat; @@ -51,9 +51,6 @@ const SpendingConfirm = ({ }; const onDefault = async (): Promise => { - const { maxChannelSize } = limits; - const defaultLspBalance = Math.round(maxChannelSize / 2); - const response = await startChannelPurchase({ clientBalance, lspBalance: defaultLspBalance, diff --git a/src/screens/Wallets/Receive/ReceiveAmount.tsx b/src/screens/Wallets/Receive/ReceiveAmount.tsx index 6f0270dc2..b99a1653d 100644 --- a/src/screens/Wallets/Receive/ReceiveAmount.tsx +++ b/src/screens/Wallets/Receive/ReceiveAmount.tsx @@ -18,13 +18,14 @@ import Button from '../../../components/buttons/Button'; import GradientView from '../../../components/GradientView'; import ReceiveNumberPad from './ReceiveNumberPad'; import UnitButton from '../UnitButton'; +import { useSwitchUnit } from '../../../hooks/wallet'; +import { useTransfer } from '../../../hooks/transfer'; import { useAppDispatch, useAppSelector } from '../../../hooks/redux'; import { updateInvoice } from '../../../store/slices/receive'; import { receiveSelector } from '../../../store/reselect/receive'; import { estimateOrderFee } from '../../../utils/blocktank'; import { getNumberPadText } from '../../../utils/numberpad'; import { showToast } from '../../../utils/notifications'; -import { blocktankInfoSelector } from '../../../store/reselect/blocktank'; import { refreshBlocktankInfo } from '../../../store/utils/blocktank'; import { nextUnitSelector, @@ -32,7 +33,6 @@ import { unitSelector, } from '../../../store/reselect/settings'; import type { ReceiveScreenProps } from '../../../navigation/types'; -import { useSwitchUnit } from '../../../hooks/wallet'; const ReceiveAmount = ({ navigation, @@ -43,12 +43,10 @@ const ReceiveAmount = ({ const nextUnit = useAppSelector(nextUnitSelector); const denomination = useAppSelector(denominationSelector); const invoice = useAppSelector(receiveSelector); - const blocktank = useAppSelector(blocktankInfoSelector); const switchUnit = useSwitchUnit(); const [minimumAmount, setMinimumAmount] = useState(0); - const { maxChannelSizeSat } = blocktank.options; - const channelSize = Math.round(maxChannelSizeSat / 2); + const { defaultLspBalance: lspBalance } = useTransfer(0); useFocusEffect( useCallback(() => { @@ -59,10 +57,11 @@ const ReceiveAmount = ({ useEffect(() => { // The minimum amount is the fee for the channel plus a buffer const getFeeEstimation = async (): Promise => { - const feeResult = await estimateOrderFee({ lspBalance: channelSize }); + const feeResult = await estimateOrderFee({ lspBalance }); if (feeResult.isOk()) { + const fees = feeResult.value; // add 10% buffer and round up to the nearest 1000 to avoid fee fluctuations - const minimum = Math.ceil((feeResult.value * 1.1) / 1000) * 1000; + const minimum = Math.ceil((fees.feeSat * 1.1) / 1000) * 1000; setMinimumAmount(minimum); } else { showToast({ @@ -74,7 +73,7 @@ const ReceiveAmount = ({ }; getFeeEstimation(); - }, [t, channelSize]); + }, [lspBalance, t]); const onMinimum = (): void => { const result = getNumberPadText(minimumAmount, denomination, unit); @@ -96,9 +95,7 @@ const ReceiveAmount = ({ }; const continueDisabled = - invoice.amount < minimumAmount || - invoice.amount > channelSize || - minimumAmount === 0; + minimumAmount === 0 || invoice.amount < minimumAmount; return ( @@ -142,9 +139,9 @@ const ReceiveAmount = ({