Skip to content

Commit 37dd5ad

Browse files
authored
fix(transfer): update liquidity policy (#2386)
1 parent 32bbd32 commit 37dd5ad

File tree

17 files changed

+355
-382
lines changed

17 files changed

+355
-382
lines changed

__tests__/reselect.ts

Lines changed: 1 addition & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,7 @@ import '../src/utils/i18n';
55
import store, { RootState } from '../src/store';
66
import { dispatch } from '../src/store/helpers';
77
import { updateWallet } from '../src/store/slices/wallet';
8-
import {
9-
TBalance,
10-
balanceSelector,
11-
transferLimitsSelector,
12-
} from '../src/store/reselect/aggregations';
8+
import { TBalance, balanceSelector } from '../src/store/reselect/aggregations';
139
import {
1410
EChannelClosureReason,
1511
EChannelStatus,
@@ -121,83 +117,4 @@ describe('Reselect', () => {
121117
assert.deepEqual(balanceSelector(state), balance);
122118
});
123119
});
124-
125-
describe('transferLimitsSelector', () => {
126-
it('should calculate limits without LN channels', () => {
127-
// max value is limited by maxChannelSize / 2
128-
const s1 = cloneDeep(s);
129-
s1.wallet.wallets.wallet0.balance.bitcoinRegtest = 1000;
130-
s1.blocktank.info.options = {
131-
...s1.blocktank.info.options,
132-
minChannelSizeSat: 10,
133-
maxChannelSizeSat: 200,
134-
maxClientBalanceSat: 100,
135-
};
136-
137-
const received1 = transferLimitsSelector(s1);
138-
const expected1 = {
139-
minChannelSize: 11,
140-
maxChannelSize: 190,
141-
maxClientBalance: 95,
142-
};
143-
144-
expect(received1).toMatchObject(expected1);
145-
146-
// max value is limited by onchain balance
147-
const s2 = cloneDeep(s);
148-
s2.wallet.wallets.wallet0.balance.bitcoinRegtest = 50;
149-
s2.blocktank.info.options = {
150-
...s2.blocktank.info.options,
151-
minChannelSizeSat: 10,
152-
maxChannelSizeSat: 200,
153-
maxClientBalanceSat: 100,
154-
};
155-
156-
const received2 = transferLimitsSelector(s2);
157-
const expected2 = {
158-
minChannelSize: 11,
159-
maxChannelSize: 190,
160-
maxClientBalance: 40,
161-
};
162-
163-
expect(received2).toMatchObject(expected2);
164-
});
165-
166-
it('should calculate limits with existing LN channels', () => {
167-
const btNodeId =
168-
'03b9a456fb45d5ac98c02040d39aec77fa3eeb41fd22cf40b862b393bcfc43473a';
169-
// max value is limited by leftover node capacity
170-
const s1 = cloneDeep(s);
171-
s1.wallet.wallets.wallet0.balance.bitcoinRegtest = 1000;
172-
s1.blocktank.info.nodes = [
173-
{ alias: 'node1', pubkey: btNodeId, connectionStrings: [] },
174-
];
175-
s1.blocktank.info.options = {
176-
...s1.blocktank.info.options,
177-
minChannelSizeSat: 10,
178-
maxChannelSizeSat: 200,
179-
};
180-
181-
const channel1 = {
182-
channel_id: 'channel1',
183-
status: EChannelStatus.open,
184-
is_channel_ready: true,
185-
outbound_capacity_sat: 1,
186-
balance_sat: 2,
187-
channel_value_satoshis: 100,
188-
counterparty_node_id: btNodeId,
189-
} as TChannel;
190-
const lnWallet = s1.lightning.nodes.wallet0;
191-
lnWallet.channels.bitcoinRegtest = { channel1 };
192-
193-
const received1 = transferLimitsSelector(s1);
194-
const expected1 = {
195-
minChannelSize: 11,
196-
maxChannelSize: 90,
197-
maxClientBalance: 45,
198-
};
199-
200-
expect(received1).toMatchObject(expected1);
201-
});
202-
});
203120
});

