From 8dbadcab975021eda8dcad054eb97164ddbfa78d Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Tue, 26 Mar 2024 18:55:47 +0100 Subject: [PATCH 1/7] chore: Make initial value optional In case a controller is provided this argument is anyways not used. It's odd to be required to add it still. --- mobile/lib/common/amount_text_input_form_field.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/lib/common/amount_text_input_form_field.dart b/mobile/lib/common/amount_text_input_form_field.dart index 3f4d5b136..c9add69d3 100644 --- a/mobile/lib/common/amount_text_input_form_field.dart +++ b/mobile/lib/common/amount_text_input_form_field.dart @@ -12,7 +12,7 @@ class AmountInputField extends StatelessWidget { this.label = '', this.hint = '', this.onChanged, - required this.initialValue, + this.initialValue, this.isLoading = false, this.suffixIcon, this.controller, @@ -23,7 +23,7 @@ class AmountInputField extends StatelessWidget { final TextEditingController? controller; final TextStyle? style; - final Amount initialValue; + final Amount? initialValue; final bool enabled; final String label; final String hint; @@ -41,7 +41,7 @@ class AmountInputField extends StatelessWidget { style: style ?? const TextStyle(color: Colors.black87, fontSize: 16), enabled: enabled, controller: controller, - initialValue: controller != null ? null : initialValue.formatted(), + initialValue: controller != null ? null : initialValue?.formatted(), keyboardType: TextInputType.number, decoration: decoration ?? InputDecoration( From 9948dbba238ee67f0c412842a5dc8dedc517069c Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Tue, 26 Mar 2024 19:28:28 +0100 Subject: [PATCH 2/7] chore: Make trade values service const --- mobile/lib/common/init_service.dart | 4 ++-- .../lib/features/trade/application/trade_values_service.dart | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/mobile/lib/common/init_service.dart b/mobile/lib/common/init_service.dart index a3ba277a2..ec9a633bb 100644 --- a/mobile/lib/common/init_service.dart +++ b/mobile/lib/common/init_service.dart @@ -42,8 +42,8 @@ import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; List createProviders() { bridge.Config config = Environment.parse(); - const ChannelInfoService channelInfoService = ChannelInfoService(); - var tradeValuesService = TradeValuesService(); + const tradeValuesService = TradeValuesService(); + const channelInfoService = ChannelInfoService(); const pollService = PollService(); const githubService = GitHubService(); diff --git a/mobile/lib/features/trade/application/trade_values_service.dart b/mobile/lib/features/trade/application/trade_values_service.dart index 5e98e7f24..3fad13f86 100644 --- a/mobile/lib/features/trade/application/trade_values_service.dart +++ b/mobile/lib/features/trade/application/trade_values_service.dart @@ -4,6 +4,8 @@ import 'package:get_10101/features/trade/domain/leverage.dart'; import 'package:get_10101/ffi.dart' as rust; class TradeValuesService { + const TradeValuesService(); + Amount? calculateMargin( {required double? price, required Usd? quantity, required Leverage leverage, dynamic hint}) { if (price == null || quantity == null) { From 6b5a5dcc1b18438e0737cb228ba4bc999691d081 Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Tue, 26 Mar 2024 19:30:33 +0100 Subject: [PATCH 3/7] chore: Floor instead of ceil quantity when calculating from margin It seems risky to ceil the quantity as the margin will be a bit more than provided on the ceiled quantity. I think flooring is safer here. Also flooring here ensures that the max quantity is always within validation. --- mobile/lib/features/trade/application/trade_values_service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/features/trade/application/trade_values_service.dart b/mobile/lib/features/trade/application/trade_values_service.dart index 3fad13f86..64c9bba38 100644 --- a/mobile/lib/features/trade/application/trade_values_service.dart +++ b/mobile/lib/features/trade/application/trade_values_service.dart @@ -23,7 +23,7 @@ class TradeValuesService { } else { final quantity = rust.api .calculateQuantity(price: price, margin: margin.sats, leverage: leverage.leverage); - return Usd(quantity.ceil()); + return Usd(quantity.floor()); } } From 271260c6e335ed9ae78c389cf279c3593ad9b83b Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Tue, 26 Mar 2024 19:33:58 +0100 Subject: [PATCH 4/7] feat: Add max quantity button Clicking the max button next to the quantity input field will lock the quantity to stay on the max value. --- mobile/lib/common/init_service.dart | 4 +- .../application/trade_values_service.dart | 11 + .../features/trade/domain/trade_values.dart | 64 ++-- .../trade/submit_order_change_notifier.dart | 2 +- .../trade/trade_bottom_sheet_tab.dart | 33 +- .../trade/trade_value_change_notifier.dart | 16 +- mobile/native/src/api.rs | 13 + mobile/native/src/lib.rs | 1 + mobile/native/src/max_quantity.rs | 348 ++++++++++++++++++ mobile/test/trade_test.dart | 6 + 10 files changed, 463 insertions(+), 35 deletions(-) create mode 100644 mobile/native/src/max_quantity.rs diff --git a/mobile/lib/common/init_service.dart b/mobile/lib/common/init_service.dart index ec9a633bb..bb8e453cc 100644 --- a/mobile/lib/common/init_service.dart +++ b/mobile/lib/common/init_service.dart @@ -44,6 +44,7 @@ List createProviders() { const tradeValuesService = TradeValuesService(); const channelInfoService = ChannelInfoService(); + const dlcChannelService = DlcChannelService(); const pollService = PollService(); const githubService = GitHubService(); @@ -59,8 +60,7 @@ List createProviders() { ChangeNotifierProvider( create: (context) => CandlestickChangeNotifier(const CandlestickService()).initialize()), ChangeNotifierProvider(create: (context) => ServiceStatusNotifier()), - ChangeNotifierProvider( - create: (context) => DlcChannelChangeNotifier(const DlcChannelService())), + ChangeNotifierProvider(create: (context) => DlcChannelChangeNotifier(dlcChannelService)), ChangeNotifierProvider(create: (context) => AsyncOrderChangeNotifier(OrderService())), ChangeNotifierProvider(create: (context) => RolloverChangeNotifier()), ChangeNotifierProvider(create: (context) => RecoverDlcChangeNotifier()), diff --git a/mobile/lib/features/trade/application/trade_values_service.dart b/mobile/lib/features/trade/application/trade_values_service.dart index 64c9bba38..b50bd056d 100644 --- a/mobile/lib/features/trade/application/trade_values_service.dart +++ b/mobile/lib/features/trade/application/trade_values_service.dart @@ -27,6 +27,17 @@ class TradeValuesService { } } + Usd? calculateMaxQuantity({required double? price, required Leverage leverage}) { + if (price == null) { + return null; + } else { + final quantity = + rust.api.calculateMaxQuantity(price: price, traderLeverage: leverage.leverage); + + return Usd(quantity); + } + } + double? calculateLiquidationPrice( {required double? price, required Leverage leverage, diff --git a/mobile/lib/features/trade/domain/trade_values.dart b/mobile/lib/features/trade/domain/trade_values.dart index d7c0d4da8..28572e1f5 100644 --- a/mobile/lib/features/trade/domain/trade_values.dart +++ b/mobile/lib/features/trade/domain/trade_values.dart @@ -13,6 +13,7 @@ class TradeValues { double? price; double? liquidationPrice; Amount? fee; // This fee is an estimate of the order-matching fee. + Usd? maxQuantity; double fundingRate; DateTime expiry; @@ -20,25 +21,27 @@ class TradeValues { // no final so it can be mocked in tests TradeValuesService tradeValuesService; - TradeValues( - {required this.direction, - required this.margin, - required this.quantity, - required this.leverage, - required this.price, - required this.liquidationPrice, - required this.fee, - required this.fundingRate, - required this.expiry, - required this.tradeValuesService}); - - factory TradeValues.fromQuantity( - {required Usd quantity, - required Leverage leverage, - required double? price, - required double fundingRate, - required Direction direction, - required TradeValuesService tradeValuesService}) { + TradeValues({ + required this.direction, + required this.margin, + required this.quantity, + required this.leverage, + required this.price, + required this.liquidationPrice, + required this.fee, + required this.fundingRate, + required this.expiry, + required this.tradeValuesService, + }); + + factory TradeValues.fromQuantity({ + required Usd quantity, + required Leverage leverage, + required double? price, + required double fundingRate, + required Direction direction, + required TradeValuesService tradeValuesService, + }) { Amount? margin = tradeValuesService.calculateMargin(price: price, quantity: quantity, leverage: leverage); double? liquidationPrice = price != null @@ -63,13 +66,14 @@ class TradeValues { tradeValuesService: tradeValuesService); } - factory TradeValues.fromMargin( - {required Amount? margin, - required Leverage leverage, - required double? price, - required double fundingRate, - required Direction direction, - required TradeValuesService tradeValuesService}) { + factory TradeValues.fromMargin({ + required Amount? margin, + required Leverage leverage, + required double? price, + required double fundingRate, + required Direction direction, + required TradeValuesService tradeValuesService, + }) { Usd? quantity = tradeValuesService.calculateQuantity(price: price, margin: margin, leverage: leverage); double? liquidationPrice = price != null @@ -104,6 +108,7 @@ class TradeValues { this.margin = margin; _recalculateQuantity(); _recalculateFee(); + _recalculateMaxQuantity(); } updatePriceAndQuantity(double? price) { @@ -111,6 +116,7 @@ class TradeValues { _recalculateQuantity(); _recalculateLiquidationPrice(); _recalculateFee(); + _recalculateMaxQuantity(); } updatePriceAndMargin(double? price) { @@ -118,12 +124,14 @@ class TradeValues { _recalculateMargin(); _recalculateLiquidationPrice(); _recalculateFee(); + _recalculateMaxQuantity(); } updateLeverage(Leverage leverage) { this.leverage = leverage; _recalculateMargin(); _recalculateLiquidationPrice(); + _recalculateMaxQuantity(); } // Can be used to calculate the counterparty's margin, based on their @@ -153,4 +161,8 @@ class TradeValues { _recalculateFee() { fee = tradeValuesService.orderMatchingFee(quantity: quantity, price: price); } + + _recalculateMaxQuantity() { + maxQuantity = tradeValuesService.calculateMaxQuantity(price: price, leverage: leverage); + } } diff --git a/mobile/lib/features/trade/submit_order_change_notifier.dart b/mobile/lib/features/trade/submit_order_change_notifier.dart index 17a6984f3..c66bb542f 100644 --- a/mobile/lib/features/trade/submit_order_change_notifier.dart +++ b/mobile/lib/features/trade/submit_order_change_notifier.dart @@ -132,7 +132,7 @@ class SubmitOrderChangeNotifier extends ChangeNotifier implements Subscriber { fee: fee, fundingRate: 0, expiry: position.expiry, - tradeValuesService: TradeValuesService()), + tradeValuesService: const TradeValuesService()), PositionAction.close, pnl: position.unrealizedPnl, stable: stable); diff --git a/mobile/lib/features/trade/trade_bottom_sheet_tab.dart b/mobile/lib/features/trade/trade_bottom_sheet_tab.dart index f3e251583..23b00eb4e 100644 --- a/mobile/lib/features/trade/trade_bottom_sheet_tab.dart +++ b/mobile/lib/features/trade/trade_bottom_sheet_tab.dart @@ -4,6 +4,7 @@ import 'package:get_10101/common/amount_text_field.dart'; import 'package:get_10101/common/amount_text_input_form_field.dart'; import 'package:get_10101/common/application/channel_info_service.dart'; import 'package:get_10101/common/application/lsp_change_notifier.dart'; +import 'package:get_10101/common/color.dart'; import 'package:get_10101/common/dlc_channel_change_notifier.dart'; import 'package:get_10101/common/domain/model.dart'; import 'package:get_10101/features/trade/channel_configuration.dart'; @@ -183,7 +184,7 @@ class _TradeBottomSheetTabState extends State { Wrap buildChildren(Direction direction, rust.TradeConstraints channelTradeConstraints, BuildContext context, ChannelInfoService channelInfoService, GlobalKey formKey) { - final tradeValues = context.read().fromDirection(direction); + final tradeValues = context.watch().fromDirection(direction); bool hasPosition = positionChangeNotifier.positions.containsKey(contractSymbol); @@ -201,6 +202,8 @@ class _TradeBottomSheetTabState extends State { price * channelTradeConstraints.coordinatorLeverage; + quantityController.text = Amount(tradeValues.quantity?.toInt ?? 0).formatted(); + return Wrap( runSpacing: 12, children: [ @@ -227,7 +230,29 @@ class _TradeBottomSheetTabState extends State { children: [ Flexible( child: AmountInputField( - initialValue: Amount(tradeValues.quantity?.toInt ?? 0), + controller: quantityController, + suffixIcon: TextButton( + onPressed: () { + final quantity = tradeValues.maxQuantity ?? Usd.zero(); + quantityController.text = quantity.formatted(); + setState(() { + provider.maxQuantityLock = !provider.maxQuantityLock; + context.read().updateQuantity(direction, quantity); + }); + _formKey.currentState?.validate(); + }, + child: Container( + padding: const EdgeInsets.all(5.0), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(10)), + color: + provider.maxQuantityLock ? tenTenOnePurple.shade50 : Colors.transparent), + child: const Text( + "Max", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), hint: "e.g. 100 USD", label: "Quantity (USD)", onChanged: (value) { @@ -241,6 +266,7 @@ class _TradeBottomSheetTabState extends State { } on Exception { context.read().updateQuantity(direction, Usd.zero()); } + provider.maxQuantityLock = false; _formKey.currentState?.validate(); }, validator: (value) { @@ -315,8 +341,7 @@ class _TradeBottomSheetTabState extends State { isActive: !hasPosition, onLeverageChanged: (value) { context.read().updateLeverage(direction, Leverage(value)); - // When the slider changes, we validate the whole form. - formKey.currentState!.validate(); + formKey.currentState?.validate(); }), Row( children: [ diff --git a/mobile/lib/features/trade/trade_value_change_notifier.dart b/mobile/lib/features/trade/trade_value_change_notifier.dart index b7afc9444..1cc1a0cd1 100644 --- a/mobile/lib/features/trade/trade_value_change_notifier.dart +++ b/mobile/lib/features/trade/trade_value_change_notifier.dart @@ -18,6 +18,8 @@ class TradeValuesChangeNotifier extends ChangeNotifier implements Subscriber { Price? _price; + bool maxQuantityLock = false; + TradeValuesChangeNotifier(this.tradeValuesService) { _buyTradeValues = _initOrder(Direction.long); _sellTradeValues = _initOrder(Direction.short); @@ -78,6 +80,7 @@ class TradeValuesChangeNotifier extends ChangeNotifier implements Subscriber { void updateLeverage(Direction direction, Leverage leverage) { fromDirection(direction).updateLeverage(leverage); + maxQuantityLock = false; notifyListeners(); } @@ -91,11 +94,20 @@ class TradeValuesChangeNotifier extends ChangeNotifier implements Subscriber { bool update = false; if (price.ask != _buyTradeValues.price) { - _buyTradeValues.updatePriceAndMargin(price.ask); + if (maxQuantityLock) { + _buyTradeValues.updatePriceAndQuantity(price.ask); + } else { + _buyTradeValues.updatePriceAndMargin(price.ask); + } + update = true; } if (price.bid != _sellTradeValues.price) { - _sellTradeValues.updatePriceAndMargin(price.bid); + if (maxQuantityLock) { + _sellTradeValues.updatePriceAndQuantity(price.bid); + } else { + _sellTradeValues.updatePriceAndMargin(price.bid); + } update = true; } _price = price; diff --git a/mobile/native/src/api.rs b/mobile/native/src/api.rs index 3b17f5ef5..01e63fe46 100644 --- a/mobile/native/src/api.rs +++ b/mobile/native/src/api.rs @@ -17,6 +17,7 @@ use crate::health; use crate::ln_dlc; use crate::ln_dlc::get_storage; use crate::logger; +use crate::max_quantity::max_quantity; use crate::orderbook; use crate::polls; use crate::trade::order; @@ -39,6 +40,7 @@ use flutter_rust_bridge::SyncReturn; use lightning::chain::chaininterface::ConfirmationTarget as LnConfirmationTarget; use ln_dlc_node::seed::Bip39Seed; use rust_decimal::prelude::FromPrimitive; +use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use std::backtrace::Backtrace; use std::fmt; @@ -327,6 +329,17 @@ pub fn order_matching_fee(quantity: f32, price: f32) -> SyncReturn { SyncReturn(order_matching_fee) } +/// Calculates the max quantity the user is able to trade considering the trader and the coordinator +/// balances and constraints. Note, this is not an exact maximum, but a very close approximation. +pub fn calculate_max_quantity(price: f32, trader_leverage: f32) -> SyncReturn { + let price = Decimal::from_f32(price).expect("price to fit in Decimal"); + + let max_quantity = max_quantity(price, trader_leverage).unwrap_or(Decimal::ZERO); + let max_quantity = max_quantity.floor().to_u64().expect("to fit into u64"); + + SyncReturn(max_quantity) +} + #[tokio::main(flavor = "current_thread")] pub async fn submit_order(order: NewOrder) -> Result { order::handler::submit_order(order.into(), None) diff --git a/mobile/native/src/lib.rs b/mobile/native/src/lib.rs index f3f5f3476..56da34d2d 100644 --- a/mobile/native/src/lib.rs +++ b/mobile/native/src/lib.rs @@ -20,6 +20,7 @@ mod destination; mod dlc_channel; mod dlc_handler; mod emergency_kit; +mod max_quantity; mod names; mod orderbook; mod polls; diff --git a/mobile/native/src/max_quantity.rs b/mobile/native/src/max_quantity.rs new file mode 100644 index 000000000..66cecbb79 --- /dev/null +++ b/mobile/native/src/max_quantity.rs @@ -0,0 +1,348 @@ +use crate::calculations; +use crate::channel_trade_constraints::channel_trade_constraints; +use crate::ln_dlc; +use bitcoin::Amount; +use commons::order_matching_fee_taker; +use rust_decimal::prelude::ToPrimitive; +use rust_decimal::Decimal; + +/// Calculates the max quantity a user can trade using the following input parameters +/// - if no channel exists the on-chain fees (channel fee reserve and funding tx fee) is substracted +/// from the max balance. Note, we add a little bit of buffer since these values are only +/// estimates. +/// - The max coordinator margin which is restricted to a certain max amount. +/// - The max trader margin which is either the on-chain balance or the off-chain balance if a +/// channel already exists. +pub fn max_quantity(price: Decimal, trader_leverage: f32) -> anyhow::Result { + let channel_trade_constraints = channel_trade_constraints()?; + + let on_chain_fee_estimate = match channel_trade_constraints.is_channel_balance { + true => None, + false => { + let channel_fee_reserve = ln_dlc::estimated_fee_reserve()?; + let funding_tx_fee = ln_dlc::estimated_funding_tx_fee()?; + // double the funding tx fee to ensure we have enough buffer + let funding_tx_with_buffer = funding_tx_fee * 2; + + Some(channel_fee_reserve + funding_tx_with_buffer) + } + }; + + let max_coordinator_margin = + Amount::from_sat(channel_trade_constraints.max_counterparty_margin_sats); + let max_trader_margin = Amount::from_sat(channel_trade_constraints.max_local_margin_sats); + + let max_quantity = calculate_max_quantity( + price, + max_coordinator_margin, + max_trader_margin, + on_chain_fee_estimate, + channel_trade_constraints.coordinator_leverage, + trader_leverage, + ); + + Ok(max_quantity) +} + +/// Calculates the max quantity for the given input parameters. If an on-chai fee estimate is +/// provided the max margins are reduced by that amount to ensure the fees are considered. +/// +/// 1. Calculate the max coordinator quantity and max trader quantity. +/// 2. The smaller quantity is used to derive the order matching fee. +/// 3. Reduce the max margin by the order matching fee. +/// 4. Recalculate and return the max quantity from the reduced margin. +/// +/// Note, this function will not exactly find the max quantity possible, but a very close +/// approximation. +fn calculate_max_quantity( + price: Decimal, + max_coordinator_margin: Amount, + max_trader_margin: Amount, + on_chain_fee_estimate: Option, + coordinator_leverage: f32, + trader_leverage: f32, +) -> Decimal { + // subtract required on-chain fees with buffer if the trade is opening a channel. + let max_coordinator_margin = max_coordinator_margin + .checked_sub(on_chain_fee_estimate.unwrap_or(Amount::ZERO)) + .unwrap_or(Amount::ZERO); + let max_trader_margin = max_trader_margin + .checked_sub(on_chain_fee_estimate.unwrap_or(Amount::ZERO)) + .unwrap_or(Amount::ZERO); + + let price_f32 = price.to_f32().expect("to fit"); + + let max_trader_quantity = + calculations::calculate_quantity(price_f32, max_trader_margin.to_sat(), trader_leverage); + let max_coordinator_quantity = calculations::calculate_quantity( + price_f32, + max_coordinator_margin.to_sat(), + coordinator_leverage, + ); + + // determine the biggest quantity possible from either side. + let (quantity, max_margin, leverage) = match max_trader_quantity > max_coordinator_quantity { + true => ( + max_coordinator_quantity, + max_coordinator_margin, + coordinator_leverage, + ), + false => (max_trader_quantity, max_trader_margin, trader_leverage), + }; + + // calculate the fee from this quantity + let order_matching_fee = order_matching_fee_taker(quantity, price); + + // subtract the fee from the max local margin and recalculate the quantity. That + // might not be perfect but the closest we can get with a relatively simple logic. + let max_margin_without_order_matching_fees = max_margin - order_matching_fee; + + let max_quantity = calculations::calculate_quantity( + price_f32, + max_margin_without_order_matching_fees.to_sat(), + leverage, + ); + + Decimal::try_from(max_quantity.floor()).expect("to fit into decimal") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_max_quantity() { + let price = Decimal::new(30209, 0); + + let max_coordinator_margin = Amount::from_sat(3_000_000); + let max_trader_margin = Amount::from_sat(280_000); + + let on_chain_fee_estimate = Amount::from_sat(13_500); + + let trader_leverage = 2.0; + let coordinator_levarage = 2.0; + + let max_quantity = calculate_max_quantity( + price, + max_coordinator_margin, + max_trader_margin, + Some(on_chain_fee_estimate), + coordinator_levarage, + trader_leverage, + ); + + let trader_margin = calculations::calculate_margin( + price.to_f32().unwrap(), + max_quantity.to_f32().unwrap(), + trader_leverage, + ); + + let order_matching_fee = order_matching_fee_taker(max_quantity.to_f32().unwrap(), price); + + // Note this is not exactly the max margin the trader, but its the closest we can get. + assert_eq!( + Amount::from_sat(trader_margin) + on_chain_fee_estimate + order_matching_fee, + // max trader margin: 280,000 - 13.500 = 266,500 + // max trader quantity: 0.00,266,500 * 30,209 * 2.0 = 161,01397 + // order matching fee: 161,01397 * (1/30,209) * 0.003 = 0.00,001,599 BTC + // max trader margin without order matching fee: 266,500 - 1,599 = 264,901 + // max quantity without order matching fee: 0.00,264,901 * 30,209 * 2.0 = 160,04788618 + + // trader margin: 160 / (30,209 * 2.0) = 0.00,264,821 BTC + // order matching fee: 160 * (1/30,209) * 0,003 = 0.00,001,589 BTC + // 264,822 + 13,500 + 1589 + Amount::from_sat(279_911) + ); + + // Ensure that the trader still has enough for the order matching fee + assert!(Amount::from_sat(trader_margin) + order_matching_fee < max_trader_margin, + "Trader does not have enough margin left for order matching fee. Has {}, order matching fee {}, needed for order {} ", + max_trader_margin, order_matching_fee , trader_margin); + + // Ensure that the coordinator has enough funds for the trade + let coordinator_margin = calculations::calculate_margin( + price.to_f32().unwrap(), + max_quantity.to_f32().unwrap(), + coordinator_levarage, + ); + assert!(Amount::from_sat(coordinator_margin) < max_coordinator_margin); + } + + #[test] + fn test_calculate_max_quantity_with_smaller_coordinator_margin() { + let price = Decimal::new(30209, 0); + + let max_coordinator_margin = Amount::from_sat(280_000); + let max_trader_margin = Amount::from_sat(280_001); + + let trader_leverage = 2.0; + let coordinator_levarage = 2.0; + + let max_quantity = calculate_max_quantity( + price, + max_coordinator_margin, + max_trader_margin, + None, + coordinator_levarage, + trader_leverage, + ); + + let trader_margin = calculations::calculate_margin( + price.to_f32().unwrap(), + max_quantity.to_f32().unwrap(), + trader_leverage, + ); + + let order_matching_fee = order_matching_fee_taker(max_quantity.to_f32().unwrap(), price); + + // Note this is not exactly the max margin of the coordinator, but its the closest we can + // get. + assert_eq!(Amount::from_sat(trader_margin), Amount::from_sat(278_063)); + + // Ensure that the trader still has enough for the order matching fee + assert!(Amount::from_sat(trader_margin) + order_matching_fee < max_trader_margin, + "Trader does not have enough margin left for order matching fee. Has {}, order matching fee {}, needed for order {} ", + max_trader_margin, order_matching_fee , trader_margin); + + // Ensure that the coordinator has enough funds for the trade + let coordinator_margin = calculations::calculate_margin( + price.to_f32().unwrap(), + max_quantity.to_f32().unwrap(), + coordinator_levarage, + ); + assert!( + Amount::from_sat(coordinator_margin) < max_coordinator_margin, + "Coordinator does not have enough margin for the trade. Has {}, needed for order {} ", + max_coordinator_margin, + coordinator_margin + ); + } + + #[test] + fn test_calculate_max_quantity_with_higher_trader_leverage() { + let price = Decimal::new(30209, 0); + + let max_coordinator_margin = Amount::from_sat(450_000); + let max_trader_margin = Amount::from_sat(280_000); + + let trader_leverage = 5.0; + let coordinator_levarage = 2.0; + + let max_quantity = calculate_max_quantity( + price, + max_coordinator_margin, + max_trader_margin, + None, + coordinator_levarage, + trader_leverage, + ); + + let trader_margin = calculations::calculate_margin( + price.to_f32().unwrap(), + max_quantity.to_f32().unwrap(), + trader_leverage, + ); + + let order_matching_fee = order_matching_fee_taker(max_quantity.to_f32().unwrap(), price); + + // Note we can not max out the users balance, because the counterparty does not have enough + // funds to match that trade on a leverage 2.0 + assert_eq!(Amount::from_sat(trader_margin), Amount::from_sat(178_755)); + + // Ensure that the trader still has enough for the order matching fee + assert!(Amount::from_sat(trader_margin) + order_matching_fee < max_trader_margin, + "Trader does not have enough margin left for order matching fee. Has {}, order matching fee {}, needed for order {} ", + max_trader_margin, order_matching_fee , trader_margin); + + // Ensure that the coordinator has enough funds for the trade + let coordinator_margin = calculations::calculate_margin( + price.to_f32().unwrap(), + max_quantity.to_f32().unwrap(), + coordinator_levarage, + ); + + // Note this is not the max coordinator balance, but the closest we can get. + assert_eq!( + Amount::from_sat(coordinator_margin), + Amount::from_sat(446_887) + ); + } + + #[test] + fn test_calculate_max_quantity_zero_balance() { + let price = Decimal::from(30353); + + let max_coordinator_margin = Amount::from_sat(3_000_000); + let max_trader_margin = Amount::from_sat(0); + + let trader_leverage = 2.0; + let coordinator_levarage = 2.0; + + let on_chain_fee_estimate = Amount::from_sat(1515); + + let max_quantity = calculate_max_quantity( + price, + max_coordinator_margin, + max_trader_margin, + Some(on_chain_fee_estimate), + coordinator_levarage, + trader_leverage, + ); + + assert_eq!(max_quantity, Decimal::ZERO) + } + + #[test] + fn test_calculate_max_quantity_with_max_channel_size() { + let price = Decimal::new(28409, 0); + + let max_coordinator_margin = Amount::from_sat(3_000_000); + let max_trader_margin = Amount::from_btc(1.0).unwrap(); + + let trader_leverage = 2.0; + let coordinator_levarage = 2.0; + + let on_chain_fee_estimate = Amount::from_sat(1515); + + let max_quantity = calculate_max_quantity( + price, + max_coordinator_margin, + max_trader_margin, + Some(on_chain_fee_estimate), + coordinator_levarage, + trader_leverage, + ); + + let trader_margin = calculations::calculate_margin( + price.to_f32().unwrap(), + max_quantity.to_f32().unwrap(), + trader_leverage, + ); + + let order_matching_fee = order_matching_fee_taker(max_quantity.to_f32().unwrap(), price); + + // Note we can not max out the users balance, because the counterparty does not have enough + // funds to match that trade on a leverage 2.0 + assert_eq!(Amount::from_sat(trader_margin), Amount::from_sat(2_979_690)); + + // Ensure that the trader still has enough for the order matching fee + assert!(Amount::from_sat(trader_margin) + order_matching_fee < max_trader_margin, + "Trader does not have enough margin left for order matching fee. Has {}, order matching fee {}, needed for order {} ", + max_trader_margin, order_matching_fee , trader_margin); + + // Ensure that the coordinator has enough funds for the trade + let coordinator_margin = calculations::calculate_margin( + price.to_f32().unwrap(), + max_quantity.to_f32().unwrap(), + coordinator_levarage, + ); + + // Note this is not the max coordinator balance, but the closest we can get. + assert!( + Amount::from_sat(coordinator_margin) < max_coordinator_margin, + "Coordinator does not have enough margin for the trade. Has {}, needed for order {} ", + max_coordinator_margin, + coordinator_margin + ); + } +} diff --git a/mobile/test/trade_test.dart b/mobile/test/trade_test.dart index 2b6d1326b..776bf6a0c 100644 --- a/mobile/test/trade_test.dart +++ b/mobile/test/trade_test.dart @@ -107,6 +107,9 @@ void main() { when(tradeValueService.orderMatchingFee( quantity: anyNamed('quantity'), price: anyNamed('price'))) .thenReturn(Amount(42)); + when(tradeValueService.calculateMaxQuantity( + price: anyNamed('price'), leverage: anyNamed('leverage'))) + .thenReturn(Usd(2500)); when(dlcChannelService.getEstimatedChannelFeeReserve()).thenReturn((Amount(500))); @@ -222,6 +225,9 @@ void main() { when(tradeValueService.orderMatchingFee( quantity: anyNamed('quantity'), price: anyNamed('price'))) .thenReturn(Amount(42)); + when(tradeValueService.calculateMaxQuantity( + price: anyNamed('price'), leverage: anyNamed('leverage'))) + .thenReturn(Usd(2500)); when(channelConstraintsService.getTradeConstraints()).thenAnswer((_) => const bridge.TradeConstraints( From b86de80068480ec1b281a6751cf4785e8eb6019b Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Tue, 26 Mar 2024 19:36:08 +0100 Subject: [PATCH 5/7] chore: Remove unused show capacity info flag --- mobile/lib/features/trade/trade_bottom_sheet_tab.dart | 8 -------- 1 file changed, 8 deletions(-) diff --git a/mobile/lib/features/trade/trade_bottom_sheet_tab.dart b/mobile/lib/features/trade/trade_bottom_sheet_tab.dart index 23b00eb4e..ef3404ca9 100644 --- a/mobile/lib/features/trade/trade_bottom_sheet_tab.dart +++ b/mobile/lib/features/trade/trade_bottom_sheet_tab.dart @@ -48,8 +48,6 @@ class _TradeBottomSheetTabState extends State { final _formKey = GlobalKey(); - bool showCapacityInfo = false; - bool marginInputFieldEnabled = false; bool quantityInputFieldEnabled = true; @@ -277,7 +275,6 @@ class _TradeBottomSheetTabState extends State { } if (quantity.toInt > maxQuantity) { - setState(() => showCapacityInfo = true); return "Max quantity is ${maxQuantity.toInt()}"; } @@ -301,18 +298,13 @@ class _TradeBottomSheetTabState extends State { int neededLocalMarginSats = margin.sats + fee.sats; if (neededLocalMarginSats > maxLocalMarginSats) { - setState(() => showCapacityInfo = true); return "Insufficient balance"; } if (neededCounterpartyMarginSats > maxCounterpartyMarginSats) { - setState(() => showCapacityInfo = true); return "Counterparty has insufficient balance"; } - setState(() { - showCapacityInfo = false; - }); return null; }, )), From 44af564285d3fb7fb885a7b56fb7072a237e613b Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Tue, 26 Mar 2024 19:38:42 +0100 Subject: [PATCH 6/7] chore: Simplify quantity validation Since we now calculate the max quantity on every price change we can rely on that from the trade values instead of recalculating it here. --- .../trade/trade_bottom_sheet_tab.dart | 34 +------------------ 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/mobile/lib/features/trade/trade_bottom_sheet_tab.dart b/mobile/lib/features/trade/trade_bottom_sheet_tab.dart index ef3404ca9..61a197d9c 100644 --- a/mobile/lib/features/trade/trade_bottom_sheet_tab.dart +++ b/mobile/lib/features/trade/trade_bottom_sheet_tab.dart @@ -194,12 +194,6 @@ class _TradeBottomSheetTabState extends State { int usableBalance = channelTradeConstraints.maxLocalMarginSats; - // We compute the max quantity based on the margin needed for the counterparty and how much he has available. - double price = tradeValues.price ?? 0.0; - double maxQuantity = (channelTradeConstraints.maxCounterpartyMarginSats / 100000000) * - price * - channelTradeConstraints.coordinatorLeverage; - quantityController.text = Amount(tradeValues.quantity?.toInt ?? 0).formatted(); return Wrap( @@ -274,37 +268,11 @@ class _TradeBottomSheetTabState extends State { return "Min quantity is ${channelTradeConstraints.minQuantity}"; } + final maxQuantity = tradeValues.maxQuantity?.toInt ?? 0; if (quantity.toInt > maxQuantity) { return "Max quantity is ${maxQuantity.toInt()}"; } - double coordinatorLeverage = channelTradeConstraints.coordinatorLeverage; - - int? optCounterPartyMargin = - provider.counterpartyMargin(direction, coordinatorLeverage); - if (optCounterPartyMargin == null) { - return "Counterparty margin not available"; - } - int neededCounterpartyMarginSats = optCounterPartyMargin; - - // This condition has to stay as the first thing to check, so we reset showing the info - int maxCounterpartyMarginSats = channelTradeConstraints.maxCounterpartyMarginSats; - int maxLocalMarginSats = channelTradeConstraints.maxLocalMarginSats; - - // First we check if we have enough money, then we check if counterparty would have enough money - Amount fee = provider.orderMatchingFee(direction) ?? Amount.zero(); - - Amount margin = tradeValues.margin!; - int neededLocalMarginSats = margin.sats + fee.sats; - - if (neededLocalMarginSats > maxLocalMarginSats) { - return "Insufficient balance"; - } - - if (neededCounterpartyMarginSats > maxCounterpartyMarginSats) { - return "Counterparty has insufficient balance"; - } - return null; }, )), From a0dc14bea8a7cdd3e2728b5912e08aa2fe9a8abc Mon Sep 17 00:00:00 2001 From: Richard Holzeis Date: Wed, 27 Mar 2024 16:38:30 +0100 Subject: [PATCH 7/7] fix: Add more resilience when getting the node from state We can get a price before the node has started, since we are already connected to the coordinator. If that happens the app would crash since the node state has not been set yet. This change makes a couple of apis more resilient towards the node not being available yet and returns a logical default value. IMO this is a safe and better approach than simply crashing the app. --- mobile/native/src/ln_dlc/mod.rs | 45 ++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/mobile/native/src/ln_dlc/mod.rs b/mobile/native/src/ln_dlc/mod.rs index 6729c42da..bd0419ec3 100644 --- a/mobile/native/src/ln_dlc/mod.rs +++ b/mobile/native/src/ln_dlc/mod.rs @@ -647,17 +647,26 @@ pub async fn close_channel(is_force_close: bool) -> Result<()> { } pub fn get_signed_dlc_channels() -> Result> { - let node = state::get_node(); + let node = match state::try_get_node() { + Some(node) => node, + None => return Ok(vec![]), + }; node.inner.list_signed_dlc_channels() } pub fn get_onchain_balance() -> Balance { - let node = state::get_node(); + let node = match state::try_get_node() { + Some(node) => node, + None => return Balance::default(), + }; node.inner.get_on_chain_balance() } pub fn get_usable_dlc_channel_balance() -> Result { - let node = state::get_node(); + let node = match state::try_get_node() { + Some(node) => node, + None => return Ok(Amount::ZERO), + }; node.inner.get_dlc_channels_usable_balance() } @@ -859,14 +868,20 @@ fn update_state_after_collab_revert( } pub fn get_signed_dlc_channel() -> Result> { - let node = state::get_node(); + let node = match state::try_get_node() { + Some(node) => node, + None => return Ok(None), + }; let signed_channels = node.inner.list_signed_dlc_channels()?; Ok(signed_channels.first().cloned()) } pub fn list_dlc_channels() -> Result> { - let node = state::get_node(); + let node = match state::try_get_node() { + Some(node) => node, + None => return Ok(vec![]), + }; let dlc_channels = node.inner.list_dlc_channels()?; @@ -884,7 +899,10 @@ pub fn delete_dlc_channel(dlc_channel_id: &DlcChannelId) -> Result<()> { } pub fn is_dlc_channel_confirmed() -> Result { - let node = state::get_node(); + let node = match state::try_get_node() { + Some(node) => node, + None => return Ok(false), + }; let dlc_channel = match get_signed_dlc_channel()? { Some(dlc_channel) => dlc_channel, @@ -895,12 +913,18 @@ pub fn is_dlc_channel_confirmed() -> Result { } pub fn get_fee_rate_for_target(target: ConfirmationTarget) -> FeeRate { - let node = state::get_node(); + let node = match state::try_get_node() { + Some(node) => node, + None => return FeeRate::default_min_relay_fee(), + }; node.inner.fee_rate_estimator.get(target) } pub fn estimated_fee_reserve() -> Result { - let node = state::get_node(); + let node = match state::try_get_node() { + Some(node) => node, + None => return Ok(Amount::ZERO), + }; // Here we assume that the coordinator will use the same confirmation target AND that their fee // rate source agrees with ours. @@ -929,7 +953,10 @@ pub async fn send_payment(amount: u64, address: String, fee: Fee) -> Result Result { - let node = state::get_node(); + let node = match state::try_get_node() { + Some(node) => node, + None => return Ok(Amount::ZERO), + }; // Here we assume that the coordinator will use the same confirmation target AND that // their fee rate source agrees with ours.