diff --git a/mobile/native/src/api.rs b/mobile/native/src/api.rs index 33f63374e..1389d1187 100644 --- a/mobile/native/src/api.rs +++ b/mobile/native/src/api.rs @@ -1,5 +1,6 @@ use crate::calculations; use crate::channel_trade_constraints; +use crate::channel_trade_constraints::TradeConstraints; use crate::commons::api::Price; use crate::config; use crate::config::api::Config; @@ -546,30 +547,6 @@ pub async fn force_close_channel() -> Result<()> { ln_dlc::close_channel(true).await } -pub struct TradeConstraints { - /// Max margin the local party can use - /// - /// This depends on whether the user has a channel or not. If he has a channel, then his - /// channel balance is the max amount, otherwise his on-chain balance dictates the max amount - pub max_local_margin_sats: u64, - /// Max amount the counterparty is willing to put. - /// - /// This depends whether the user has a channel or not, i.e. if he has a channel then the max - /// amount is what the counterparty has in the channel, otherwise, it's a fixed amount what - /// the counterparty is willing to provide. - pub max_counterparty_margin_sats: u64, - /// The leverage the coordinator will take - pub coordinator_leverage: f32, - /// Smallest allowed amount of contracts - pub min_quantity: u64, - /// If true it means that the user has a channel and hence the max amount is limited by what he - /// has in the channel. In the future we can consider splice in and allow the user to use more - /// than just his channel balance. - pub is_channel_balance: bool, - /// Smallest allowed margin - pub min_margin: u64, -} - pub fn channel_trade_constraints() -> Result> { let trade_constraints = channel_trade_constraints::channel_trade_constraints()?; Ok(SyncReturn(trade_constraints)) diff --git a/mobile/native/src/channel_trade_constraints.rs b/mobile/native/src/channel_trade_constraints.rs index 403ec2945..524de96a2 100644 --- a/mobile/native/src/channel_trade_constraints.rs +++ b/mobile/native/src/channel_trade_constraints.rs @@ -1,8 +1,31 @@ -use crate::api::TradeConstraints; use crate::ln_dlc; use anyhow::Context; use anyhow::Result; +pub struct TradeConstraints { + /// Max margin the local party can use + /// + /// This depends on whether the user has a channel or not. If he has a channel, then his + /// channel balance is the max amount, otherwise his on-chain balance dictates the max amount + pub max_local_margin_sats: u64, + /// Max amount the counterparty is willing to put. + /// + /// This depends whether the user has a channel or not, i.e. if he has a channel then the max + /// amount is what the counterparty has in the channel, otherwise, it's a fixed amount what + /// the counterparty is willing to provide. + pub max_counterparty_margin_sats: u64, + /// The leverage the coordinator will take + pub coordinator_leverage: f32, + /// Smallest allowed amount of contracts + pub min_quantity: u64, + /// If true it means that the user has a channel and hence the max amount is limited by what he + /// has in the channel. In the future we can consider splice in and allow the user to use more + /// than just his channel balance. + pub is_channel_balance: bool, + /// Smallest allowed margin + pub min_margin: u64, +} + pub fn channel_trade_constraints() -> Result { let lsp_config = crate::state::try_get_lsp_config().context("We can't trade without LSP config")?; diff --git a/mobile/native/src/lib.rs b/mobile/native/src/lib.rs index f8943b0a6..49669f8bc 100644 --- a/mobile/native/src/lib.rs +++ b/mobile/native/src/lib.rs @@ -15,7 +15,7 @@ pub mod schema; pub mod state; mod backup; -mod channel_trade_constraints; +pub mod channel_trade_constraints; mod cipher; mod destination; mod dlc_channel; diff --git a/webapp/frontend/lib/auth/login_screen.dart b/webapp/frontend/lib/auth/login_screen.dart index 04c205448..1942898d5 100644 --- a/webapp/frontend/lib/auth/login_screen.dart +++ b/webapp/frontend/lib/auth/login_screen.dart @@ -2,10 +2,10 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:get_10101/auth/auth_service.dart'; +import 'package:get_10101/services/auth_service.dart'; import 'package:get_10101/common/snack_bar.dart'; import 'package:get_10101/common/text_input_field.dart'; -import 'package:get_10101/common/version_service.dart'; +import 'package:get_10101/services/version_service.dart'; import 'package:get_10101/trade/trade_screen.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; diff --git a/webapp/frontend/lib/settings/channel_change_notifier.dart b/webapp/frontend/lib/change_notifier/channel_change_notifier.dart similarity index 68% rename from webapp/frontend/lib/settings/channel_change_notifier.dart rename to webapp/frontend/lib/change_notifier/channel_change_notifier.dart index a9d56745c..03370be24 100644 --- a/webapp/frontend/lib/settings/channel_change_notifier.dart +++ b/webapp/frontend/lib/change_notifier/channel_change_notifier.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get_10101/logger/logger.dart'; -import 'package:get_10101/settings/channel_service.dart'; +import 'package:get_10101/services/channel_service.dart'; import 'package:get_10101/settings/dlc_channel.dart'; class ChannelChangeNotifier extends ChangeNotifier { @@ -41,6 +41,21 @@ class ChannelChangeNotifier extends ChangeNotifier { List? getChannels() => _channels; + DlcChannel? getOpenChannel() { + if (_channels == null) return null; + try { + return _channels!.firstWhere( + (channel) => + (channel.signedChannelState == SignedChannelState.established || + channel.signedChannelState == SignedChannelState.settled) && + channel.channelState == ChannelState.signed, + ); + } catch (e) { + // If no element satisfies the condition, we return null + return null; + } + } + @override void dispose() { super.dispose(); diff --git a/webapp/frontend/lib/common/currency_change_notifier.dart b/webapp/frontend/lib/change_notifier/currency_change_notifier.dart similarity index 100% rename from webapp/frontend/lib/common/currency_change_notifier.dart rename to webapp/frontend/lib/change_notifier/currency_change_notifier.dart diff --git a/webapp/frontend/lib/trade/order_change_notifier.dart b/webapp/frontend/lib/change_notifier/order_change_notifier.dart similarity index 92% rename from webapp/frontend/lib/trade/order_change_notifier.dart rename to webapp/frontend/lib/change_notifier/order_change_notifier.dart index 32d5f2ef1..31901d998 100644 --- a/webapp/frontend/lib/trade/order_change_notifier.dart +++ b/webapp/frontend/lib/change_notifier/order_change_notifier.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get_10101/logger/logger.dart'; -import 'package:get_10101/trade/order_service.dart'; +import 'package:get_10101/services/order_service.dart'; class OrderChangeNotifier extends ChangeNotifier { final OrderService service; diff --git a/webapp/frontend/lib/trade/position_change_notifier.dart b/webapp/frontend/lib/change_notifier/position_change_notifier.dart similarity index 92% rename from webapp/frontend/lib/trade/position_change_notifier.dart rename to webapp/frontend/lib/change_notifier/position_change_notifier.dart index dff6fbd3f..4eda42504 100644 --- a/webapp/frontend/lib/trade/position_change_notifier.dart +++ b/webapp/frontend/lib/change_notifier/position_change_notifier.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get_10101/logger/logger.dart'; -import 'package:get_10101/trade/position_service.dart'; +import 'package:get_10101/services/position_service.dart'; class PositionChangeNotifier extends ChangeNotifier { final PositionService service; diff --git a/webapp/frontend/lib/trade/quote_change_notifier.dart b/webapp/frontend/lib/change_notifier/quote_change_notifier.dart similarity index 92% rename from webapp/frontend/lib/trade/quote_change_notifier.dart rename to webapp/frontend/lib/change_notifier/quote_change_notifier.dart index c26662f36..cda462249 100644 --- a/webapp/frontend/lib/trade/quote_change_notifier.dart +++ b/webapp/frontend/lib/change_notifier/quote_change_notifier.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get_10101/logger/logger.dart'; -import 'package:get_10101/trade/quote_service.dart'; +import 'package:get_10101/services/quote_service.dart'; class QuoteChangeNotifier extends ChangeNotifier { final QuoteService service; diff --git a/webapp/frontend/lib/change_notifier/trade_constraint_change_notifier.dart b/webapp/frontend/lib/change_notifier/trade_constraint_change_notifier.dart new file mode 100644 index 000000000..1e9373a93 --- /dev/null +++ b/webapp/frontend/lib/change_notifier/trade_constraint_change_notifier.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:get_10101/logger/logger.dart'; +import 'package:get_10101/services/trade_constraints_service.dart'; + +class TradeConstraintsChangeNotifier extends ChangeNotifier { + final TradeConstraintsService service; + + TradeConstraints? _tradeConstraints; + + TradeConstraintsChangeNotifier(this.service) { + _refresh(); + } + + void _refresh() async { + try { + final tradeConstraints = await service.getTradeConstraints(); + _tradeConstraints = tradeConstraints; + super.notifyListeners(); + } catch (error) { + logger.e(error); + } + } + + TradeConstraints? get tradeConstraints => _tradeConstraints; +} diff --git a/webapp/frontend/lib/wallet/wallet_change_notifier.dart b/webapp/frontend/lib/change_notifier/wallet_change_notifier.dart similarity index 94% rename from webapp/frontend/lib/wallet/wallet_change_notifier.dart rename to webapp/frontend/lib/change_notifier/wallet_change_notifier.dart index 1a9e804a9..e10d0d8e8 100644 --- a/webapp/frontend/lib/wallet/wallet_change_notifier.dart +++ b/webapp/frontend/lib/change_notifier/wallet_change_notifier.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:get_10101/common/balance.dart'; import 'package:get_10101/common/payment.dart'; import 'package:get_10101/logger/logger.dart'; -import 'package:get_10101/wallet/wallet_service.dart'; +import 'package:get_10101/services/wallet_service.dart'; class WalletChangeNotifier extends ChangeNotifier { final WalletService service; diff --git a/webapp/frontend/lib/common/amount_text_field.dart b/webapp/frontend/lib/common/amount_text_field.dart new file mode 100644 index 000000000..58c2847ab --- /dev/null +++ b/webapp/frontend/lib/common/amount_text_field.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:get_10101/common/color.dart'; +import 'package:get_10101/common/model.dart'; + +class AmountTextField extends StatefulWidget { + const AmountTextField( + {super.key, required this.label, required this.value, this.suffixIcon, this.error}); + + final Amount value; + final String label; + final Widget? suffixIcon; + final String? error; + + @override + State createState() => _AmountTextState(); +} + +class _AmountTextState extends State { + @override + Widget build(BuildContext context) { + String value = widget.value.formatted(); + + return InputDecorator( + decoration: InputDecoration( + contentPadding: const EdgeInsets.fromLTRB(12, 24, 12, 17), + border: const OutlineInputBorder(), + labelText: widget.label, + labelStyle: const TextStyle(color: Colors.black87), + errorStyle: TextStyle( + color: Colors.red[900], + ), + errorText: widget.error, + filled: true, + suffixIcon: widget.suffixIcon, + fillColor: tenTenOnePurple.shade50.withOpacity(0.3)), + child: Text(value, style: const TextStyle(fontSize: 16)), + ); + } +} diff --git a/webapp/frontend/lib/common/amount_text_input_form_field.dart b/webapp/frontend/lib/common/amount_text_input_form_field.dart index f0eb05121..1c400db1b 100644 --- a/webapp/frontend/lib/common/amount_text_input_form_field.dart +++ b/webapp/frontend/lib/common/amount_text_input_form_field.dart @@ -23,6 +23,8 @@ class AmountInputField extends StatelessWidget { this.onTap, this.textAlign = TextAlign.left, this.suffixIcon, + this.heightPadding, + this.widthPadding, }); final TextEditingController? controller; @@ -38,6 +40,8 @@ class AmountInputField extends StatelessWidget { final InputDecoration? decoration; final TextAlign textAlign; final Widget? suffixIcon; + final double? heightPadding; + final double? widthPadding; final String? Function(String?)? validator; @@ -62,6 +66,13 @@ class AmountInputField extends StatelessWidget { color: Colors.red[900], ), suffixIcon: isLoading ? const CircularProgressIndicator() : suffixIcon, + contentPadding: (heightPadding != null || widthPadding != null) + ? EdgeInsets.only( + top: heightPadding! / 2, + bottom: heightPadding! / 2, + left: widthPadding! / 2, + right: widthPadding! / 2) + : null, ), inputFormatters: [ FilteringTextInputFormatter.digitsOnly, diff --git a/webapp/frontend/lib/common/balance.dart b/webapp/frontend/lib/common/balance.dart index ea9661671..1a1193159 100644 --- a/webapp/frontend/lib/common/balance.dart +++ b/webapp/frontend/lib/common/balance.dart @@ -21,5 +21,5 @@ class Balance { : offChain = Amount.zero(), onChain = Amount.zero(); - Amount total() => offChain.add(onChain); + Amount total() => offChain + onChain; } diff --git a/webapp/frontend/lib/common/calculations.dart b/webapp/frontend/lib/common/calculations.dart new file mode 100644 index 000000000..cf71b2518 --- /dev/null +++ b/webapp/frontend/lib/common/calculations.dart @@ -0,0 +1,40 @@ +import 'package:get_10101/common/model.dart'; +import 'package:get_10101/services/quote_service.dart'; + +Amount calculateFee(Usd? quantity, BestQuote? quote, bool isLong) { + if (quote?.fee == null || quote?.fee == 0 || quantity == null) { + return Amount.zero(); + } + + return Amount( + (calculateMargin(quantity, quote!, Leverage.one(), isLong).sats * quote.fee!).toInt()); +} + +Amount calculateMargin(Usd quantity, BestQuote quote, Leverage leverage, bool isLong) { + if (isLong && quote.ask != null) { + if (quote.ask!.asDouble == 0) { + return Amount.zero(); + } + return Amount.fromBtc(quantity.asDouble / (quote.ask!.asDouble * leverage.asDouble)); + } else if (!isLong && quote.bid != null) { + if (quote.bid!.asDouble == 0) { + return Amount.zero(); + } + return Amount.fromBtc(quantity.asDouble / (quote.bid!.asDouble * leverage.asDouble)); + } else { + return Amount.zero(); + } +} + +Amount calculateLiquidationPrice( + Usd quantity, BestQuote quote, Leverage leverage, double maintenanceMargin, bool isLong) { + if (isLong && quote.ask != null) { + return Amount((quote.bid!.asDouble * leverage.asDouble) ~/ + (leverage.asDouble + 1.0 + (maintenanceMargin * leverage.asDouble))); + } else if (!isLong && quote.bid != null) { + return Amount((quote.ask!.asDouble * leverage.asDouble) ~/ + (leverage.asDouble - 1.0 + (maintenanceMargin * leverage.asDouble))); + } else { + return Amount.zero(); + } +} diff --git a/webapp/frontend/lib/common/channel_state_label.dart b/webapp/frontend/lib/common/channel_state_label.dart index 1038d549f..319d06fe0 100644 --- a/webapp/frontend/lib/common/channel_state_label.dart +++ b/webapp/frontend/lib/common/channel_state_label.dart @@ -10,28 +10,7 @@ class SignedChannelStateLabel extends StatelessWidget { Widget build(BuildContext context) { Widget label = _buildLabel("Unknown state", Colors.green.shade300); if (channel != null && channel!.signedChannelState != null) { - switch (channel!.signedChannelState) { - case SignedChannelState.established: - case SignedChannelState.settled: - label = _buildLabel("Active", Colors.green.shade300); - break; - case SignedChannelState.settledOffered: - case SignedChannelState.settledReceived: - case SignedChannelState.settledAccepted: - case SignedChannelState.settledConfirmed: - case SignedChannelState.renewOffered: - case SignedChannelState.renewAccepted: - case SignedChannelState.renewConfirmed: - case SignedChannelState.renewFinalized: - label = _buildLabel("Pending", Colors.green.shade300); - break; - case SignedChannelState.closing: - case SignedChannelState.collaborativeCloseOffered: - label = _buildLabel("Closing", Colors.orange.shade300); - break; - case null: - // nothing - } + label = _buildLabel(channel!.signedChannelState!.nameU, Colors.green.shade300); } return label; } @@ -46,30 +25,7 @@ class ChannelStateLabel extends StatelessWidget { Widget build(BuildContext context) { Widget label = _buildLabel("Unknown state", Colors.green.shade300); if (channel != null) { - switch (channel!.channelState) { - case ChannelState.offered: - label = _buildLabel("Offered", Colors.grey.shade300); - case ChannelState.accepted: - label = _buildLabel("Accepted", Colors.grey.shade300); - case ChannelState.signed: - label = _buildLabel("Signed", Colors.grey.shade300); - case ChannelState.closing: - label = _buildLabel("Closing", Colors.grey.shade300); - case ChannelState.closed: - label = _buildLabel("Closed", Colors.grey.shade300); - case ChannelState.counterClosed: - label = _buildLabel("Counter closed", Colors.grey.shade300); - case ChannelState.closedPunished: - label = _buildLabel("Closed punished", Colors.grey.shade300); - case ChannelState.collaborativelyClosed: - label = _buildLabel("Collaboratively closed", Colors.grey.shade300); - case ChannelState.failedAccept: - label = _buildLabel("Failed", Colors.grey.shade300); - case ChannelState.failedSign: - label = _buildLabel("Failed", Colors.grey.shade300); - case ChannelState.cancelled: - label = _buildLabel("Cancelled", Colors.grey.shade300); - } + label = _buildLabel(channel!.channelState.nameU, Colors.green.shade300); } return label; } diff --git a/webapp/frontend/lib/common/currency_selection_widget.dart b/webapp/frontend/lib/common/currency_selection_widget.dart index f9b0027c9..6053304d5 100644 --- a/webapp/frontend/lib/common/currency_selection_widget.dart +++ b/webapp/frontend/lib/common/currency_selection_widget.dart @@ -1,7 +1,7 @@ import 'package:bitcoin_icons/bitcoin_icons.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:get_10101/common/currency_change_notifier.dart'; +import 'package:get_10101/change_notifier/currency_change_notifier.dart'; import 'package:provider/provider.dart'; class CurrencySelectionScreen extends StatelessWidget { diff --git a/webapp/frontend/lib/common/model.dart b/webapp/frontend/lib/common/model.dart index 847fdd839..2f33a5588 100644 --- a/webapp/frontend/lib/common/model.dart +++ b/webapp/frontend/lib/common/model.dart @@ -33,12 +33,28 @@ class Amount implements Formattable { Amount.zero() : _sats = Decimal.zero; - Amount add(Amount amount) { - return Amount(sats + amount.sats); + // Overloading the + operator + Amount operator +(Amount other) { + return Amount(sats + other.sats); } - Amount sub(Amount amount) { - return Amount(sats - amount.sats); + // Overloading the - operator + Amount operator -(Amount other) { + return Amount(sats - other.sats); + } + + static Amount max(Amount amountA, [Amount? amountB]) { + if (amountB == null) { + return amountA; + } + return amountA.sats > amountB.sats ? amountA : amountB; + } + + static Amount min(Amount amountA, [Amount? amountB]) { + if (amountB == null) { + return amountA; + } + return amountA.sats < amountB.sats ? amountA : amountB; } Amount.parseAmount(String? value) { diff --git a/webapp/frontend/lib/common/scaffold_with_nav.dart b/webapp/frontend/lib/common/scaffold_with_nav.dart index f3548fdec..79a96b0df 100644 --- a/webapp/frontend/lib/common/scaffold_with_nav.dart +++ b/webapp/frontend/lib/common/scaffold_with_nav.dart @@ -3,20 +3,20 @@ import 'dart:async'; import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:get_10101/auth/auth_service.dart'; +import 'package:get_10101/change_notifier/quote_change_notifier.dart'; +import 'package:get_10101/services/auth_service.dart'; import 'package:get_10101/auth/login_screen.dart'; import 'package:get_10101/common/amount_text.dart'; import 'package:get_10101/common/balance.dart'; import 'package:get_10101/common/color.dart'; -import 'package:get_10101/common/currency_change_notifier.dart'; +import 'package:get_10101/change_notifier/currency_change_notifier.dart'; import 'package:get_10101/common/currency_selection_widget.dart'; import 'package:get_10101/common/model.dart'; import 'package:get_10101/common/snack_bar.dart'; -import 'package:get_10101/common/version_service.dart'; +import 'package:get_10101/services/version_service.dart'; import 'package:get_10101/logger/logger.dart'; -import 'package:get_10101/trade/quote_change_notifier.dart'; -import 'package:get_10101/trade/quote_service.dart'; -import 'package:get_10101/wallet/wallet_change_notifier.dart'; +import 'package:get_10101/services/quote_service.dart'; +import 'package:get_10101/change_notifier/wallet_change_notifier.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; @@ -288,8 +288,8 @@ class ScaffoldWithNavigationRail extends StatelessWidget { ? [] : [ formatAmountAsCurrency( - balance?.onChain - .add(balance?.offChain ?? Amount.zero()), + (balance?.onChain ?? Amount.zero()) + + (balance?.offChain ?? Amount.zero()), currency, midMarket), ]), diff --git a/webapp/frontend/lib/main.dart b/webapp/frontend/lib/main.dart index 1cd73c967..aa0b4293e 100644 --- a/webapp/frontend/lib/main.dart +++ b/webapp/frontend/lib/main.dart @@ -1,20 +1,22 @@ import 'package:flutter/material.dart'; -import 'package:get_10101/auth/auth_service.dart'; -import 'package:get_10101/common/currency_change_notifier.dart'; -import 'package:get_10101/common/version_service.dart'; +import 'package:get_10101/change_notifier/quote_change_notifier.dart'; +import 'package:get_10101/change_notifier/trade_constraint_change_notifier.dart'; +import 'package:get_10101/services/auth_service.dart'; +import 'package:get_10101/change_notifier/currency_change_notifier.dart'; +import 'package:get_10101/services/trade_constraints_service.dart'; +import 'package:get_10101/services/version_service.dart'; import 'package:get_10101/logger/logger.dart'; import 'package:get_10101/routes.dart'; -import 'package:get_10101/settings/channel_change_notifier.dart'; -import 'package:get_10101/settings/channel_service.dart'; -import 'package:get_10101/trade/order_change_notifier.dart'; -import 'package:get_10101/trade/order_service.dart'; -import 'package:get_10101/trade/position_change_notifier.dart'; -import 'package:get_10101/trade/position_service.dart'; -import 'package:get_10101/trade/quote_change_notifier.dart'; -import 'package:get_10101/trade/quote_service.dart'; -import 'package:get_10101/settings/settings_service.dart'; -import 'package:get_10101/wallet/wallet_change_notifier.dart'; -import 'package:get_10101/wallet/wallet_service.dart'; +import 'package:get_10101/change_notifier/channel_change_notifier.dart'; +import 'package:get_10101/services/channel_service.dart'; +import 'package:get_10101/change_notifier/order_change_notifier.dart'; +import 'package:get_10101/services/order_service.dart'; +import 'package:get_10101/change_notifier/position_change_notifier.dart'; +import 'package:get_10101/services/position_service.dart'; +import 'package:get_10101/services/quote_service.dart'; +import 'package:get_10101/services/settings_service.dart'; +import 'package:get_10101/change_notifier/wallet_change_notifier.dart'; +import 'package:get_10101/services/wallet_service.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl_browser.dart'; import 'package:provider/provider.dart'; @@ -36,6 +38,7 @@ Future main() async { const walletService = WalletService(); const channelService = ChannelService(); + const tradeConstraintsService = TradeConstraintsService(); var providers = [ Provider(create: (context) => const VersionService()), @@ -45,6 +48,8 @@ Future main() async { ChangeNotifierProvider(create: (context) => OrderChangeNotifier(const OrderService())), ChangeNotifierProvider(create: (context) => ChannelChangeNotifier(channelService)), ChangeNotifierProvider(create: (context) => CurrencyChangeNotifier(Currency.sats)), + ChangeNotifierProvider( + create: (context) => TradeConstraintsChangeNotifier(tradeConstraintsService)), Provider(create: (context) => const SettingsService()), Provider(create: (context) => channelService), Provider(create: (context) => AuthService()), diff --git a/webapp/frontend/lib/routes.dart b/webapp/frontend/lib/routes.dart index 020d84d40..4ba0c6874 100644 --- a/webapp/frontend/lib/routes.dart +++ b/webapp/frontend/lib/routes.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:get_10101/auth/auth_service.dart'; +import 'package:get_10101/services/auth_service.dart'; import 'package:get_10101/auth/login_screen.dart'; import 'package:get_10101/common/global_keys.dart'; import 'package:get_10101/common/scaffold_with_nav.dart'; diff --git a/webapp/frontend/lib/auth/auth_service.dart b/webapp/frontend/lib/services/auth_service.dart similarity index 100% rename from webapp/frontend/lib/auth/auth_service.dart rename to webapp/frontend/lib/services/auth_service.dart diff --git a/webapp/frontend/lib/settings/channel_service.dart b/webapp/frontend/lib/services/channel_service.dart similarity index 100% rename from webapp/frontend/lib/settings/channel_service.dart rename to webapp/frontend/lib/services/channel_service.dart diff --git a/webapp/frontend/lib/trade/new_order_service.dart b/webapp/frontend/lib/services/new_order_service.dart similarity index 75% rename from webapp/frontend/lib/trade/new_order_service.dart rename to webapp/frontend/lib/services/new_order_service.dart index 7af274a0f..167fc267a 100644 --- a/webapp/frontend/lib/trade/new_order_service.dart +++ b/webapp/frontend/lib/services/new_order_service.dart @@ -23,7 +23,8 @@ class OrderId { class NewOrderService { const NewOrderService(); - static Future postNewOrder(Leverage leverage, Usd quantity, bool isLong) async { + static Future postNewOrder(Leverage leverage, Usd quantity, bool isLong, + {ChannelOpeningParams? channelOpeningParams}) async { final response = await HttpClientManager.instance.post(Uri(path: '/api/orders'), headers: { 'Content-Type': 'application/json; charset=UTF-8', @@ -32,6 +33,8 @@ class NewOrderService { 'leverage': leverage.asDouble, 'quantity': quantity.asDouble, 'direction': isLong ? "Long" : "Short", + 'coordinator_reserve': channelOpeningParams?.coordinatorReserve.sats, + 'trader_reserve': channelOpeningParams?.traderReserve.sats })); if (response.statusCode == 200) { @@ -41,3 +44,10 @@ class NewOrderService { } } } + +class ChannelOpeningParams { + final Amount coordinatorReserve; + final Amount traderReserve; + + ChannelOpeningParams(this.coordinatorReserve, this.traderReserve); +} diff --git a/webapp/frontend/lib/trade/order_service.dart b/webapp/frontend/lib/services/order_service.dart similarity index 100% rename from webapp/frontend/lib/trade/order_service.dart rename to webapp/frontend/lib/services/order_service.dart diff --git a/webapp/frontend/lib/trade/position_service.dart b/webapp/frontend/lib/services/position_service.dart similarity index 100% rename from webapp/frontend/lib/trade/position_service.dart rename to webapp/frontend/lib/services/position_service.dart diff --git a/webapp/frontend/lib/services/proportional_fee.dart b/webapp/frontend/lib/services/proportional_fee.dart new file mode 100644 index 000000000..9aa490bf6 --- /dev/null +++ b/webapp/frontend/lib/services/proportional_fee.dart @@ -0,0 +1,22 @@ +import 'package:get_10101/common/model.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'proportional_fee.g.dart'; + +/// A representation of a proportional fee of an amount with an optional min amount. +@JsonSerializable() +class ProportionalFee { + double percentage; + @JsonKey(name: 'min_sats') + int minSats; + + ProportionalFee({required this.percentage, this.minSats = 0}); + + Amount getFee(Amount amount) { + final fee = (amount.sats / 100) * percentage; + return fee < minSats ? Amount(minSats) : Amount(fee.ceil()); + } + + factory ProportionalFee.fromJson(Map json) => _$ProportionalFeeFromJson(json); + Map toJson() => _$ProportionalFeeToJson(this); +} diff --git a/webapp/frontend/lib/services/proportional_fee.g.dart b/webapp/frontend/lib/services/proportional_fee.g.dart new file mode 100644 index 000000000..1c82f47b5 --- /dev/null +++ b/webapp/frontend/lib/services/proportional_fee.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'proportional_fee.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ProportionalFee _$ProportionalFeeFromJson(Map json) => ProportionalFee( + percentage: (json['percentage'] as num).toDouble(), + minSats: json['min_sats'] as int? ?? 0, + ); + +Map _$ProportionalFeeToJson(ProportionalFee instance) => { + 'percentage': instance.percentage, + 'min_sats': instance.minSats, + }; diff --git a/webapp/frontend/lib/trade/quote_service.dart b/webapp/frontend/lib/services/quote_service.dart similarity index 100% rename from webapp/frontend/lib/trade/quote_service.dart rename to webapp/frontend/lib/services/quote_service.dart diff --git a/webapp/frontend/lib/settings/settings_service.dart b/webapp/frontend/lib/services/settings_service.dart similarity index 100% rename from webapp/frontend/lib/settings/settings_service.dart rename to webapp/frontend/lib/services/settings_service.dart diff --git a/webapp/frontend/lib/settings/settings_service.g.dart b/webapp/frontend/lib/services/settings_service.g.dart similarity index 100% rename from webapp/frontend/lib/settings/settings_service.g.dart rename to webapp/frontend/lib/services/settings_service.g.dart diff --git a/webapp/frontend/lib/services/trade_constraints_service.dart b/webapp/frontend/lib/services/trade_constraints_service.dart new file mode 100644 index 000000000..35a2ced9c --- /dev/null +++ b/webapp/frontend/lib/services/trade_constraints_service.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; +import 'package:get_10101/common/http_client.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'trade_constraints_service.g.dart'; + +class TradeConstraintsService { + const TradeConstraintsService(); + + Future getTradeConstraints() async { + final response = await HttpClientManager.instance.get(Uri(path: '/api/tradeconstraints')); + + if (response.statusCode == 200) { + final jsonData = jsonDecode(response.body); + return TradeConstraints.fromJson(jsonData); + } else { + throw FlutterError("Failed to fetch liquidity options phrase"); + } + } +} + +@JsonSerializable() +class TradeConstraints { + /// Max margin the local party can use + /// + /// This depends on whether the user has a channel or not. If he has a channel, then his + /// channel balance is the max amount, otherwise his on-chain balance dictates the max amount + @JsonKey(name: 'max_local_margin_sats') + final int maxLocalMarginSats; + + /// Max amount the counterparty is willing to put. + /// + /// This depends whether the user has a channel or not, i.e. if he has a channel then the max + /// amount is what the counterparty has in the channel, otherwise, it's a fixed amount what + /// the counterparty is willing to provide. + @JsonKey(name: 'max_counterparty_margin_sats') + final int maxCounterpartyMarginSats; + + /// The leverage the coordinator will take + @JsonKey(name: 'coordinator_leverage') + final double coordinatorLeverage; + + /// Smallest allowed amount of contracts + @JsonKey(name: 'min_quantity') + final int minQuantity; + + /// If true it means that the user has a channel and hence the max amount is limited by what he + /// has in the channel. In the future we can consider splice in and allow the user to use more + /// than just his channel balance. + @JsonKey(name: 'is_channel_balance') + final bool isChannelBalance; + + /// Smallest allowed margin + @JsonKey(name: 'min_margin_sats') + final int minMarginSats; + + /// The estimated fee to be paid to open a channel in sats + @JsonKey(name: 'estimated_funding_tx_fee_sats') + final int estimatedFundingTxFeeSats; + + /// The fee we need to reserve in the channel reserve for tx fees + @JsonKey(name: 'channel_fee_reserve_sats') + final int channelFeeReserveSats; + + const TradeConstraints({ + required this.maxLocalMarginSats, + required this.maxCounterpartyMarginSats, + required this.coordinatorLeverage, + required this.minQuantity, + required this.isChannelBalance, + required this.minMarginSats, + required this.estimatedFundingTxFeeSats, + required this.channelFeeReserveSats, + }); + + factory TradeConstraints.fromJson(Map json) => _$TradeConstraintsFromJson(json); + Map toJson() => _$TradeConstraintsToJson(this); +} diff --git a/webapp/frontend/lib/services/trade_constraints_service.g.dart b/webapp/frontend/lib/services/trade_constraints_service.g.dart new file mode 100644 index 000000000..6315fa362 --- /dev/null +++ b/webapp/frontend/lib/services/trade_constraints_service.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'trade_constraints_service.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TradeConstraints _$TradeConstraintsFromJson(Map json) => TradeConstraints( + maxLocalMarginSats: json['max_local_margin_sats'] as int, + maxCounterpartyMarginSats: json['max_counterparty_margin_sats'] as int, + coordinatorLeverage: (json['coordinator_leverage'] as num).toDouble(), + minQuantity: json['min_quantity'] as int, + isChannelBalance: json['is_channel_balance'] as bool, + minMarginSats: json['min_margin_sats'] as int, + estimatedFundingTxFeeSats: json['estimated_funding_tx_fee_sats'] as int, + channelFeeReserveSats: json['channel_fee_reserve_sats'] as int, + ); + +Map _$TradeConstraintsToJson(TradeConstraints instance) => { + 'max_local_margin_sats': instance.maxLocalMarginSats, + 'max_counterparty_margin_sats': instance.maxCounterpartyMarginSats, + 'coordinator_leverage': instance.coordinatorLeverage, + 'min_quantity': instance.minQuantity, + 'is_channel_balance': instance.isChannelBalance, + 'min_margin_sats': instance.minMarginSats, + 'estimated_funding_tx_fee_sats': instance.estimatedFundingTxFeeSats, + 'channel_fee_reserve_sats': instance.channelFeeReserveSats, + }; diff --git a/webapp/frontend/lib/common/version_service.dart b/webapp/frontend/lib/services/version_service.dart similarity index 100% rename from webapp/frontend/lib/common/version_service.dart rename to webapp/frontend/lib/services/version_service.dart diff --git a/webapp/frontend/lib/wallet/wallet_service.dart b/webapp/frontend/lib/services/wallet_service.dart similarity index 100% rename from webapp/frontend/lib/wallet/wallet_service.dart rename to webapp/frontend/lib/services/wallet_service.dart diff --git a/webapp/frontend/lib/settings/app_info_screen.dart b/webapp/frontend/lib/settings/app_info_screen.dart index 446f857cd..f2a56dae6 100644 --- a/webapp/frontend/lib/settings/app_info_screen.dart +++ b/webapp/frontend/lib/settings/app_info_screen.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get_10101/common/color.dart'; import 'package:get_10101/common/snack_bar.dart'; -import 'package:get_10101/common/version_service.dart'; -import 'package:get_10101/settings/settings_service.dart'; +import 'package:get_10101/services/version_service.dart'; +import 'package:get_10101/services/settings_service.dart'; import 'package:provider/provider.dart'; class AppInfoScreen extends StatefulWidget { diff --git a/webapp/frontend/lib/settings/channel_screen.dart b/webapp/frontend/lib/settings/channel_screen.dart index e9557206d..3edaf4e77 100644 --- a/webapp/frontend/lib/settings/channel_screen.dart +++ b/webapp/frontend/lib/settings/channel_screen.dart @@ -3,8 +3,8 @@ import 'package:flutter/services.dart'; import 'package:get_10101/common/channel_state_label.dart'; import 'package:get_10101/common/snack_bar.dart'; import 'package:get_10101/common/truncate_text.dart'; -import 'package:get_10101/settings/channel_change_notifier.dart'; -import 'package:get_10101/settings/channel_service.dart'; +import 'package:get_10101/change_notifier/channel_change_notifier.dart'; +import 'package:get_10101/services/channel_service.dart'; import 'package:get_10101/settings/dlc_channel.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; diff --git a/webapp/frontend/lib/settings/dlc_channel.dart b/webapp/frontend/lib/settings/dlc_channel.dart index 96d938acd..abf0eeffd 100644 --- a/webapp/frontend/lib/settings/dlc_channel.dart +++ b/webapp/frontend/lib/settings/dlc_channel.dart @@ -24,7 +24,9 @@ enum ChannelState { @JsonValue('FailedSign') failedSign, @JsonValue('Cancelled') - cancelled, + cancelled; + + String get nameU => "${name[0].toUpperCase()}${name.substring(1)}"; } enum SignedChannelState { @@ -51,7 +53,9 @@ enum SignedChannelState { @JsonValue('Closing') closing, @JsonValue('CollaborativeCloseOffered') - collaborativeCloseOffered, + collaborativeCloseOffered; + + String get nameU => "${name[0].toUpperCase()}${name.substring(1)}"; } @JsonSerializable() diff --git a/webapp/frontend/lib/settings/seed_screen.dart b/webapp/frontend/lib/settings/seed_screen.dart index a4b589ba5..6e95a0409 100644 --- a/webapp/frontend/lib/settings/seed_screen.dart +++ b/webapp/frontend/lib/settings/seed_screen.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get_10101/settings/seed_words.dart'; -import 'package:get_10101/settings/settings_service.dart'; +import 'package:get_10101/services/settings_service.dart'; import 'package:provider/provider.dart'; class SeedScreen extends StatefulWidget { diff --git a/webapp/frontend/lib/trade/close_position_confirmation_dialog.dart b/webapp/frontend/lib/trade/close_position_confirmation_dialog.dart index ef9c0d651..b9f856aa8 100644 --- a/webapp/frontend/lib/trade/close_position_confirmation_dialog.dart +++ b/webapp/frontend/lib/trade/close_position_confirmation_dialog.dart @@ -5,8 +5,8 @@ import 'package:get_10101/common/model.dart'; import 'package:get_10101/common/snack_bar.dart'; import 'package:get_10101/common/theme.dart'; import 'package:get_10101/common/value_data_row.dart'; -import 'package:get_10101/trade/new_order_service.dart'; -import 'package:get_10101/trade/quote_service.dart'; +import 'package:get_10101/services/new_order_service.dart'; +import 'package:get_10101/services/quote_service.dart'; class TradeConfirmationDialog extends StatelessWidget { final Direction direction; diff --git a/webapp/frontend/lib/trade/collateral_slider.dart b/webapp/frontend/lib/trade/collateral_slider.dart new file mode 100644 index 000000000..ba7fe9931 --- /dev/null +++ b/webapp/frontend/lib/trade/collateral_slider.dart @@ -0,0 +1,96 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:get_10101/common/color.dart'; +import 'package:syncfusion_flutter_sliders/sliders.dart'; +import 'package:syncfusion_flutter_core/theme.dart' as slider_theme; + +/// Slider that allows the user to select a value between minValue and maxValue. +class CollateralSlider extends StatefulWidget { + final int value; + final Function(int)? onValueChanged; + final int minValue; + final int maxValue; + final String labelText; + + const CollateralSlider( + {required this.onValueChanged, + required this.value, + super.key, + required this.minValue, + required this.maxValue, + required this.labelText}); + + @override + State createState() => _ValueSliderState(); +} + +class _ValueSliderState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return InputDecorator( + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: widget.labelText, + labelStyle: const TextStyle(color: tenTenOnePurple), + filled: true, + fillColor: Colors.white, + errorStyle: TextStyle( + color: Colors.red[900], + ), + ), + child: Padding( + padding: const EdgeInsets.only(left: 8, right: 8), + child: SizedBox( + height: 35, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 2, right: 2), + child: slider_theme.SfSliderTheme( + data: slider_theme.SfSliderThemeData(), + child: SfSlider( + min: widget.minValue, + max: widget.maxValue, + value: min(max(widget.value, widget.minValue), widget.maxValue), + showTicks: true, + stepSize: 1, + enableTooltip: true, + showLabels: true, + tooltipTextFormatterCallback: (dynamic actualValue, String formattedText) { + return "$formattedText sats"; + }, + labelFormatterCallback: (dynamic actualValue, String formattedText) { + if (actualValue == widget.minValue) { + return "Min"; + } else if (actualValue == widget.maxValue) { + return "Max"; + } else { + return ""; + } + }, + tooltipShape: const SfPaddleTooltipShape(), + onChanged: widget.onValueChanged == null + ? null + : (dynamic value) { + // weirdly this is a double value + widget.onValueChanged!((value as double).toInt()); + }, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/webapp/frontend/lib/trade/create_channel_confirmation_dialog.dart b/webapp/frontend/lib/trade/create_channel_confirmation_dialog.dart new file mode 100644 index 000000000..c0949e3be --- /dev/null +++ b/webapp/frontend/lib/trade/create_channel_confirmation_dialog.dart @@ -0,0 +1,347 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:get_10101/change_notifier/trade_constraint_change_notifier.dart'; +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/calculations.dart'; +import 'package:get_10101/common/contract_symbol_icon.dart'; +import 'package:get_10101/common/direction.dart'; +import 'package:get_10101/common/model.dart'; +import 'package:get_10101/common/snack_bar.dart'; +import 'package:get_10101/common/theme.dart'; +import 'package:get_10101/common/value_data_row.dart'; +import 'package:get_10101/services/new_order_service.dart'; +import 'package:get_10101/services/quote_service.dart'; +import 'package:get_10101/services/trade_constraints_service.dart'; +import 'package:get_10101/trade/collateral_slider.dart'; +import 'package:provider/provider.dart'; + +class CreateChannelConfirmationDialog extends StatefulWidget { + final Direction direction; + final Function() onConfirmation; + final Function() onCancel; + final BestQuote bestQuote; + final Amount? fee; + final Amount margin; + final Leverage leverage; + final Usd quantity; + + const CreateChannelConfirmationDialog( + {super.key, + required this.direction, + required this.onConfirmation, + required this.onCancel, + required this.bestQuote, + required this.fee, + required this.leverage, + required this.quantity, + required this.margin}); + + @override + State createState() => _CreateChannelConfirmationDialogState(); +} + +class _CreateChannelConfirmationDialogState extends State { + final TextEditingController _ownCollateralController = TextEditingController(); + + // TODO: Once we have one, fetch it from backend + Amount openingFee = Amount(0); + + final _formKey = GlobalKey(); + Amount _ownChannelCollateral = Amount.zero(); + Amount _counterpartyChannelCollateral = Amount.zero(); + + @override + void initState() { + super.initState(); + + TradeConstraintsChangeNotifier changeNotifier = context.read(); + + _ownChannelCollateral = widget.margin; + + changeNotifier.service.getTradeConstraints().then((value) { + setState(() { + _ownChannelCollateral = Amount(max(widget.margin.sats, value.minMarginSats)); + var coordinatorLeverage = value.coordinatorLeverage; + var counterpartyMargin = calculateMargin(widget.quantity, widget.bestQuote, + Leverage(coordinatorLeverage), widget.direction == Direction.short); + updateCounterpartyCollateral(counterpartyMargin, coordinatorLeverage); + _ownCollateralController.text = _ownChannelCollateral.formatted(); + }); + }); + } + + @override + void dispose() { + _ownCollateralController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + TradeConstraintsChangeNotifier changeNotifier = context.watch(); + + final messenger = ScaffoldMessenger.of(context); + + var tradeConstraints = changeNotifier.tradeConstraints; + + return Dialog( + insetPadding: const EdgeInsets.all(15), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10.0))), + child: SingleChildScrollView( + child: tradeConstraints == null + ? const CircularProgressIndicator() + : createDialogContent(tradeConstraints, context, messenger), + )); + } + + Widget createDialogContent( + TradeConstraints tradeConstraints, BuildContext context, ScaffoldMessengerState messenger) { + double coordinatorLeverage = tradeConstraints.coordinatorLeverage; + Amount counterpartyMargin = calculateMargin(widget.quantity, widget.bestQuote, + Leverage(coordinatorLeverage), widget.direction == Direction.short); + + final maxCounterpartyCollateral = Amount(tradeConstraints.maxCounterpartyMarginSats); + + final maxOnChainSpending = Amount(tradeConstraints.maxLocalMarginSats); + final counterpartyLeverage = tradeConstraints.coordinatorLeverage; + + final minMargin = Amount(max(tradeConstraints.minMarginSats, widget.margin.sats)); + + final orderMatchingFees = widget.fee ?? Amount.zero(); + + var estimatedFundingTxFeeSats = Amount(tradeConstraints.estimatedFundingTxFeeSats); + final Amount fundingTxFeeWithBuffer = Amount(estimatedFundingTxFeeSats.sats * 2); + + var channelFeeReserve = Amount(tradeConstraints.channelFeeReserveSats); + final maxUsableOnChainBalance = Amount.max( + maxOnChainSpending - orderMatchingFees - fundingTxFeeWithBuffer - channelFeeReserve, + Amount.zero()); + final maxCounterpartyCollateralSats = + (maxCounterpartyCollateral.sats * counterpartyLeverage).toInt(); + + final int collateralSliderMaxValue = + min(maxCounterpartyCollateralSats, maxUsableOnChainBalance.toInt); + + // if we don't have enough on-chain balance to pay for min margin, we set it to `minMargin` nevertheless and disable the slider + // this could be the case if we do not have enough money. + final int collateralSliderMinValue = minMargin.sats; + bool notEnoughOnchainBalance = false; + if (maxUsableOnChainBalance.sats < minMargin.sats) { + notEnoughOnchainBalance = true; + } + + return Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + width: 450, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + const ContractSymbolIcon(), + const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("DLC Channel Configuration", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 17)), + ], + ), + const SizedBox(height: 20), + Text( + "This is your first trade which will open a DLC Channel and opens your position.", + style: DefaultTextStyle.of(context).style), + const SizedBox(height: 10), + Text( + "Specify your preferred channel size, impacting how much you will be able to win up to.", + style: DefaultTextStyle.of(context).style), + const SizedBox(height: 20), + CollateralSlider( + onValueChanged: notEnoughOnchainBalance + ? null + : (newValue) { + var parsedAmount = Amount(newValue); + _ownCollateralController.text = parsedAmount.formatted(); + setState(() { + _ownChannelCollateral = parsedAmount; + updateCounterpartyCollateral( + counterpartyMargin, coordinatorLeverage); + }); + }, + minValue: collateralSliderMinValue, + maxValue: collateralSliderMaxValue, + labelText: 'Your collateral (sats)', + value: _ownChannelCollateral.sats, + ), + const SizedBox(height: 15), + AmountInputField( + enabled: !notEnoughOnchainBalance, + controller: _ownCollateralController, + label: 'Your collateral (sats)', + heightPadding: 40, + widthPadding: 20, + onChanged: (value) { + setState(() { + _ownChannelCollateral = Amount.parseAmount(value); + updateCounterpartyCollateral(counterpartyMargin, coordinatorLeverage); + _formKey.currentState!.validate(); + }); + }, + suffixIcon: TextButton( + onPressed: () { + setState(() { + _ownChannelCollateral = Amount( + min(maxCounterpartyCollateralSats, maxUsableOnChainBalance.sats)); + _ownCollateralController.text = _ownChannelCollateral.formatted(); + + updateCounterpartyCollateral(counterpartyMargin, coordinatorLeverage); + }); + }, + child: const Text( + "Max", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + validator: (value) { + if (_ownChannelCollateral.sats < minMargin.sats) { + return "Min collateral: $minMargin"; + } + + if ((_ownChannelCollateral + orderMatchingFees).sats > + maxOnChainSpending.sats) { + return "Max on-chain: $maxUsableOnChainBalance"; + } + + if (maxCounterpartyCollateral.sats < counterpartyMargin.sats) { + return "Over limit: $maxCounterpartyCollateral"; + } + + return null; + }, + ), + const SizedBox(height: 15), + AmountTextField( + value: Amount(min( + maxCounterpartyCollateralSats, _counterpartyChannelCollateral.sats)), + label: 'Win up to (sats)', + ), + const SizedBox( + height: 15, + ), + ValueDataRow( + type: ValueType.amount, + value: _ownChannelCollateral, + label: 'Your collateral'), + ValueDataRow( + type: ValueType.amount, + value: openingFee, + label: 'Channel-opening fee', + ), + ValueDataRow( + type: ValueType.amount, + value: widget.fee, + label: 'Order matching fee', + ), + ValueDataRow( + type: ValueType.amount, + value: estimatedFundingTxFeeSats, + label: 'Blockchain transaction fee estimate', + ), + ValueDataRow( + type: ValueType.amount, + value: channelFeeReserve, + label: 'Channel transaction fee reserve', + ), + const Divider(), + ValueDataRow( + type: ValueType.amount, + value: _ownChannelCollateral + + widget.fee! + + estimatedFundingTxFeeSats + + channelFeeReserve, + label: "Total"), + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.only(top: 20.0), + child: Visibility( + visible: notEnoughOnchainBalance, + replacement: RichText( + textAlign: TextAlign.justify, + text: TextSpan( + text: + 'By confirming, a market order will be created. Once the order is matched your channel will be opened and your position will be created.', + style: DefaultTextStyle.of(context).style)), + child: RichText( + textAlign: TextAlign.justify, + text: const TextSpan( + text: + 'You do not have enough balance in your on-chain wallet. Please fund it with at least 270,000 sats.', + style: TextStyle(color: TenTenOneTheme.red600))), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: () { + widget.onCancel(); + Navigator.pop(context); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey, fixedSize: const Size(100, 20)), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: notEnoughOnchainBalance + ? null + : () async { + await NewOrderService.postNewOrder( + widget.leverage, + widget.quantity, + widget.direction == Direction.long.opposite(), + channelOpeningParams: ChannelOpeningParams( + Amount.max( + Amount.zero(), + (_counterpartyChannelCollateral - + counterpartyMargin)), + Amount.max(Amount.zero(), + _ownChannelCollateral - widget.margin))) + .then((orderId) { + showSnackBar( + messenger, "Market order created. Order id: $orderId."); + Navigator.pop(context); + }).catchError((error) { + showSnackBar( + messenger, "Failed creating market order: $error."); + }).whenComplete(widget.onConfirmation); + }, + style: ElevatedButton.styleFrom(fixedSize: const Size(100, 20)), + child: const Text('Accept'), + ), + ], + ), + ), + ], + )) + ], + ), + ), + ), + ); + } + + void updateCounterpartyCollateral(Amount counterpartyMargin, double counterpartyLeverage) { + final collateral = (_ownChannelCollateral.sats.toDouble() / counterpartyLeverage).floor(); + _counterpartyChannelCollateral = Amount(collateral.toInt()); + } +} diff --git a/webapp/frontend/lib/trade/create_order_confirmation_dialog.dart b/webapp/frontend/lib/trade/create_order_confirmation_dialog.dart index 21d61fdae..849e383c5 100644 --- a/webapp/frontend/lib/trade/create_order_confirmation_dialog.dart +++ b/webapp/frontend/lib/trade/create_order_confirmation_dialog.dart @@ -5,8 +5,8 @@ import 'package:get_10101/common/model.dart'; import 'package:get_10101/common/snack_bar.dart'; import 'package:get_10101/common/theme.dart'; import 'package:get_10101/common/value_data_row.dart'; -import 'package:get_10101/trade/new_order_service.dart'; -import 'package:get_10101/trade/quote_service.dart'; +import 'package:get_10101/services/new_order_service.dart'; +import 'package:get_10101/services/quote_service.dart'; class CreateOrderConfirmationDialog extends StatelessWidget { final Direction direction; diff --git a/webapp/frontend/lib/trade/order_history_table.dart b/webapp/frontend/lib/trade/order_history_table.dart index c2ad04bd0..de7843dbb 100644 --- a/webapp/frontend/lib/trade/order_history_table.dart +++ b/webapp/frontend/lib/trade/order_history_table.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get_10101/common/color.dart'; import 'package:get_10101/common/direction.dart'; -import 'package:get_10101/trade/order_change_notifier.dart'; -import 'package:get_10101/trade/order_service.dart'; +import 'package:get_10101/change_notifier/order_change_notifier.dart'; +import 'package:get_10101/services/order_service.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; diff --git a/webapp/frontend/lib/trade/position_table.dart b/webapp/frontend/lib/trade/position_table.dart index 20c4290cc..8ffd85017 100644 --- a/webapp/frontend/lib/trade/position_table.dart +++ b/webapp/frontend/lib/trade/position_table.dart @@ -1,17 +1,17 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; +import 'package:get_10101/change_notifier/quote_change_notifier.dart'; import 'package:get_10101/common/amount_text.dart'; import 'package:get_10101/common/color.dart'; -import 'package:get_10101/common/currency_change_notifier.dart'; +import 'package:get_10101/change_notifier/currency_change_notifier.dart'; import 'package:get_10101/common/direction.dart'; import 'package:get_10101/common/model.dart'; -import 'package:get_10101/settings/channel_change_notifier.dart'; +import 'package:get_10101/change_notifier/channel_change_notifier.dart'; import 'package:get_10101/settings/dlc_channel.dart'; import 'package:get_10101/trade/close_position_confirmation_dialog.dart'; -import 'package:get_10101/trade/position_change_notifier.dart'; -import 'package:get_10101/trade/position_service.dart'; -import 'package:get_10101/trade/quote_change_notifier.dart'; -import 'package:get_10101/trade/quote_service.dart'; +import 'package:get_10101/change_notifier/position_change_notifier.dart'; +import 'package:get_10101/services/position_service.dart'; +import 'package:get_10101/services/quote_service.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:collection/collection.dart'; diff --git a/webapp/frontend/lib/trade/trade_screen_order_form.dart b/webapp/frontend/lib/trade/trade_screen_order_form.dart index 0c404ab42..b407282e7 100644 --- a/webapp/frontend/lib/trade/trade_screen_order_form.dart +++ b/webapp/frontend/lib/trade/trade_screen_order_form.dart @@ -1,14 +1,18 @@ import 'package:bitcoin_icons/bitcoin_icons.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:get_10101/change_notifier/channel_change_notifier.dart'; +import 'package:get_10101/change_notifier/quote_change_notifier.dart'; import 'package:get_10101/common/amount_text_input_form_field.dart'; +import 'package:get_10101/common/calculations.dart'; import 'package:get_10101/common/direction.dart'; import 'package:get_10101/common/model.dart'; import 'package:get_10101/common/theme.dart'; +import 'package:get_10101/settings/dlc_channel.dart'; +import 'package:get_10101/trade/create_channel_confirmation_dialog.dart'; import 'package:get_10101/trade/create_order_confirmation_dialog.dart'; import 'package:get_10101/trade/leverage_slider.dart'; -import 'package:get_10101/trade/quote_change_notifier.dart'; -import 'package:get_10101/trade/quote_service.dart'; +import 'package:get_10101/services/quote_service.dart'; import 'package:provider/provider.dart'; class NewOrderForm extends StatefulWidget { @@ -28,6 +32,7 @@ class _NewOrderForm extends State { Usd? _quantity = Usd(100); Leverage _leverage = Leverage(1); bool isBuy = true; + DlcChannel? _openChannel; final TextEditingController _marginController = TextEditingController(); final TextEditingController _liquidationPriceController = TextEditingController(); @@ -47,6 +52,7 @@ class _NewOrderForm extends State { final direction = isBuy ? Direction.long : Direction.short; _quote = context.watch().getBestQuote(); + _openChannel = context.watch().getOpenChannel(); updateOrderValues(); @@ -122,15 +128,31 @@ class _NewOrderForm extends State { showDialog( context: context, builder: (BuildContext context) { - return CreateOrderConfirmationDialog( - direction: direction, - onConfirmation: () {}, - onCancel: () {}, - bestQuote: _quote, - fee: fee, - leverage: _leverage, - quantity: _quantity ?? Usd.zero(), - ); + if (_openChannel != null) { + return CreateOrderConfirmationDialog( + direction: direction, + onConfirmation: () {}, + onCancel: () {}, + bestQuote: _quote, + fee: fee, + leverage: _leverage, + quantity: _quantity ?? Usd.zero(), + ); + } else { + return CreateChannelConfirmationDialog( + direction: direction, + onConfirmation: () {}, + onCancel: () {}, + bestQuote: _quote == null + ? BestQuote(ask: Price.zero(), bid: Price.zero(), fee: 0.0) + : _quote!, + fee: fee, + leverage: _leverage, + quantity: _quantity ?? Usd.zero(), + margin: (_quantity != null && _quote != null) + ? calculateMargin(_quantity!, _quote!, _leverage, isBuy) + : Amount.zero()); + } }); }, style: ElevatedButton.styleFrom( @@ -157,41 +179,3 @@ class _NewOrderForm extends State { } } } - -Amount calculateFee(Usd? quantity, BestQuote? quote, bool isLong) { - if (quote?.fee == null || quote?.fee == 0 || quantity == null) { - return Amount.zero(); - } - - return Amount( - (calculateMargin(quantity, quote!, Leverage.one(), isLong).sats * quote.fee!).toInt()); -} - -Amount calculateMargin(Usd quantity, BestQuote quote, Leverage leverage, bool isLong) { - if (isLong && quote.ask != null) { - if (quote.ask!.asDouble == 0) { - return Amount.zero(); - } - return Amount.fromBtc(quantity.asDouble / (quote.ask!.asDouble * leverage.asDouble)); - } else if (!isLong && quote.bid != null) { - if (quote.bid!.asDouble == 0) { - return Amount.zero(); - } - return Amount.fromBtc(quantity.asDouble / (quote.bid!.asDouble * leverage.asDouble)); - } else { - return Amount.zero(); - } -} - -Amount calculateLiquidationPrice( - Usd quantity, BestQuote quote, Leverage leverage, double maintenanceMargin, bool isLong) { - if (isLong && quote.ask != null) { - return Amount((quote.bid!.asDouble * leverage.asDouble) ~/ - (leverage.asDouble + 1.0 + (maintenanceMargin * leverage.asDouble))); - } else if (!isLong && quote.bid != null) { - return Amount((quote.ask!.asDouble * leverage.asDouble) ~/ - (leverage.asDouble - 1.0 + (maintenanceMargin * leverage.asDouble))); - } else { - return Amount.zero(); - } -} diff --git a/webapp/frontend/lib/wallet/history_screen.dart b/webapp/frontend/lib/wallet/history_screen.dart index 9a9e11c59..13cfaeef2 100644 --- a/webapp/frontend/lib/wallet/history_screen.dart +++ b/webapp/frontend/lib/wallet/history_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get_10101/wallet/onchain_payment_history_item.dart'; -import 'package:get_10101/wallet/wallet_change_notifier.dart'; -import 'package:get_10101/wallet/wallet_service.dart'; +import 'package:get_10101/change_notifier/wallet_change_notifier.dart'; +import 'package:get_10101/services/wallet_service.dart'; import 'package:provider/provider.dart'; class HistoryScreen extends StatefulWidget { diff --git a/webapp/frontend/lib/wallet/receive_screen.dart b/webapp/frontend/lib/wallet/receive_screen.dart index 9380290ed..ab29f196e 100644 --- a/webapp/frontend/lib/wallet/receive_screen.dart +++ b/webapp/frontend/lib/wallet/receive_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter/services.dart'; import 'package:get_10101/common/color.dart'; import 'package:get_10101/common/snack_bar.dart'; import 'package:get_10101/common/truncate_text.dart'; -import 'package:get_10101/wallet/wallet_change_notifier.dart'; +import 'package:get_10101/change_notifier/wallet_change_notifier.dart'; import 'package:provider/provider.dart'; import 'package:qr_flutter/qr_flutter.dart'; diff --git a/webapp/frontend/lib/wallet/send_screen.dart b/webapp/frontend/lib/wallet/send_screen.dart index 4db3f56fe..737d1ffd0 100644 --- a/webapp/frontend/lib/wallet/send_screen.dart +++ b/webapp/frontend/lib/wallet/send_screen.dart @@ -5,7 +5,7 @@ import 'package:get_10101/common/amount_text_input_form_field.dart'; import 'package:get_10101/common/color.dart'; import 'package:get_10101/common/snack_bar.dart'; import 'package:get_10101/common/text_input_field.dart'; -import 'package:get_10101/wallet/wallet_change_notifier.dart'; +import 'package:get_10101/change_notifier/wallet_change_notifier.dart'; import 'package:provider/provider.dart'; class SendScreen extends StatefulWidget { diff --git a/webapp/src/api.rs b/webapp/src/api.rs index 30422751e..1f9542922 100644 --- a/webapp/src/api.rs +++ b/webapp/src/api.rs @@ -21,6 +21,7 @@ use native::api::Direction; use native::api::Fee; use native::api::WalletHistoryItemType; use native::calculations::calculate_pnl; +use native::channel_trade_constraints; use native::ln_dlc; use native::ln_dlc::is_dlc_channel_confirmed; use native::trade::order::FailureReason; @@ -52,6 +53,7 @@ pub fn router(subscribers: Arc) -> Router { .route("/api/sync", post(post_sync)) .route("/api/seed", get(get_seed_phrase)) .route("/api/channels", get(get_channels).delete(close_channel)) + .route("/api/tradeconstraints", get(get_trade_constraints)) .with_state(subscribers) } @@ -192,13 +194,17 @@ pub struct OrderId { id: Uuid, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] pub struct NewOrderParams { #[serde(with = "rust_decimal::serde::float")] pub leverage: Decimal, #[serde(with = "rust_decimal::serde::float")] pub quantity: Decimal, pub direction: Direction, + #[serde(with = "bitcoin::amount::serde::as_sat::opt")] + pub coordinator_reserve: Option, + #[serde(with = "bitcoin::amount::serde::as_sat::opt")] + pub trader_reserve: Option, } impl TryFrom for native::trade::order::Order { @@ -231,6 +237,7 @@ impl TryFrom for native::trade::order::Order { pub async fn post_new_order(params: Json) -> Result, AppError> { let order: native::trade::order::Order = params + .clone() .0 .try_into() .context("Could not parse order request")?; @@ -241,9 +248,8 @@ pub async fn post_new_order(params: Json) -> Result FromStr::from_str(s).map_err(de::Error::custom).map(Some), } } + +#[derive(Serialize, Copy, Clone, Debug)] +pub struct TradeConstraints { + pub max_local_margin_sats: u64, + pub max_counterparty_margin_sats: u64, + pub coordinator_leverage: f32, + pub min_quantity: u64, + pub is_channel_balance: bool, + pub min_margin_sats: u64, + pub estimated_funding_tx_fee_sats: u64, + pub channel_fee_reserve_sats: u64, +} + +pub async fn get_trade_constraints() -> Result, AppError> { + let trade_constraints = channel_trade_constraints::channel_trade_constraints()?; + let fee = ln_dlc::estimated_funding_tx_fee()?; + let channel_fee_reserve = ln_dlc::estimated_fee_reserve()?; + Ok(Json(TradeConstraints { + max_local_margin_sats: trade_constraints.max_local_margin_sats, + max_counterparty_margin_sats: trade_constraints.max_counterparty_margin_sats, + coordinator_leverage: trade_constraints.coordinator_leverage, + min_quantity: trade_constraints.min_quantity, + is_channel_balance: trade_constraints.is_channel_balance, + min_margin_sats: trade_constraints.min_margin, + estimated_funding_tx_fee_sats: fee.to_sat(), + channel_fee_reserve_sats: channel_fee_reserve.to_sat(), + })) +}