Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(transfer): update liquidity policy #2386

Merged
merged 1 commit into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 1 addition & 84 deletions __tests__/reselect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
});
39 changes: 29 additions & 10 deletions e2e/channels.e2e.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import jestExpect from 'expect';
import createLnRpc from '@radar/lnrpc';
import BitcoinJsonRpc from 'bitcoin-json-rpc';
import jestExpect from 'expect';

import initWaitForElectrumToSync from '../__tests__/utils/wait-for-electrum';
import {
Expand All @@ -10,7 +10,6 @@
completeOnboarding,
electrumHost,
electrumPort,
isButtonEnabled,
launchAndWait,
markComplete,
sleep,
Expand Down Expand Up @@ -85,22 +84,42 @@
.withTimeout(20000);
await element(by.id('NewTxPrompt')).swipe('down'); // close Receive screen

// switch to USD
await element(by.id('Settings')).tap();
await element(by.id('GeneralSettings')).tap();
await element(by.id('CurrenciesSettings')).tap();
await element(by.text('EUR (€)')).tap();
await element(by.id('NavigationClose')).tap();

await element(by.id('Suggestion-lightning')).tap();
await element(by.id('TransferIntro-button')).tap();
await element(by.id('FundTransfer')).tap();
await element(by.id('SpendingIntro-button')).tap();

// default amount is 0
const button = element(by.id('SpendingAmountContinue'));
const buttonEnabled = await isButtonEnabled(button);
jestExpect(buttonEnabled).toBe(false);
// can continue with default client balance (0)
await element(by.id('SpendingAmountContinue')).tap();
await sleep(100);
await element(by.id('SpendingConfirmAdvanced')).tap();
await element(by.id('SpendingAdvancedMin')).tap();
await expect(element(by.text('100 000'))).toBeVisible();
await element(by.id('SpendingAdvancedDefault')).tap();
await element(by.id('SpendingAdvancedNumberField')).tap();
let { label } = await element(
by.id('SpendingAdvancedNumberField'),
).getAttributes();
const lspBalance = Number.parseInt(label);

Check warning on line 110 in e2e/channels.e2e.js

View workflow job for this annotation

GitHub Actions / Run lint check

Missing radix parameter
jestExpect(lspBalance).toBeGreaterThan(440);
jestExpect(lspBalance).toBeLessThan(460);
await element(by.id('SpendingAdvancedNumberField')).tap();
await element(by.id('SpendingAdvancedContinue')).tap();
await element(by.id('NavigationBack')).tap();

// can continue with max amount
// can continue with max client balance
await element(by.id('SpendingAmountMax')).tap();
await element(by.id('SpendingAmountContinue')).tap();
await element(by.id('NavigationBack')).tap();

// can continue with 25% amount
// can continue with 25% client balance
await element(by.id('SpendingAmountQuarter')).tap();
await expect(element(by.text('250 000'))).toBeVisible();
await element(by.id('SpendingAmountContinue')).tap();
Expand All @@ -109,7 +128,7 @@
await element(by.id('NavigationBack')).tap();
await element(by.id('SpendingIntro-button')).tap();

// can change amount
// can change client balance
await element(by.id('N2').withAncestor(by.id('SpendingAmount'))).tap();
await element(by.id('N0').withAncestor(by.id('SpendingAmount'))).multiTap(
5,
Expand Down Expand Up @@ -141,7 +160,7 @@
// Receiving Capacity
// can continue with min amount
await element(by.id('SpendingAdvancedMin')).tap();
await expect(element(by.text('105 000'))).toBeVisible();
await expect(element(by.text('2 000'))).toBeVisible();
await element(by.id('SpendingAdvancedContinue')).tap();
await element(by.id('SpendingConfirmDefault')).tap();
await element(by.id('SpendingConfirmAdvanced')).tap();
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion src/components/ActivityIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion src/components/NumberPadTextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ const NumberPadTextField = ({
}

return (
<Pressable style={style} testID={testID} onPress={onPress}>
<Pressable
style={style}
accessibilityLabel={value}
testID={testID}
onPress={onPress}>
{showConversion && !reverse && (
<Money
style={styles.secondary}
Expand Down
90 changes: 90 additions & 0 deletions src/hooks/transfer.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
15 changes: 6 additions & 9 deletions src/screens/Transfer/SpendingAdvanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -80,9 +76,11 @@ const SpendingAdvanced = ({
return;
}

const fee = result.value.feeSat;

setFeeEstimate((value) => ({
...value,
[`${clientBalance}-${lspBalance}`]: result.value,
[`${clientBalance}-${lspBalance}`]: fee,
}));
};

Expand All @@ -98,7 +96,6 @@ const SpendingAdvanced = ({
};

const onDefault = (): void => {
const defaultLspBalance = Math.round(maxChannelSize / 2);
const result = getNumberPadText(defaultLspBalance, denomination, unit);
setTextFieldValue(result);
};
Expand Down
Loading
Loading