From a2e1f98f30a68f08bbea0438b871ed20215b1933 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Tue, 17 Dec 2024 16:37:53 +0100 Subject: [PATCH] fix(transfer): remove 80% onchain cap, misc fixes --- e2e/channels.e2e.js | 2 +- src/hooks/transfer.ts | 72 ++++++++++---- src/screens/Transfer/SpendingAdvanced.tsx | 8 +- src/screens/Transfer/SpendingAmount.tsx | 80 ++++++++++------ src/screens/Wallets/Receive/ReceiveAmount.tsx | 6 +- .../Wallets/Receive/ReceiveConnect.tsx | 7 +- .../Wallets/Receive/ReceiveDetails.tsx | 2 +- src/store/reselect/wallet.ts | 6 +- src/store/utils/blocktank.ts | 96 +++---------------- src/utils/blocktank/index.ts | 12 ++- src/utils/i18n/locales/en/lightning.json | 3 + 11 files changed, 144 insertions(+), 150 deletions(-) diff --git a/e2e/channels.e2e.js b/e2e/channels.e2e.js index eb5769d92..a5808f75a 100644 --- a/e2e/channels.e2e.js +++ b/e2e/channels.e2e.js @@ -160,7 +160,7 @@ d('Transfer', () => { // Receiving Capacity // can continue with min amount await element(by.id('SpendingAdvancedMin')).tap(); - await expect(element(by.text('2 000'))).toBeVisible(); + await expect(element(by.text('2 500'))).toBeVisible(); await element(by.id('SpendingAdvancedContinue')).tap(); await element(by.id('SpendingConfirmDefault')).tap(); await element(by.id('SpendingConfirmAdvanced')).tap(); diff --git a/src/hooks/transfer.ts b/src/hooks/transfer.ts index c52446c79..0c2dc193a 100644 --- a/src/hooks/transfer.ts +++ b/src/hooks/transfer.ts @@ -1,8 +1,10 @@ +import { useEffect, useState } from 'react'; + import { useAppSelector } from './redux'; -import { onChainBalanceSelector } from '../store/reselect/wallet'; +import { estimateOrderFee } from '../utils/blocktank'; +import { fiatToBitcoinUnit } from '../utils/conversion'; import { blocktankInfoSelector } from '../store/reselect/blocktank'; import { blocktankChannelsSizeSelector } from '../store/reselect/lightning'; -import { fiatToBitcoinUnit } from '../utils/conversion'; type TTransferValues = { maxClientBalance: number; @@ -36,25 +38,18 @@ 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); + // LSP balance must be at least 2.5% of the channel size for LDK to accept (reserve balance) + const ldkMinimum = Math.round(clientBalance * 0.025); // 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); +const getMaxClientBalance = (maxChannelSize: number): number => { + // Remote balance must be at least 2.5% of the channel size for LDK to accept (reserve balance) + const minRemoteBalance = Math.round(maxChannelSize * 0.025); + return maxChannelSize - minRemoteBalance; }; /** @@ -64,7 +59,6 @@ const getMaxClientBalance = ( */ export const useTransfer = (clientBalance: number): TTransferValues => { const blocktankInfo = useAppSelector(blocktankInfoSelector); - const onchainBalance = useAppSelector(onChainBalanceSelector); const channelsSize = useAppSelector(blocktankChannelsSizeSelector); const { minChannelSizeSat, maxChannelSizeSat } = blocktankInfo.options; @@ -77,9 +71,9 @@ export const useTransfer = (clientBalance: number): TTransferValues => { const maxChannelSize = Math.min(maxChannelSize1, maxChannelSize2); const minLspBalance = getMinLspBalance(clientBalance, minChannelSizeSat); - const maxLspBalance = maxChannelSize - clientBalance; + const maxLspBalance = Math.max(maxChannelSize - clientBalance, 0); const defaultLspBalance = getDefaultLspBalance(clientBalance, maxLspBalance); - const maxClientBalance = getMaxClientBalance(onchainBalance, maxChannelSize); + const maxClientBalance = getMaxClientBalance(maxChannelSize); return { defaultLspBalance, @@ -88,3 +82,45 @@ export const useTransfer = (clientBalance: number): TTransferValues => { maxClientBalance, }; }; + +/** + * Returns limits and default values for channel orders with the LSP + * @param {number} lspBalance + * @param {number} clientBalance + * @returns {{ fee: number, loading: boolean, error: string | null }} + */ +export const useTransferFee = ( + lspBalance: number, + clientBalance: number, +): { fee: number; loading: boolean; error: string | null } => { + const [{ fee, loading, error }, setState] = useState<{ + fee: number; + loading: boolean; + error: string | null; + }>({ + fee: 0, + loading: true, + error: null, + }); + + useEffect(() => { + const getFeeEstimation = async (): Promise => { + setState((prevState) => ({ ...prevState, loading: true })); + try { + const result = await estimateOrderFee({ lspBalance, clientBalance }); + if (result.isOk()) { + const { feeSat } = result.value; + setState({ fee: feeSat, loading: false, error: null }); + } else { + setState({ fee: 0, loading: false, error: result.error.message }); + } + } catch (err) { + setState({ fee: 0, loading: false, error: err }); + } + }; + + getFeeEstimation(); + }, [lspBalance, clientBalance]); + + return { fee, loading, error }; +}; diff --git a/src/screens/Transfer/SpendingAdvanced.tsx b/src/screens/Transfer/SpendingAdvanced.tsx index 6e441d871..65a0e05e8 100644 --- a/src/screens/Transfer/SpendingAdvanced.tsx +++ b/src/screens/Transfer/SpendingAdvanced.tsx @@ -65,13 +65,7 @@ const SpendingAdvanced = ({ return; } - const result = await estimateOrderFee({ - lspBalance, - options: { - clientBalanceSat: clientBalance, - turboChannel: false, - }, - }); + const result = await estimateOrderFee({ lspBalance, clientBalance }); if (result.isErr()) { return; } diff --git a/src/screens/Transfer/SpendingAmount.tsx b/src/screens/Transfer/SpendingAmount.tsx index 19b7f7ca0..fb21757f2 100644 --- a/src/screens/Transfer/SpendingAmount.tsx +++ b/src/screens/Transfer/SpendingAmount.tsx @@ -22,14 +22,13 @@ 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 { useTransfer, useTransferFee } from '../../hooks/transfer'; import { useAppSelector } from '../../hooks/redux'; import { useBalance, useSwitchUnit } from '../../hooks/wallet'; import { convertToSats } from '../../utils/conversion'; import { showToast } from '../../utils/notifications'; import { getNumberPadText } from '../../utils/numberpad'; import { getDisplayValues } from '../../utils/displayValues'; -import { getMaxSendAmount } from '../../utils/wallet/transactions'; import { transactionSelector } from '../../store/reselect/wallet'; import { resetSendTransaction, @@ -45,6 +44,7 @@ import { conversionUnitSelector, denominationSelector, } from '../../store/reselect/settings'; +import { onChainFeesSelector } from '../../store/reselect/fees'; const SpendingAmount = ({ navigation, @@ -57,37 +57,49 @@ const SpendingAmount = ({ const nextUnit = useAppSelector(nextUnitSelector); const conversionUnit = useAppSelector(conversionUnitSelector); const denomination = useAppSelector(denominationSelector); + const fees = useAppSelector(onChainFeesSelector); const [textFieldValue, setTextFieldValue] = useState(''); const [loading, setLoading] = useState(false); + const clientBalance = useMemo((): number => { + return convertToSats(textFieldValue, conversionUnit); + }, [textFieldValue, conversionUnit]); + + const transferValues = useTransfer(clientBalance); + const { minLspBalance, defaultLspBalance, maxClientBalance } = transferValues; + + // Calculate the maximum amount that can be transferred + const availableAmount = onchainBalance - transaction.fee; + const { defaultLspBalance: maxLspBalance } = useTransfer(availableAmount); + const { fee: maxLspFee } = useTransferFee(maxLspBalance, availableAmount); + const feeMaximum = Math.floor(availableAmount - maxLspFee); + const maximum = Math.min(maxClientBalance, feeMaximum); + useFocusEffect( useCallback(() => { const setupTransfer = async (): Promise => { + // In case of the low fee market, we bump fee by 5 sats + // details: https://github.com/synonymdev/bitkit/issues/2139 + const getSatsPerByte = (fee: number): number => { + const MIN_FEE = 10; + const BUMP_FEE = 5; + return fee <= MIN_FEE ? fee + BUMP_FEE : fee; + }; + + const satsPerByte = getSatsPerByte(fees.fast); + await resetSendTransaction(); - await setupOnChainTransaction({ rbf: false }); + await setupOnChainTransaction({ satsPerByte, rbf: false }); refreshBlocktankInfo().then(); }; setupTransfer(); + + // onMount + // eslint-disable-next-line react-hooks/exhaustive-deps }, []), ); - 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()) { - return maxAmountResponse.value.amount; - } - return 0; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [transaction.outputs, transaction.satsPerByte]); - const onChangeUnit = (): void => { const result = getNumberPadText(clientBalance, denomination, nextUnit); setTextFieldValue(result); @@ -96,37 +108,43 @@ const SpendingAmount = ({ const onQuarter = (): void => { const quarter = Math.round(onchainBalance / 4); - const amount = Math.min(quarter, maxClientBalance); + const amount = Math.min(quarter, maximum); const result = getNumberPadText(amount, denomination, unit); setTextFieldValue(result); }; const onMaxAmount = (): void => { - const result = getNumberPadText(maxClientBalance, denomination, unit); + const result = getNumberPadText(maximum, denomination, unit); setTextFieldValue(result); }; const onNumberPadError = (): void => { - const dv = getDisplayValues({ satoshis: maxClientBalance }); + const dv = getDisplayValues({ satoshis: maximum }); + let description = t('spending_amount.error_max.description', { + amount: dv.bitcoinFormatted, + }); + + if (maximum === 0) { + description = t('spending_amount.error_max.description_zero'); + } + showToast({ type: 'warning', title: t('spending_amount.error_max.title'), - description: t('spending_amount.error_max.description', { - amount: dv.bitcoinFormatted, - }), + description, }); }; const onContinue = async (): Promise => { setLoading(true); - const lspBalance = defaultLspBalance; - const response = await startChannelPurchase({ clientBalance, lspBalance }); + const lspBalance = Math.max(defaultLspBalance, minLspBalance); + const result = await startChannelPurchase({ clientBalance, lspBalance }); setLoading(false); - if (response.isErr()) { - const { message } = response.error; + if (result.isErr()) { + const { message } = result.error; const nodeCapped = message.includes('channel size check'); const title = nodeCapped ? t('spending_amount.error_max.title') @@ -143,7 +161,7 @@ const SpendingAmount = ({ return; } - navigation.navigate('SpendingConfirm', { order: response.value }); + navigation.navigate('SpendingConfirm', { order: result.value }); }; return ( @@ -222,7 +240,7 @@ const SpendingAmount = ({ diff --git a/src/screens/Wallets/Receive/ReceiveAmount.tsx b/src/screens/Wallets/Receive/ReceiveAmount.tsx index b99a1653d..0bb6927de 100644 --- a/src/screens/Wallets/Receive/ReceiveAmount.tsx +++ b/src/screens/Wallets/Receive/ReceiveAmount.tsx @@ -46,7 +46,7 @@ const ReceiveAmount = ({ const switchUnit = useSwitchUnit(); const [minimumAmount, setMinimumAmount] = useState(0); - const { defaultLspBalance: lspBalance } = useTransfer(0); + const { defaultLspBalance: lspBalance, maxClientBalance } = useTransfer(0); useFocusEffect( useCallback(() => { @@ -95,7 +95,9 @@ const ReceiveAmount = ({ }; const continueDisabled = - minimumAmount === 0 || invoice.amount < minimumAmount; + minimumAmount === 0 || + invoice.amount < minimumAmount || + invoice.amount > maxClientBalance; return ( diff --git a/src/screens/Wallets/Receive/ReceiveConnect.tsx b/src/screens/Wallets/Receive/ReceiveConnect.tsx index 48277a041..ad538bc4a 100644 --- a/src/screens/Wallets/Receive/ReceiveConnect.tsx +++ b/src/screens/Wallets/Receive/ReceiveConnect.tsx @@ -148,7 +148,10 @@ const ReceiveConnect = ({ useEffect(() => { const getFeeEstimation = async (): Promise => { setIsLoading(true); - const feeResult = await estimateOrderFee({ lspBalance }); + const feeResult = await estimateOrderFee({ + lspBalance, + clientBalance: amount, + }); if (feeResult.isOk()) { const fees = feeResult.value; setFeeEstimate(fees); @@ -163,7 +166,7 @@ const ReceiveConnect = ({ }; getFeeEstimation(); - }, [t, lspBalance]); + }, [t, lspBalance, amount]); return ( diff --git a/src/screens/Wallets/Receive/ReceiveDetails.tsx b/src/screens/Wallets/Receive/ReceiveDetails.tsx index 722538b39..46c35fce2 100644 --- a/src/screens/Wallets/Receive/ReceiveDetails.tsx +++ b/src/screens/Wallets/Receive/ReceiveDetails.tsx @@ -70,7 +70,7 @@ const ReceiveDetails = ({ }; getFeeEstimation(); - }, [lspBalance, t]); + }, [lspBalance]); useEffect(() => { if (invoice.tags.length > 0) { diff --git a/src/store/reselect/wallet.ts b/src/store/reselect/wallet.ts index 253fc8b3c..528e8c864 100644 --- a/src/store/reselect/wallet.ts +++ b/src/store/reselect/wallet.ts @@ -194,10 +194,8 @@ export const transactionFeeSelector = createSelector( [walletState], (wallet) => { const { selectedWallet, selectedNetwork } = wallet; - return ( - wallet.wallets[selectedWallet]?.transaction[selectedNetwork].fee || - defaultSendTransaction.fee - ); + const { transaction } = wallet.wallets[selectedWallet]; + return transaction[selectedNetwork].fee; }, ); diff --git a/src/store/utils/blocktank.ts b/src/store/utils/blocktank.ts index 38a1dd877..6214a67c5 100644 --- a/src/store/utils/blocktank.ts +++ b/src/store/utils/blocktank.ts @@ -14,19 +14,17 @@ import { __E2E__ } from '../../constants/env'; import { addTransfer, removeTransfer } from '../slices/wallet'; import { updateBeignetSendTransaction } from '../actions/wallet'; import { setLightningSetupStep } from '../slices/user'; -import { getBlocktankStore, dispatch, getFeesStore } from '../helpers'; +import { getBlocktankStore, dispatch } from '../helpers'; import * as blocktank from '../../utils/blocktank'; import { createOrder, getBlocktankInfo, getCJitEntry, - getOrder, isGeoBlocked, openChannel, watchOrder, } from '../../utils/blocktank'; import { - getBalance, getSelectedNetwork, getSelectedWallet, refreshWallet, @@ -35,10 +33,8 @@ import { EAvailableNetwork } from '../../utils/networks'; import { broadcastTransaction, createTransaction, - updateFee, } from '../../utils/wallet/transactions'; import { showToast } from '../../utils/notifications'; -import { getDisplayValues } from '../../utils/displayValues'; import i18n from '../../utils/i18n'; import { refreshLdk } from '../../utils/lightning'; import { DEFAULT_CHANNEL_DURATION } from '../../utils/wallet/constants'; @@ -211,100 +207,38 @@ export const refreshBlocktankInfo = async (): Promise> => { * Attempts to start the purchase of a Blocktank channel. * @param {number} clientBalance * @param {number} lspBalance - * @param {number} [channelExpiry] - * @param {TWalletName} [selectedWallet] - * @param {EAvailableNetwork} [selectedNetwork] * @returns {Promise>} */ export const startChannelPurchase = async ({ clientBalance, lspBalance, - channelExpiryWeeks = DEFAULT_CHANNEL_DURATION, - lspNodeId, - source, - turboChannel = true, - zeroConfPayment = false, - selectedWallet = getSelectedWallet(), - selectedNetwork = getSelectedNetwork(), }: { clientBalance: number; lspBalance: number; - channelExpiryWeeks?: number; - lspNodeId?: string; - source?: string; - turboChannel?: boolean; - zeroConfPayment?: boolean; - selectedWallet?: TWalletName; - selectedNetwork?: EAvailableNetwork; }): Promise> => { - const buyChannelResponse = await createOrder({ + const response = await createOrder({ lspBalance, - channelExpiryWeeks, + channelExpiryWeeks: DEFAULT_CHANNEL_DURATION, options: { clientBalanceSat: clientBalance, - lspNodeId, - source, - turboChannel, - zeroConfPayment, + turboChannel: true, + zeroConfPayment: false, }, }); - if (buyChannelResponse.isErr()) { - return err(buyChannelResponse.error.message); - } - const buyChannelData = buyChannelResponse.value; - - const orderData = await getOrder(buyChannelData.id); - if (orderData.isErr()) { - showToast({ - type: 'warning', - title: i18n.t('other:bt_error_retrieve'), - description: i18n.t('other:bt_error_retrieve_msg', { - raw: orderData.error.message, - }), - }); - return err(orderData.error.message); + if (response.isErr()) { + return err(response.error.message); } + const order = response.value; - const { onchainBalance } = getBalance({ selectedNetwork, selectedWallet }); + const output = { + address: order.payment.onchain.address, + value: order.feeSat, + index: 0, + }; - const amountToSend = Math.ceil(buyChannelData.feeSat); + updateBeignetSendTransaction({ outputs: [output] }); - // Ensure we have enough funds to pay for both the channel and the fee to broadcast the transaction. - if (amountToSend > onchainBalance) { - // TODO: Attempt to re-calculate a lower fee channel-open that's not instant if unable to pay. - const delta = Math.abs(amountToSend - onchainBalance); - const cost = getDisplayValues({ satoshis: delta }); - return err( - i18n.t('other:bt_channel_purchase_cost_error', { - delta: `${cost.fiatSymbol}${cost.fiatFormatted}`, - }), - ); - } - - updateBeignetSendTransaction({ - outputs: [ - { - address: buyChannelData.payment.onchain.address, - value: amountToSend, - index: 0, - }, - ], - }); - - const fees = getFeesStore().onchain; - let fee = fees.fast; - // in case of the low fee market, we bump fee by 5 sats - // details: https://github.com/synonymdev/bitkit/issues/2139 - if (fee <= 10) { - fee += 5; - } - - const feeRes = updateFee({ satsPerByte: fee }); - if (feeRes.isErr()) { - return err(feeRes.error.message); - } - - return ok(buyChannelData); + return ok(order); }; /** diff --git a/src/utils/blocktank/index.ts b/src/utils/blocktank/index.ts index 4168ea708..b289135f6 100644 --- a/src/utils/blocktank/index.ts +++ b/src/utils/blocktank/index.ts @@ -6,6 +6,7 @@ import { IBtInfo, IBtOrder, ICJitEntry, + ICreateOrderOptions, } from '@synonymdev/blocktank-lsp-http-client'; import { err, ok, Result } from '@synonymdev/result'; import { IBtEstimateFeeResponse2 } from '@synonymdev/blocktank-lsp-http-client/dist/shared/IBtEstimateFeeResponse2'; @@ -121,15 +122,20 @@ export const createOrder = async ({ */ export const estimateOrderFee = async ({ lspBalance, - channelExpiryWeeks = DEFAULT_CHANNEL_DURATION, + clientBalance, options, -}: ICreateOrderRequest): Promise> => { +}: { + lspBalance: number; + clientBalance?: number; + options?: Partial; +}): Promise> => { try { const response = await bt.estimateOrderFeeFull( lspBalance, - channelExpiryWeeks, + DEFAULT_CHANNEL_DURATION, { ...options, + clientBalanceSat: clientBalance, source: options?.source ?? 'bitkit', zeroReserve: true, }, diff --git a/src/utils/i18n/locales/en/lightning.json b/src/utils/i18n/locales/en/lightning.json index ee7009d36..80bc9d36f 100644 --- a/src/utils/i18n/locales/en/lightning.json +++ b/src/utils/i18n/locales/en/lightning.json @@ -119,6 +119,9 @@ }, "description": { "string": "The amount you can transfer to your spending balance is currently limited to ₿ {amount}." + }, + "description_zero": { + "string": "You've reached the maximum for your spending balance." } } },