e2e/channels.e2e.js

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import jestExpect from 'expect';
12
import createLnRpc from '@radar/lnrpc';
23
import BitcoinJsonRpc from 'bitcoin-json-rpc';
3-
import jestExpect from 'expect';
44

55
import initWaitForElectrumToSync from '../__tests__/utils/wait-for-electrum';
66
import {
@@ -10,7 +10,6 @@ import {
1010
completeOnboarding,
1111
electrumHost,
1212
electrumPort,
13-
isButtonEnabled,
1413
launchAndWait,
1514
markComplete,
1615
sleep,
@@ -85,22 +84,42 @@ d('Transfer', () => {
8584
.withTimeout(20000);
8685
await element(by.id('NewTxPrompt')).swipe('down'); // close Receive screen
8786

87+
// switch to USD
88+
await element(by.id('Settings')).tap();
89+
await element(by.id('GeneralSettings')).tap();
90+
await element(by.id('CurrenciesSettings')).tap();
91+
await element(by.text('EUR (€)')).tap();
92+
await element(by.id('NavigationClose')).tap();
93+
8894
await element(by.id('Suggestion-lightning')).tap();
8995
await element(by.id('TransferIntro-button')).tap();
9096
await element(by.id('FundTransfer')).tap();
9197
await element(by.id('SpendingIntro-button')).tap();
9298

93-
// default amount is 0
94-
const button = element(by.id('SpendingAmountContinue'));
95-
const buttonEnabled = await isButtonEnabled(button);
96-
jestExpect(buttonEnabled).toBe(false);
99+
// can continue with default client balance (0)
100+
await element(by.id('SpendingAmountContinue')).tap();
101+
await sleep(100);
102+
await element(by.id('SpendingConfirmAdvanced')).tap();
103+
await element(by.id('SpendingAdvancedMin')).tap();
104+
await expect(element(by.text('100 000'))).toBeVisible();
105+
await element(by.id('SpendingAdvancedDefault')).tap();
106+
await element(by.id('SpendingAdvancedNumberField')).tap();
107+
let { label } = await element(
108+
by.id('SpendingAdvancedNumberField'),
109+
).getAttributes();
110+
const lspBalance = Number.parseInt(label);
111+
jestExpect(lspBalance).toBeGreaterThan(440);
112+
jestExpect(lspBalance).toBeLessThan(460);
113+
await element(by.id('SpendingAdvancedNumberField')).tap();
114+
await element(by.id('SpendingAdvancedContinue')).tap();
115+
await element(by.id('NavigationBack')).tap();
97116

98-
// can continue with max amount
117+
// can continue with max client balance
99118
await element(by.id('SpendingAmountMax')).tap();
100119
await element(by.id('SpendingAmountContinue')).tap();
101120
await element(by.id('NavigationBack')).tap();
102121

103-
// can continue with 25% amount
122+
// can continue with 25% client balance
104123
await element(by.id('SpendingAmountQuarter')).tap();
105124
await expect(element(by.text('250 000'))).toBeVisible();
106125
await element(by.id('SpendingAmountContinue')).tap();
@@ -109,7 +128,7 @@ d('Transfer', () => {
109128
await element(by.id('NavigationBack')).tap();
110129
await element(by.id('SpendingIntro-button')).tap();
111130

112-
// can change amount
131+
// can change client balance
113132
await element(by.id('N2').withAncestor(by.id('SpendingAmount'))).tap();
114133
await element(by.id('N0').withAncestor(by.id('SpendingAmount'))).multiTap(
115134
5,
@@ -141,7 +160,7 @@ d('Transfer', () => {
141160
// Receiving Capacity
142161
// can continue with min amount
143162
await element(by.id('SpendingAdvancedMin')).tap();
144-
await expect(element(by.text('105 000'))).toBeVisible();
163+
await expect(element(by.text('2 000'))).toBeVisible();
145164
await element(by.id('SpendingAdvancedContinue')).tap();
146165
await element(by.id('SpendingConfirmDefault')).tap();
147166
await element(by.id('SpendingConfirmAdvanced')).tap();

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"@react-navigation/native-stack": "6.10.1",
4747
"@reduxjs/toolkit": "2.2.6",
4848
"@shopify/react-native-skia": "1.3.11",
49-
"@synonymdev/blocktank-lsp-http-client": "2.0.0",
49+
"@synonymdev/blocktank-lsp-http-client": "2.2.0",
5050
"@synonymdev/feeds": "3.0.0",
5151
"@synonymdev/react-native-ldk": "0.0.154",
5252
"@synonymdev/react-native-lnurl": "0.0.10",

src/components/ActivityIndicator.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import Animated, {
1616
withTiming,
1717
} from 'react-native-reanimated';
1818

19-
export const ActivityIndicator = ({ size }: { size: number }): ReactElement => {
19+
export const ActivityIndicator = ({
20+
size = 32,
21+
}: {
22+
size?: number;
23+
}): ReactElement => {
2024
const strokeWidth = size / 12;
2125
const radius = (size - strokeWidth) / 2;
2226
const canvasSize = size + 30;

src/components/NumberPadTextField.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,11 @@ const NumberPadTextField = ({
9393
}
9494

9595
return (
96-
<Pressable style={style} testID={testID} onPress={onPress}>
96+
<Pressable
97+
style={style}
98+
accessibilityLabel={value}
99+
testID={testID}
100+
onPress={onPress}>
97101
{showConversion && !reverse && (
98102
<Money
99103
style={styles.secondary}

src/hooks/transfer.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { useAppSelector } from './redux';
2+
import { onChainBalanceSelector } from '../store/reselect/wallet';
3+
import { blocktankInfoSelector } from '../store/reselect/blocktank';
4+
import { blocktankChannelsSizeSelector } from '../store/reselect/lightning';
5+
import { fiatToBitcoinUnit } from '../utils/conversion';
6+
7+
type TTransferValues = {
8+
maxClientBalance: number;
9+
defaultLspBalance: number;
10+
minLspBalance: number;
11+
maxLspBalance: number;
12+
};
13+
14+
const getDefaultLspBalance = (
15+
clientBalance: number,
16+
maxLspBalance: number,
17+
): number => {
18+
const threshold1 = fiatToBitcoinUnit({ amount: 225, currency: 'EUR' });
19+
const threshold2 = fiatToBitcoinUnit({ amount: 495, currency: 'EUR' });
20+
const defaultLspBalance = fiatToBitcoinUnit({ amount: 450, currency: 'EUR' });
21+
22+
let lspBalance = defaultLspBalance - clientBalance;
23+
24+
if (clientBalance > threshold1) {
25+
lspBalance = clientBalance;
26+
}
27+
28+
if (clientBalance > threshold2) {
29+
lspBalance = maxLspBalance;
30+
}
31+
32+
return Math.min(lspBalance, maxLspBalance);
33+
};
34+
35+
const getMinLspBalance = (
36+
clientBalance: number,
37+
minChannelSize: number,
38+
): number => {
39+
// LSP balance must be at least 2% of the channel size for LDK to accept (reserve balance)
40+
const ldkMinimum = Math.round(clientBalance * 0.02);
41+
// Channel size must be at least minChannelSize
42+
const lspMinimum = Math.max(minChannelSize - clientBalance, 0);
43+
44+
return Math.max(ldkMinimum, lspMinimum);
45+
};
46+
47+
const getMaxClientBalance = (
48+
onchainBalance: number,
49+
maxChannelSize: number,
50+
): number => {
51+
// Remote balance must be at least 2% of the channel size for LDK to accept (reserve balance)
52+
const minRemoteBalance = Math.round(maxChannelSize * 0.02);
53+
// Cap client balance to 80% to leave buffer for fees
54+
const feeMaximum = Math.round(onchainBalance * 0.8);
55+
const ldkMaximum = maxChannelSize - minRemoteBalance;
56+
57+
return Math.min(feeMaximum, ldkMaximum);
58+
};
59+
60+
/**
61+
* Returns limits and default values for channel orders with the LSP
62+
* @param {number} clientBalance
63+
* @returns {TTransferValues}
64+
*/
65+
export const useTransfer = (clientBalance: number): TTransferValues => {
66+
const blocktankInfo = useAppSelector(blocktankInfoSelector);
67+
const onchainBalance = useAppSelector(onChainBalanceSelector);
68+
const channelsSize = useAppSelector(blocktankChannelsSizeSelector);
69+
70+
const { minChannelSizeSat, maxChannelSizeSat } = blocktankInfo.options;
71+
72+
// Because LSP limits constantly change depending on network fees
73+
// add a 2% buffer to avoid fluctuations while making the order
74+
const maxChannelSize1 = Math.round(maxChannelSizeSat * 0.98);
75+
// The maximum channel size the user can open including existing channels
76+
const maxChannelSize2 = Math.max(0, maxChannelSize1 - channelsSize);
77+
const maxChannelSize = Math.min(maxChannelSize1, maxChannelSize2);
78+
79+
const minLspBalance = getMinLspBalance(clientBalance, minChannelSizeSat);
80+
const maxLspBalance = maxChannelSize - clientBalance;
81+
const defaultLspBalance = getDefaultLspBalance(clientBalance, maxLspBalance);
82+
const maxClientBalance = getMaxClientBalance(onchainBalance, maxChannelSize);
83+
84+
return {
85+
defaultLspBalance,
86+
minLspBalance,
87+
maxLspBalance,
88+
maxClientBalance,
89+
};
90+
};

src/screens/Transfer/SpendingAdvanced.tsx

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ import Button from '../../components/buttons/Button';
1515
import TransferNumberPad from './TransferNumberPad';
1616
import { useAppSelector } from '../../hooks/redux';
1717
import { useSwitchUnit } from '../../hooks/wallet';
18+
import { useTransfer } from '../../hooks/transfer';
1819
import { convertToSats } from '../../utils/conversion';
1920
import { showToast } from '../../utils/notifications';
2021
import { estimateOrderFee } from '../../utils/blocktank';
2122
import { getNumberPadText } from '../../utils/numberpad';
2223
import type { TransferScreenProps } from '../../navigation/types';
23-
import { transferLimitsSelector } from '../../store/reselect/aggregations';
2424
import { startChannelPurchase } from '../../store/utils/blocktank';
2525
import {
2626
nextUnitSelector,
@@ -40,18 +40,14 @@ const SpendingAdvanced = ({
4040
const nextUnit = useAppSelector(nextUnitSelector);
4141
const conversionUnit = useAppSelector(conversionUnitSelector);
4242
const denomination = useAppSelector(denominationSelector);
43-
const limits = useAppSelector(transferLimitsSelector);
43+
const transferValues = useTransfer(order.clientBalanceSat);
44+
const { defaultLspBalance, minLspBalance, maxLspBalance } = transferValues;
4445

4546
const [textFieldValue, setTextFieldValue] = useState('');
4647
const [loading, setLoading] = useState(false);
4748
const [feeEstimate, setFeeEstimate] = useState<{ [key: string]: number }>({});
4849

4950
const clientBalance = order.clientBalanceSat;
50-
const { minChannelSize, maxChannelSize } = limits;
51-
// LSP balance should be at least half of the channel size
52-
// TODO: get exact requirements from LSP
53-
const minLspBalance = Math.max(minChannelSize, clientBalance);
54-
const maxLspBalance = Math.round(maxChannelSize - clientBalance);
5551

5652
const lspBalance = useMemo((): number => {
5753
return convertToSats(textFieldValue, conversionUnit);
@@ -80,9 +76,11 @@ const SpendingAdvanced = ({
8076
return;
8177
}
8278

79+
const fee = result.value.feeSat;
80+
8381
setFeeEstimate((value) => ({
8482
...value,
85-
[`${clientBalance}-${lspBalance}`]: result.value,
83+
[`${clientBalance}-${lspBalance}`]: fee,
8684
}));
8785
};
8886

@@ -98,7 +96,6 @@ const SpendingAdvanced = ({
9896
};
9997

10098
const onDefault = (): void => {
101-
const defaultLspBalance = Math.round(maxChannelSize / 2);
10299
const result = getNumberPadText(defaultLspBalance, denomination, unit);
103100
setTextFieldValue(result);
104101
};

0 commit comments

Comments
 (0)