diff --git a/coordinator/src/trade/mod.rs b/coordinator/src/trade/mod.rs index 321290c7f..8c8eccd1e 100644 --- a/coordinator/src/trade/mod.rs +++ b/coordinator/src/trade/mod.rs @@ -393,7 +393,6 @@ impl TradeExecutor { let contract_input = ContractInput { offer_collateral, - accept_collateral, fee_rate, contract_infos: vec![ContractInputInfo { diff --git a/crates/tests-e2e/src/test_subscriber.rs b/crates/tests-e2e/src/test_subscriber.rs index 5f62abf3c..e4bbba466 100644 --- a/crates/tests-e2e/src/test_subscriber.rs +++ b/crates/tests-e2e/src/test_subscriber.rs @@ -191,6 +191,9 @@ impl Senders { native::event::EventInternal::DlcChannelEvent(_) => { // ignored } + native::event::EventInternal::FundingChannelNotification(_) => { + // ignored + } } Ok(()) } diff --git a/crates/xxi-node/src/dlc_wallet.rs b/crates/xxi-node/src/dlc_wallet.rs index 1b235b859..3032675be 100644 --- a/crates/xxi-node/src/dlc_wallet.rs +++ b/crates/xxi-node/src/dlc_wallet.rs @@ -258,6 +258,8 @@ impl dlc_manager::Wallet for DlcWallet(); + let mut coin_selector = CoinSelector::new(&candidates, base_weight_wu as u32); let dust_limit = 0; @@ -280,7 +282,7 @@ impl dlc_manager::Wallet for DlcWallet Result> { + let txs = self.get_utxo_for_address(address).await?; + let mut statuses = vec![]; + for tx in txs { + if let Some(index) = tx + .vout + .iter() + .position(|vout| vout.scriptpubkey == address.script_pubkey()) + { + match self.get_status_for_vout(&tx.txid, index as u64).await { + Ok(Some(status)) => { + if status.spent { + tracing::warn!( + txid = tx.txid.to_string(), + vout = index, + "Ignoring output as it is already spent" + ) + } else { + let amount = + Amount::from_sat(tx.vout.get(index).expect("to exist").value); + statuses.push((tx, amount)); + } + } + Ok(None) => { + tracing::warn!( + txid = tx.txid.to_string(), + vout = index, + "No status found for tx" + ); + } + Err(error) => { + tracing::error!( + txid = tx.txid.to_string(), + vout = index, + "Failed at checking tx status {error:?}" + ); + } + } + } else { + tracing::error!( + txid = tx.txid.to_string(), + address = address.to_string(), + "Output not found. This should not happen, but if it does, it indicates something is wrong."); + } + } + Ok(statuses) + } + + async fn get_utxo_for_address(&self, address: &Address) -> Result> { + let vec = self + .blockchain + .esplora_client_async + .scripthash_txs(&address.script_pubkey(), None) + .await?; + Ok(vec) + } + + async fn get_status_for_vout(&self, tx_id: &Txid, vout: u64) -> Result> { + let status = self + .blockchain + .esplora_client_async + .get_output_status(tx_id, vout) + .await?; + Ok(status) + } } async fn update_fee_rate_estimates( diff --git a/mobile/assets/coming_soon.png b/mobile/assets/coming_soon.png new file mode 100644 index 000000000..cd5c432ba Binary files /dev/null and b/mobile/assets/coming_soon.png differ diff --git a/mobile/lib/common/application/event_service.dart b/mobile/lib/common/application/event_service.dart index d2b04c2ac..48adbe109 100644 --- a/mobile/lib/common/application/event_service.dart +++ b/mobile/lib/common/application/event_service.dart @@ -35,6 +35,7 @@ class EventService { subscribers[eventType] = List.empty(growable: true); } + logger.i("Subscribed: $subscriber for event: $event $eventType"); subscribers[eventType]!.add(subscriber); } } diff --git a/mobile/lib/common/bitcoin_balance_field.dart b/mobile/lib/common/bitcoin_balance_field.dart new file mode 100644 index 000000000..950aa0efe --- /dev/null +++ b/mobile/lib/common/bitcoin_balance_field.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:get_10101/common/color.dart'; +import 'package:get_10101/common/domain/model.dart'; +import 'package:get_10101/features/wallet/application/util.dart'; + +class BitcoinBalanceField extends StatelessWidget { + final Amount bitcoinBalance; + final double? fontSize; + + const BitcoinBalanceField({super.key, required this.bitcoinBalance, this.fontSize = 28.0}); + + @override + Widget build(BuildContext context) { + var (leading, balance) = getFormattedBalance(bitcoinBalance.toInt); + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(leading, + style: TextStyle( + color: Colors.grey, + fontSize: fontSize, + fontWeight: FontWeight.bold, + )), + Text(balance, + style: TextStyle( + color: Colors.black87, + fontSize: fontSize, + fontWeight: FontWeight.bold, + )), + Icon(Icons.currency_bitcoin, size: fontSize, color: tenTenOnePurple), + ], + ); + } +} diff --git a/mobile/lib/common/custom_qr_code.dart b/mobile/lib/common/custom_qr_code.dart new file mode 100644 index 000000000..a5f148d17 --- /dev/null +++ b/mobile/lib/common/custom_qr_code.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +class CustomQrCode extends StatelessWidget { + final String data; + final ImageProvider embeddedImage; + final double embeddedImageSizeWidth; + final double embeddedImageSizeHeight; + final double dimension; + + const CustomQrCode({ + Key? key, + required this.data, + required this.embeddedImage, + this.dimension = 350.0, + this.embeddedImageSizeHeight = 50, + this.embeddedImageSizeWidth = 50, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: dimension, + child: QrImageView( + data: data, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: Colors.black, + ), + dataModuleStyle: const QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: Colors.black, + ), + embeddedImage: embeddedImage, + embeddedImageStyle: QrEmbeddedImageStyle( + size: Size(embeddedImageSizeHeight, embeddedImageSizeWidth), + ), + version: QrVersions.auto, + padding: const EdgeInsets.all(5), + ), + ); + } +} diff --git a/mobile/lib/common/domain/funding_channel_task.dart b/mobile/lib/common/domain/funding_channel_task.dart new file mode 100644 index 000000000..e9d617951 --- /dev/null +++ b/mobile/lib/common/domain/funding_channel_task.dart @@ -0,0 +1,48 @@ +import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; + +enum FundingChannelTaskStatus { + pending, + funded, + orderCreated, + failed; + + static (FundingChannelTaskStatus, String?) fromApi(dynamic taskStatus) { + if (taskStatus is bridge.FundingChannelTask_Pending) { + return (FundingChannelTaskStatus.pending, null); + } + + if (taskStatus is bridge.FundingChannelTask_Funded) { + return (FundingChannelTaskStatus.funded, null); + } + + if (taskStatus is bridge.FundingChannelTask_Failed) { + final error = taskStatus.field0; + return (FundingChannelTaskStatus.failed, error); + } + + if (taskStatus is bridge.FundingChannelTask_OrderCreated) { + final orderId = taskStatus.field0; + return (FundingChannelTaskStatus.orderCreated, orderId); + } + + return (FundingChannelTaskStatus.pending, null); + } + + static bridge.FundingChannelTask apiDummy() { + return const bridge.FundingChannelTask_Pending(); + } + + @override + String toString() { + switch (this) { + case FundingChannelTaskStatus.pending: + return "Pending"; + case FundingChannelTaskStatus.failed: + return "Failed"; + case FundingChannelTaskStatus.funded: + return "Funded"; + case FundingChannelTaskStatus.orderCreated: + return "OrderCreated"; + } + } +} diff --git a/mobile/lib/common/funding_channel_task_change_notifier.dart b/mobile/lib/common/funding_channel_task_change_notifier.dart new file mode 100644 index 000000000..2f7242029 --- /dev/null +++ b/mobile/lib/common/funding_channel_task_change_notifier.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:get_10101/common/application/event_service.dart'; +import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; +import 'package:get_10101/common/domain/funding_channel_task.dart'; +import 'package:get_10101/logger/logger.dart'; + +class FundingChannelChangeNotifier extends ChangeNotifier implements Subscriber { + FundingChannelTaskStatus? status; + String? error; + + @override + void notify(bridge.Event event) async { + if (event is bridge.Event_FundingChannelNotification) { + logger.d("Received a funding channel task notification. ${event.field0}"); + var fromApi = FundingChannelTaskStatus.fromApi(event.field0); + status = fromApi.$1; + error = fromApi.$2; + notifyListeners(); + } + } +} diff --git a/mobile/lib/common/init_service.dart b/mobile/lib/common/init_service.dart index bf85a15e4..f772f2984 100644 --- a/mobile/lib/common/init_service.dart +++ b/mobile/lib/common/init_service.dart @@ -4,7 +4,9 @@ import 'package:get_10101/common/background_task_change_notifier.dart'; import 'package:get_10101/common/dlc_channel_change_notifier.dart'; import 'package:get_10101/common/dlc_channel_service.dart'; import 'package:get_10101/common/domain/dlc_channel.dart'; +import 'package:get_10101/common/domain/funding_channel_task.dart'; import 'package:get_10101/common/domain/tentenone_config.dart'; +import 'package:get_10101/common/funding_channel_task_change_notifier.dart'; import 'package:get_10101/features/brag/meme_service.dart'; import 'package:get_10101/features/trade/order_change_notifier.dart'; import 'package:get_10101/features/trade/position_change_notifier.dart'; @@ -54,6 +56,7 @@ List createProviders() { ChangeNotifierProvider(create: (context) => ServiceStatusNotifier()), ChangeNotifierProvider(create: (context) => DlcChannelChangeNotifier(dlcChannelService)), ChangeNotifierProvider(create: (context) => BackgroundTaskChangeNotifier()), + ChangeNotifierProvider(create: (context) => FundingChannelChangeNotifier()), ChangeNotifierProvider(create: (context) => TenTenOneConfigChangeNotifier(channelInfoService)), ChangeNotifierProvider(create: (context) => PollChangeNotifier(pollService)), Provider(create: (context) => config), @@ -78,6 +81,7 @@ void subscribeToNotifiers(BuildContext context) { final tradeValuesChangeNotifier = context.read(); final serviceStatusNotifier = context.read(); final backgroundTaskChangeNotifier = context.read(); + final fundingChannelChangeNotifier = context.read(); final tentenoneConfigChangeNotifier = context.read(); final dlcChannelChangeNotifier = context.read(); @@ -111,6 +115,9 @@ void subscribeToNotifiers(BuildContext context) { eventService.subscribe( backgroundTaskChangeNotifier, bridge.Event.backgroundNotification(BackgroundTask.apiDummy())); + eventService.subscribe(fundingChannelChangeNotifier, + bridge.Event.fundingChannelNotification(FundingChannelTaskStatus.apiDummy())); + eventService.subscribe( tentenoneConfigChangeNotifier, bridge.Event.authenticated(TenTenOneConfig.apiDummy())); diff --git a/mobile/lib/common/routes.dart b/mobile/lib/common/routes.dart index 955e6508c..ba873a019 100644 --- a/mobile/lib/common/routes.dart +++ b/mobile/lib/common/routes.dart @@ -6,6 +6,8 @@ import 'package:get_10101/common/settings/user_screen.dart'; import 'package:get_10101/common/settings/wallet_settings.dart'; import 'package:get_10101/common/status_screen.dart'; import 'package:get_10101/common/background_task_dialog_screen.dart'; +import 'package:get_10101/features/trade/channel_creation_flow/channel_configuration_screen.dart'; +import 'package:get_10101/features/trade/channel_creation_flow/channel_funding_screen.dart'; import 'package:get_10101/features/wallet/domain/destination.dart'; import 'package:get_10101/features/wallet/send/send_onchain_screen.dart'; import 'package:get_10101/features/welcome/error_screen.dart'; @@ -225,6 +227,28 @@ GoRouter createRoutes() { ), ], ), + GoRoute( + path: ChannelConfigurationScreen.route, + builder: (BuildContext context, GoRouterState state) { + final data = state.extra! as Map; + return ChannelConfigurationScreen( + direction: data["direction"], + ); + }, + routes: [ + GoRoute( + path: ChannelFundingScreen.subRouteName, + builder: (BuildContext context, GoRouterState state) { + final data = state.extra! as Map; + return ChannelFundingScreen( + amount: data["amount"], + address: data["address"], + ); + }, + routes: const [], + ) + ], + ) ]), ]); } diff --git a/mobile/lib/common/task_status_dialog.dart b/mobile/lib/common/task_status_dialog.dart index 1e3ddda3b..de4f1a240 100644 --- a/mobile/lib/common/task_status_dialog.dart +++ b/mobile/lib/common/task_status_dialog.dart @@ -93,7 +93,10 @@ class _TaskStatusDialog extends State { width: MediaQuery.of(context).size.width * 0.65, child: ElevatedButton( onPressed: () { - GoRouter.of(context).pop(); + var goRouter = GoRouter.of(context); + if (goRouter.canPop()) { + goRouter.pop(); + } if (widget.onClose != null) { widget.onClose!(); diff --git a/mobile/lib/features/trade/application/order_service.dart b/mobile/lib/features/trade/application/order_service.dart index ca3b73a0a..3b7890074 100644 --- a/mobile/lib/features/trade/application/order_service.dart +++ b/mobile/lib/features/trade/application/order_service.dart @@ -41,6 +41,36 @@ class OrderService { traderReserve: traderReserve.sats); } + // starts a process to watch for funding an address before creating the order + // returns the address to watch for + Future submitUnfundedChannelOpeningMarketOrder( + Leverage leverage, + Usd quantity, + ContractSymbol contractSymbol, + Direction direction, + bool stable, + Amount coordinatorReserve, + Amount traderReserve, + Amount margin) async { + rust.NewOrder order = rust.NewOrder( + leverage: leverage.leverage, + quantity: quantity.asDouble(), + contractSymbol: contractSymbol.toApi(), + direction: direction.toApi(), + orderType: const rust.OrderType.market(), + stable: stable); + + var address = await rust.api.getNewAddress(); + + await rust.api.submitUnfundedChannelOpeningOrder( + fundingAddress: address, + order: order, + coordinatorReserve: coordinatorReserve.sats, + traderReserve: traderReserve.sats, + estimatedMargin: margin.sats); + return address; + } + Future> fetchOrders() async { List apiOrders = await rust.api.getOrders(); List orders = apiOrders.map((order) => Order.fromApi(order)).toList(); diff --git a/mobile/lib/features/trade/channel_configuration.dart b/mobile/lib/features/trade/channel_configuration.dart index 15de0c5a4..3db97d542 100644 --- a/mobile/lib/features/trade/channel_configuration.dart +++ b/mobile/lib/features/trade/channel_configuration.dart @@ -280,8 +280,8 @@ class _ChannelConfiguration extends State { ? () { GoRouter.of(context).pop(); widget.onConfirmation(ChannelOpeningParams( - coordinatorCollateral: counterpartyCollateral, - traderCollateral: ownTotalCollateral)); + coordinatorReserve: counterpartyCollateral, + traderReserve: ownTotalCollateral)); } : null, style: ElevatedButton.styleFrom( diff --git a/mobile/lib/features/trade/channel_creation_flow/channel_configuration_screen.dart b/mobile/lib/features/trade/channel_creation_flow/channel_configuration_screen.dart new file mode 100644 index 000000000..762b471aa --- /dev/null +++ b/mobile/lib/features/trade/channel_creation_flow/channel_configuration_screen.dart @@ -0,0 +1,513 @@ +import 'dart:math'; + +import 'package:flutter/gestures.dart'; +import 'package:get_10101/common/dlc_channel_change_notifier.dart'; +import 'package:get_10101/common/dlc_channel_service.dart'; +import 'package:flutter/material.dart'; +import 'package:get_10101/common/application/tentenone_config_change_notifier.dart'; +import 'package:get_10101/common/color.dart'; +import 'package:get_10101/common/domain/model.dart'; +import 'package:get_10101/common/snack_bar.dart'; +import 'package:get_10101/common/value_data_row.dart'; +import 'package:get_10101/features/trade/channel_creation_flow/channel_funding_screen.dart'; +import 'package:get_10101/features/trade/channel_creation_flow/custom_framed_container.dart'; +import 'package:get_10101/features/trade/channel_creation_flow/fee_expansion_widget.dart'; +import 'package:get_10101/features/trade/domain/channel_opening_params.dart'; +import 'package:get_10101/features/trade/domain/direction.dart'; +import 'package:get_10101/features/trade/domain/leverage.dart'; +import 'package:get_10101/features/trade/domain/trade_values.dart'; +import 'package:get_10101/features/trade/submit_order_change_notifier.dart'; +import 'package:get_10101/features/trade/trade_screen.dart'; +import 'package:get_10101/features/trade/trade_theme.dart'; +import 'package:get_10101/features/trade/trade_value_change_notifier.dart'; +import 'package:get_10101/logger/logger.dart'; +import 'package:get_10101/util/constants.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:slide_to_confirm/slide_to_confirm.dart'; +import 'package:syncfusion_flutter_core/theme.dart' as slider_theme; +import 'package:syncfusion_flutter_sliders/sliders.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class ChannelConfigurationScreen extends StatelessWidget { + static const route = "/channelconfiguration"; + static const label = "Channel Configuration"; + final Direction direction; + + const ChannelConfigurationScreen({super.key, required this.direction}); + + @override + Widget build(BuildContext context) { + final tradeValues = context.read().fromDirection(direction); + return ChannelConfiguration(tradeValues: tradeValues); + } +} + +class ChannelConfiguration extends StatefulWidget { + final TradeValues tradeValues; + + const ChannelConfiguration({super.key, required this.tradeValues}); + + @override + State createState() => _ChannelConfiguration(); +} + +class _ChannelConfiguration extends State { + final TextEditingController _collateralController = TextEditingController(); + + late final TenTenOneConfigChangeNotifier tentenoneConfigChangeNotifier; + late final DlcChannelChangeNotifier dlcChannelChangeNotifier; + + bool useInnerWallet = false; + + Amount minMargin = Amount.zero(); + Amount counterpartyMargin = Amount.zero(); + Amount ownTotalCollateral = Amount.zero(); + Amount counterpartyCollateral = Amount.zero(); + + double counterpartyLeverage = 1; + + Amount maxOnChainSpending = Amount.zero(); + Amount maxCounterpartyCollateral = Amount.zero(); + + Amount orderMatchingFees = Amount.zero(); + + Amount channelFeeReserve = Amount.zero(); + + Amount fundingTxFee = Amount.zero(); + + final _formKey = GlobalKey(); + + Usd quantity = Usd.zero(); + + Leverage leverage = Leverage(0); + + double liquidationPrice = 0.0; + + bool fundWithWalletEnabled = true; + + Amount maxUsableOnChainBalance = Amount.zero(); + int maxCounterpartyCollateralSats = 0; + + Amount fundingTxFeeWithBuffer = Amount.zero(); + + @override + void initState() { + super.initState(); + + tentenoneConfigChangeNotifier = context.read(); + var tradeConstraints = tentenoneConfigChangeNotifier.channelInfoService.getTradeConstraints(); + + DlcChannelService dlcChannelService = + context.read().dlcChannelService; + + quantity = widget.tradeValues.quantity; + leverage = widget.tradeValues.leverage; + liquidationPrice = widget.tradeValues.liquidationPrice ?? 0.0; + + maxCounterpartyCollateral = Amount(tradeConstraints.maxCounterpartyBalanceSats); + + maxOnChainSpending = Amount(tradeConstraints.maxLocalBalanceSats); + counterpartyLeverage = tradeConstraints.coordinatorLeverage; + + counterpartyMargin = widget.tradeValues.calculateMargin(Leverage(counterpartyLeverage)); + + minMargin = Amount(max(tradeConstraints.minMargin, widget.tradeValues.margin?.sats ?? 0)); + + ownTotalCollateral = tradeConstraints.minMargin > widget.tradeValues.margin!.sats + ? Amount(tradeConstraints.minMargin) + : widget.tradeValues.margin!; + + _collateralController.text = ownTotalCollateral.formatted(); + + orderMatchingFees = widget.tradeValues.fee ?? Amount.zero(); + + updateCounterpartyCollateral(); + + channelFeeReserve = dlcChannelService.getEstimatedChannelFeeReserve(); + + fundingTxFee = dlcChannelService.getEstimatedFundingTxFee(); + + // We add a buffer because the `fundingTxFee` is just an estimate. This + // estimate will undershoot if we end up using more inputs or change + // outputs. + fundingTxFeeWithBuffer = Amount(fundingTxFee.sats * 2); + + maxUsableOnChainBalance = + maxOnChainSpending - orderMatchingFees - fundingTxFeeWithBuffer - channelFeeReserve; + + maxCounterpartyCollateralSats = (maxCounterpartyCollateral.sats * counterpartyLeverage).toInt(); + + fundWithWalletEnabled = maxUsableOnChainBalance.sats >= ownTotalCollateral.sats; + + // We add this so that the confirmation slider can be enabled immediately + // _if_ the form is already valid. Otherwise we have to wait for the user to + // interact with the form. + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _formKey.currentState?.validate(); + }); + }); + } + + @override + Widget build(BuildContext context) { + TradeTheme tradeTheme = Theme.of(context).extension()!; + + final tradeValueChangeNotifier = context.read(); + final submitOrderChangeNotifier = context.read(); + + Color confirmationSliderColor = + widget.tradeValues.direction == Direction.long ? tradeTheme.buy : tradeTheme.sell; + + Amount orderMatchingFee = + tradeValueChangeNotifier.orderMatchingFee(widget.tradeValues.direction) ?? Amount.zero(); + + Amount totalFee = orderMatchingFee.add(fundingTxFee).add(channelFeeReserve); + Amount totalAmountToBeFunded = ownTotalCollateral.add(totalFee); + + return Scaffold( + body: SafeArea( + child: Form( + key: _formKey, + child: Container( + padding: const EdgeInsets.only(top: 20, left: 15, right: 15), + child: Column( + children: [ + Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Stack( + children: [ + GestureDetector( + child: Container( + alignment: AlignmentDirectional.topStart, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(10)), + width: 70, + child: const Icon( + Icons.arrow_back_ios_new_rounded, + size: 22, + )), + onTap: () { + GoRouter.of(context).go(TradeScreen.route); + }, + ), + const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Fund Channel", + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 20), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + Center( + child: Text.rich( + textAlign: TextAlign.justify, + TextSpan( + children: [ + TextSpan( + text: + "🔔 This is your first trade and you do not have a channel yet. " + "In 10101 we use DLC-Channels for fast and low-cost off-chain trading. " + "You can read more about this technology ", + style: DefaultTextStyle.of(context).style, + ), + TextSpan( + text: 'here.', + style: const TextStyle( + color: tenTenOnePurple, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () async { + final httpsUri = Uri( + scheme: 'https', + host: '10101.finance', + path: '/blog/dlc-channels-demystified'); + + canLaunchUrl(httpsUri).then((canLaunch) async { + if (canLaunch) { + launchUrl(httpsUri, mode: LaunchMode.externalApplication); + } else { + showSnackBar( + ScaffoldMessenger.of(context), "Failed to open link"); + } + }); + }, + ), + ], + ), + ), + ), + ], + ), + Expanded( + child: Container(), + ), + Column( + children: [ + CustomFramedContainer( + text: 'Channel size', + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(top: 5), + child: slider_theme.SfSliderTheme( + data: slider_theme.SfSliderThemeData( + activeLabelStyle: + const TextStyle(color: Colors.black, fontSize: 12), + inactiveLabelStyle: + const TextStyle(color: Colors.black, fontSize: 12), + activeTrackColor: tenTenOnePurple.shade50, + inactiveTrackColor: tenTenOnePurple.shade50, + tickOffset: const Offset(0.0, 10.0), + ), + child: SfSlider( + // TODO(bonomat): don't hard code this value + min: 250000, + max: maxCounterpartyCollateralSats, + value: ownTotalCollateral.sats, + stepSize: 100000, + interval: 10000, + showTicks: false, + showLabels: true, + enableTooltip: true, + labelFormatterCallback: + (dynamic actualValue, String formattedText) { + if (actualValue == 250000) { + return "Min"; + } + + if (actualValue == maxCounterpartyCollateralSats) { + return "Max"; + } + + return ""; + }, + tooltipShape: const SfPaddleTooltipShape(), + tooltipTextFormatterCallback: + (dynamic actualValue, String formattedText) { + return "${(actualValue as double).toInt()} sats"; + }, + onChanged: (dynamic value) { + setState(() { + if (value < minMargin.sats) { + value = minMargin.sats.toDouble(); + } + + ownTotalCollateral = Amount((value as double).toInt()); + fundWithWalletEnabled = + maxUsableOnChainBalance.sats >= ownTotalCollateral.sats; + if (!fundWithWalletEnabled) { + useInnerWallet = false; + } + + updateCounterpartyCollateral(); + }); + }, + ), + ), + ), + Padding( + padding: + const EdgeInsets.only(top: 15, bottom: 5, left: 10, right: 10), + child: ValueDataRow( + type: ValueType.amount, + value: counterpartyCollateral, + label: 'Win up to'), + ) + ], + )), + CustomFramedContainer( + text: 'Order details', + child: Padding( + padding: const EdgeInsets.only(top: 15, bottom: 5, left: 10, right: 10), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ValueDataRow( + type: ValueType.fiat, + value: quantity.asDouble(), + label: 'Quantity'), + ValueDataRow( + type: ValueType.text, + value: leverage.formatted(), + label: 'Leverage'), + ValueDataRow( + type: ValueType.fiat, + value: liquidationPrice, + label: 'Liquidation price'), + ], + ), + )), + CustomFramedContainer( + text: 'Order cost', + child: Padding( + padding: const EdgeInsets.only(top: 15, bottom: 5, left: 10, right: 10), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ValueDataRow( + type: ValueType.amount, + value: ownTotalCollateral, + label: 'Channel size'), + FeeExpansionTile( + label: "Fee*", + value: totalFee, + orderMatchingFee: orderMatchingFee, + fundingTxFee: fundingTxFee, + channelFeeReserve: channelFeeReserve), + const Divider(), + ValueDataRow( + type: ValueType.amount, + value: totalAmountToBeFunded, + label: "Total"), + ], + ), + )), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Checkbox( + key: tradeScreenBottomSheetChannelConfigurationFundWithWalletCheckBox, + value: useInnerWallet, + onChanged: fundWithWalletEnabled + ? (bool? value) { + setState(() { + useInnerWallet = value ?? false; + }); + } + : null, + ), + Text( + "Fund with internal 10101 wallet", + style: + TextStyle(color: fundWithWalletEnabled ? Colors.black : Colors.grey), + ) + ], + ), + Visibility( + visible: useInnerWallet, + replacement: Padding( + padding: const EdgeInsets.only(top: 1, left: 8, right: 8, bottom: 8), + child: ElevatedButton( + key: tradeScreenBottomSheetChannelConfigurationConfirmButton, + onPressed: _formKey.currentState != null && + _formKey.currentState!.validate() + ? () async { + logger.d( + "Submitting an order with ownTotalCollateral: $ownTotalCollateral orderMatchingFee: $orderMatchingFee, fundingTxFee: $fundingTxFee, channelFeeReserve: $channelFeeReserve, counterpartyCollateral: $counterpartyCollateral, ownMargin: ${widget.tradeValues.margin}"); + + await submitOrderChangeNotifier + .submitUnfundedOrder( + widget.tradeValues, + ChannelOpeningParams( + coordinatorReserve: counterpartyCollateral, + traderReserve: ownTotalCollateral)) + .then((address) { + GoRouter.of(context).push(ChannelFundingScreen.route, extra: { + "address": address, + "amount": totalAmountToBeFunded + }); + }).onError((error, stackTrace) { + logger.e("Failed at submitting unfunded order $error"); + final messenger = ScaffoldMessenger.of(context); + showSnackBar( + messenger, "Failed creating order ${error.toString()}"); + }); + } + : null, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(50), + backgroundColor: tenTenOnePurple), + child: const Text( + "Confirm", + style: TextStyle(color: Colors.white), + ), + ), + ), + child: Padding( + padding: const EdgeInsets.only(top: 1, left: 8, right: 8, bottom: 8), + child: ConfirmationSlider( + key: tradeScreenBottomSheetChannelConfigurationConfirmSlider, + text: "Swipe to confirm ${widget.tradeValues.direction.nameU}", + textStyle: TextStyle(color: confirmationSliderColor), + height: 40, + foregroundColor: confirmationSliderColor, + sliderButtonContent: const Icon( + Icons.chevron_right, + color: Colors.white, + size: 20, + ), + onConfirmation: () async { + logger.d("Submitting new order with " + "quantity: ${widget.tradeValues.quantity}, " + "leverage: ${widget.tradeValues.leverage.formatted()}, " + "direction: ${widget.tradeValues.direction}, " + "liquidationPrice: ${widget.tradeValues.liquidationPrice}, " + "margin: ${widget.tradeValues.margin}, " + "ownTotalCollateral: $ownTotalCollateral, " + "counterpartyCollateral: $counterpartyCollateral, " + ""); + submitOrderChangeNotifier + .submitOrder(widget.tradeValues, + channelOpeningParams: ChannelOpeningParams( + coordinatorReserve: counterpartyCollateral, + traderReserve: ownTotalCollateral)) + .onError((error, stackTrace) { + logger.e("Failed creating new channel due to $error"); + final messenger = ScaffoldMessenger.of(context); + showSnackBar(messenger, "Failed creating order ${e.toString()}"); + }).then((ignored) => GoRouter.of(context).go(TradeScreen.route)); + }, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + void updateCounterpartyCollateral() { + final collateral = (ownTotalCollateral.sats / counterpartyLeverage).floor(); + counterpartyCollateral = + counterpartyMargin.sats > collateral ? counterpartyMargin : Amount(collateral); + } +} + +formatNumber(dynamic myNumber) { + // Convert number into a string if it was not a string previously + String stringNumber = myNumber.toString(); + + // Convert number into double to be formatted. + // Default to zero if unable to do so + double doubleNumber = double.tryParse(stringNumber) ?? 0; + + // Set number format to use + NumberFormat numberFormat = NumberFormat.compact(); + + return numberFormat.format(doubleNumber); +} + +int roundToNearestThousand(int value) { + return ((value + 500) ~/ 1000) * 1000; +} diff --git a/mobile/lib/features/trade/channel_creation_flow/channel_funding_screen.dart b/mobile/lib/features/trade/channel_creation_flow/channel_funding_screen.dart new file mode 100644 index 000000000..f71df8d4a --- /dev/null +++ b/mobile/lib/features/trade/channel_creation_flow/channel_funding_screen.dart @@ -0,0 +1,452 @@ +import 'package:bitcoin_icons/bitcoin_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get_10101/common/bitcoin_balance_field.dart'; +import 'package:get_10101/common/color.dart'; +import 'package:get_10101/common/custom_qr_code.dart'; +import 'package:get_10101/common/domain/funding_channel_task.dart'; +import 'package:get_10101/common/domain/model.dart'; +import 'package:get_10101/common/funding_channel_task_change_notifier.dart'; +import 'package:get_10101/common/snack_bar.dart'; +import 'package:get_10101/features/trade/channel_creation_flow/channel_configuration_screen.dart'; +import 'package:get_10101/features/trade/trade_screen.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +// TODO: Fetch from backend. +Amount openingFee = Amount(0); + +class ChannelFundingScreen extends StatelessWidget { + static const route = "${ChannelConfigurationScreen.route}/$subRouteName"; + static const subRouteName = "fund_tx"; + final Amount amount; + final String address; + + const ChannelFundingScreen({ + super.key, + required this.amount, + required this.address, + }); + + @override + Widget build(BuildContext context) { + return ChannelFunding( + amount: amount, + address: address, + ); + } +} + +enum FundinType { + lightning, + onchain, + unified, + external, +} + +class ChannelFunding extends StatefulWidget { + final Amount amount; + final String address; + + const ChannelFunding({super.key, required this.amount, required this.address}); + + @override + State createState() => _ChannelFunding(); +} + +class _ChannelFunding extends State { + FundinType selectedBox = FundinType.onchain; + + @override + Widget build(BuildContext context) { + String address = widget.address; + // TODO: creating a bip21 qr code should be generic once we support other desposit methods + String qcCodeContent = "bitcoin:$address?amount=${widget.amount.btc.toString()}"; + + var qrCode = CustomQrCode( + data: qcCodeContent, + embeddedImage: const AssetImage("assets/10101_logo_icon_white_background.png"), + dimension: 300, + ); + + if (selectedBox != FundinType.onchain) { + qcCodeContent = "Follow us on Twitter for news: @get10101"; + + qrCode = CustomQrCode( + data: qcCodeContent, + embeddedImage: const AssetImage("assets/coming_soon.png"), + embeddedImageSizeHeight: 350, + embeddedImageSizeWidth: 350, + dimension: 300, + ); + } + + return Scaffold( + body: SafeArea( + child: Container( + padding: const EdgeInsets.only(top: 20, left: 10, right: 10), + child: Column( + children: [ + Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Stack( + children: [ + GestureDetector( + child: Container( + alignment: AlignmentDirectional.topStart, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(10)), + width: 70, + child: const Icon( + Icons.arrow_back_ios_new_rounded, + size: 22, + )), + onTap: () { + GoRouter.of(context).pop(); + }, + ), + const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Fund Channel", + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 20), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + ], + ), + // QR code and content field + Column( + children: [ + Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(0, 20, 0, 20), + padding: const EdgeInsets.only(top: 10, left: 0, right: 0), + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey, width: 1), + borderRadius: BorderRadius.circular(20), + shape: BoxShape.rectangle, + ), + child: Center( + child: Column( + children: [ + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: widget.amount.btc.toString())) + .then((_) { + showSnackBar(ScaffoldMessenger.of(context), + "Copied amount: ${widget.amount}"); + }); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + BitcoinBalanceField(bitcoinBalance: widget.amount), + ], + ), + ), + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: qcCodeContent)).then((_) { + showSnackBar(ScaffoldMessenger.of(context), + "Address copied: $qcCodeContent"); + }); + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: qrCode, + ), + ), + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.only(left: 10.0, right: 10.0), + child: GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: address)).then((_) { + showSnackBar(ScaffoldMessenger.of(context), + "Address copied: $address"); + }); + }, + child: Text( + address, + style: const TextStyle(fontSize: 14), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); + }, + ), + const SizedBox( + height: 10, + ), + ], + ), + ), + ) + ], + ), + // information text about the tx status + Expanded( + child: Selector( + selector: (_, provider) { + return provider.status; + }, builder: (BuildContext context, value, Widget? child) { + return buildInfoBox(value, selectedBox); + })), + Padding( + padding: const EdgeInsets.only(top: 1, left: 8, right: 8, bottom: 8), + child: Selector( + selector: (_, provider) { + return provider.status; + }, builder: (BuildContext context, value, Widget? child) { + if (value case FundingChannelTaskStatus.orderCreated) { + return ElevatedButton( + onPressed: () { + GoRouter.of(context).go(TradeScreen.route); + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(50), + backgroundColor: tenTenOnePurple), + child: const Text( + "Home", + style: TextStyle(color: Colors.white), + ), + ); + } else { + return buildButtonRow(); + } + })), + ], + ), + ), + ), + ); + } + + Row buildButtonRow() { + return Row( + children: [ + Expanded( + child: ClickableBox( + text: "Unified", + image: const Icon(BitcoinIcons.bitcoin_circle_outline), + isSelected: selectedBox == FundinType.unified, + onTap: () { + setState(() { + selectedBox = FundinType.unified; + }); + }, + ), + ), + Expanded( + child: ClickableBox( + text: "Lightning", + image: const Icon(BitcoinIcons.lightning_outline), + isSelected: selectedBox == FundinType.lightning, + onTap: () { + setState(() { + selectedBox = FundinType.lightning; + }); + }, + ), + ), + Expanded( + child: ClickableBox( + text: "On-chain", + image: const Icon(BitcoinIcons.link_outline), + isSelected: selectedBox == FundinType.onchain, + onTap: () { + setState(() { + selectedBox = FundinType.onchain; + }); + }, + ), + ), + Expanded( + child: ClickableBox( + text: "External", + image: const Icon(BitcoinIcons.wallet), + isSelected: selectedBox == FundinType.external, + onTap: () { + setState(() { + selectedBox = FundinType.external; + }); + }, + ), + ) + ], + ); + } + + Column buildInfoBox(FundingChannelTaskStatus? value, FundinType selectedBox) { + String transactionStatusText = "Waiting for payment..."; + String transactionStatusInformationText = + "Please wait. If you leave now, your position won’t be opened when the funds arrive."; + + Widget loadingWidget = Container(); + + switch (selectedBox) { + case FundinType.onchain: + switch (value) { + case null: + case FundingChannelTaskStatus.pending: + loadingWidget = const RotatingIcon(icon: Icons.sync); + break; + case FundingChannelTaskStatus.funded: + transactionStatusText = "Address funded"; + loadingWidget = const RotatingIcon(icon: BitcoinIcons.bitcoin); + break; + case FundingChannelTaskStatus.orderCreated: + transactionStatusText = "Order successfully created"; + transactionStatusInformationText = ""; + loadingWidget = const Icon( + Icons.check, + size: 20.0, + color: tenTenOnePurple, + ); + break; + case FundingChannelTaskStatus.failed: + loadingWidget = const Icon( + Icons.error, + size: 20.0, + color: tenTenOnePurple, + ); + break; + } + default: + break; + } + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(transactionStatusText), + loadingWidget, + ], + ), + const SizedBox( + height: 5, + ), + Text( + transactionStatusInformationText, + textAlign: TextAlign.center, + ) + ], + ); + } +} + +class ClickableBox extends StatelessWidget { + final String text; + final Widget image; + final bool isSelected; + final VoidCallback onTap; + + const ClickableBox({ + Key? key, + required this.text, + required this.image, + required this.isSelected, + required this.onTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: isSelected ? tenTenOnePurple.shade100 : Colors.transparent, + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.only(left: 10, right: 10, top: 2, bottom: 2), + child: Column( + children: [ + image, + const SizedBox(height: 1), + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return FittedBox( + fit: BoxFit.scaleDown, + child: Text( + text, + style: const TextStyle( + color: Colors.black, + fontSize: 16, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + }, + ) + ], + ), + ), + ); + } +} + +class RotatingIcon extends StatefulWidget { + final IconData icon; + + const RotatingIcon({super.key, required this.icon}); + + @override + State createState() => _RotatingIconState(); +} + +class _RotatingIconState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(seconds: 2), + vsync: this, + )..repeat(); // Repeats the animation indefinitely + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RotationTransition( + turns: _controller, + child: Icon( + widget.icon, + size: 20.0, + color: tenTenOnePurple, + ), + ); + } +} diff --git a/mobile/lib/features/trade/channel_creation_flow/custom_framed_container.dart b/mobile/lib/features/trade/channel_creation_flow/custom_framed_container.dart new file mode 100644 index 000000000..9e00d9fc9 --- /dev/null +++ b/mobile/lib/features/trade/channel_creation_flow/custom_framed_container.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +class CustomFramedContainer extends StatelessWidget { + final String text; + final Widget child; + + const CustomFramedContainer({super.key, required this.text, required this.child}); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(0, 20, 0, 5), + padding: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey, width: 1), + borderRadius: BorderRadius.circular(20), + shape: BoxShape.rectangle, + ), + child: child, + ), + Positioned( + left: 15, + top: 12, + child: Container( + padding: const EdgeInsets.only(bottom: 10, left: 10, right: 10), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: FractionalOffset.topCenter, + end: FractionalOffset.bottomCenter, + stops: const [0.28, 0.28], + colors: [ + Colors.transparent, + Colors.grey.shade100, + ], + ), + borderRadius: BorderRadius.circular(5), + shape: BoxShape.rectangle, + ), + child: Text( + text, + style: const TextStyle(color: Colors.black, fontSize: 12), + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/features/trade/channel_creation_flow/fee_expansion_widget.dart b/mobile/lib/features/trade/channel_creation_flow/fee_expansion_widget.dart new file mode 100644 index 000000000..0b346ae67 --- /dev/null +++ b/mobile/lib/features/trade/channel_creation_flow/fee_expansion_widget.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:get_10101/common/domain/model.dart'; +import 'package:get_10101/common/value_data_row.dart'; + +class FeeExpansionTile extends StatefulWidget { + final String label; + final Amount value; + final Amount orderMatchingFee; + final Amount fundingTxFee; + final Amount channelFeeReserve; + + const FeeExpansionTile({ + super.key, + required this.label, + required this.value, + required this.orderMatchingFee, + required this.fundingTxFee, + required this.channelFeeReserve, + }); + + @override + State createState() => _FeeExpansionTileState(); +} + +class _FeeExpansionTileState extends State with SingleTickerProviderStateMixin { + bool _isExpanded = false; + late AnimationController _controller; + late Animation _expandAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + _expandAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _toggleExpand() { + setState(() { + _isExpanded = !_isExpanded; + if (_isExpanded) { + _controller.forward(); + } else { + _controller.reverse(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + GestureDetector( + onTap: _toggleExpand, + child: ValueDataRow(type: ValueType.amount, value: widget.value, label: 'Fee*'), + ), + SizeTransition( + sizeFactor: _expandAnimation, + child: Container( + padding: const EdgeInsets.only(top: 4.0, left: 8.0, right: 8), + color: Colors.grey[100], // You can change this to match your design + child: SingleChildScrollView( + child: GestureDetector( + onTap: _toggleExpand, + child: Column( + children: [ + ValueDataRow( + type: ValueType.amount, + value: widget.orderMatchingFee, + label: 'Order matching fee'), + ValueDataRow( + type: ValueType.amount, + value: widget.fundingTxFee, + label: 'Funding tx fee'), + ValueDataRow( + type: ValueType.amount, + value: widget.channelFeeReserve, + label: 'Channel reserve fee') + ], + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/features/trade/domain/channel_opening_params.dart b/mobile/lib/features/trade/domain/channel_opening_params.dart index abb14845c..a687159aa 100644 --- a/mobile/lib/features/trade/domain/channel_opening_params.dart +++ b/mobile/lib/features/trade/domain/channel_opening_params.dart @@ -1,8 +1,8 @@ import 'package:get_10101/common/domain/model.dart'; class ChannelOpeningParams { - Amount coordinatorCollateral; - Amount traderCollateral; + Amount coordinatorReserve; + Amount traderReserve; - ChannelOpeningParams({required this.coordinatorCollateral, required this.traderCollateral}); + ChannelOpeningParams({required this.coordinatorReserve, required this.traderReserve}); } diff --git a/mobile/lib/features/trade/submit_order_change_notifier.dart b/mobile/lib/features/trade/submit_order_change_notifier.dart index 111b5671b..98b8ff82a 100644 --- a/mobile/lib/features/trade/submit_order_change_notifier.dart +++ b/mobile/lib/features/trade/submit_order_change_notifier.dart @@ -17,16 +17,17 @@ class SubmitOrderChangeNotifier extends ChangeNotifier { SubmitOrderChangeNotifier(this.orderService); - submitOrder(TradeValues tradeValues, {ChannelOpeningParams? channelOpeningParams}) async { + Future submitOrder(TradeValues tradeValues, + {ChannelOpeningParams? channelOpeningParams}) async { try { if (channelOpeningParams != null) { // TODO(holzeis): The coordinator leverage should not be hard coded here. final coordinatorCollateral = tradeValues.calculateMargin(Leverage(2.0)); final coordinatorReserve = - max(0, channelOpeningParams.coordinatorCollateral.sub(coordinatorCollateral).sats); + max(0, channelOpeningParams.coordinatorReserve.sub(coordinatorCollateral).sats); final traderReserve = - max(0, channelOpeningParams.traderCollateral.sub(tradeValues.margin!).sats); + max(0, channelOpeningParams.traderReserve.sub(tradeValues.margin!).sats); await orderService.submitChannelOpeningMarketOrder( tradeValues.leverage, @@ -45,6 +46,33 @@ class SubmitOrderChangeNotifier extends ChangeNotifier { } } + Future submitUnfundedOrder( + TradeValues tradeValues, ChannelOpeningParams channelOpeningParams) async { + try { + // TODO(holzeis): The coordinator leverage should not be hard coded here. + final coordinatorCollateral = tradeValues.calculateMargin(Leverage(2.0)); + + final coordinatorReserve = + max(0, channelOpeningParams.coordinatorReserve.sub(coordinatorCollateral).sats); + final traderReserve = + max(0, channelOpeningParams.traderReserve.sub(tradeValues.margin!).sats); + + return orderService.submitUnfundedChannelOpeningMarketOrder( + tradeValues.leverage, + tradeValues.contracts, + ContractSymbol.btcusd, + tradeValues.direction, + false, + Amount(coordinatorReserve), + Amount(traderReserve), + tradeValues.margin!, + ); + } on FfiException catch (exception) { + logger.e("Failed to submit order: $exception"); + rethrow; + } + } + Future closePosition(Position position, double? closingPrice, Amount? fee) async { final tradeValues = TradeValues( direction: position.direction.opposite(), diff --git a/mobile/lib/features/trade/trade_bottom_sheet_confirmation.dart b/mobile/lib/features/trade/trade_bottom_sheet_confirmation.dart index 8ae6b830a..e79f8003a 100644 --- a/mobile/lib/features/trade/trade_bottom_sheet_confirmation.dart +++ b/mobile/lib/features/trade/trade_bottom_sheet_confirmation.dart @@ -94,7 +94,7 @@ tradeBottomSheetConfirmation( sliderKey: sliderKey, onConfirmation: onConfirmation, tradeAction: tradeAction, - traderCollateral: channelOpeningParams?.traderCollateral, + traderCollateral: channelOpeningParams?.traderReserve, channelFeeReserve: channelFeeReserve, fundingTxFee: fundingTxFee, )), diff --git a/mobile/lib/features/trade/trade_bottom_sheet_tab.dart b/mobile/lib/features/trade/trade_bottom_sheet_tab.dart index 711c19bc5..ee628eddb 100644 --- a/mobile/lib/features/trade/trade_bottom_sheet_tab.dart +++ b/mobile/lib/features/trade/trade_bottom_sheet_tab.dart @@ -8,7 +8,7 @@ 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/common/usd_text_field.dart'; -import 'package:get_10101/features/trade/channel_configuration.dart'; +import 'package:get_10101/features/trade/channel_creation_flow/channel_configuration_screen.dart'; import 'package:get_10101/features/trade/domain/channel_opening_params.dart'; import 'package:get_10101/ffi.dart' as rust; import 'package:get_10101/common/value_data_row.dart'; @@ -22,6 +22,7 @@ import 'package:get_10101/features/trade/submit_order_change_notifier.dart'; import 'package:get_10101/features/trade/trade_bottom_sheet_confirmation.dart'; import 'package:get_10101/features/trade/trade_theme.dart'; import 'package:get_10101/features/trade/trade_value_change_notifier.dart'; +import 'package:get_10101/util/constants.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; @@ -153,22 +154,10 @@ class _TradeBottomSheetTabState extends State switch (tradeAction) { case TradeAction.openChannel: { - final tradeValues = - context.read().fromDirection(direction); - channelConfiguration( - context: context, - tradeValues: tradeValues, - onConfirmation: (ChannelOpeningParams channelOpeningParams) { - tradeBottomSheetConfirmation( - context: context, - direction: direction, - tradeAction: tradeAction, - onConfirmation: () => onConfirmation( - submitOrderChangeNotifier, tradeValues, channelOpeningParams), - channelOpeningParams: channelOpeningParams, - ); - }, - ); + Navigator.pop(context); + + GoRouter.of(context).go(ChannelConfigurationScreen.route, + extra: {"direction": direction}); break; } case TradeAction.trade: @@ -243,6 +232,7 @@ class _TradeBottomSheetTabState extends State selector: (_, provider) => provider.fromDirection(direction).price ?? 0, builder: (context, price, child) { return UsdTextField( + key: tradeButtonSheetMarketPrice, value: Usd.fromDouble(price), label: "Market Price (USD)", ); @@ -252,6 +242,7 @@ class _TradeBottomSheetTabState extends State children: [ Flexible( child: AmountInputField( + key: tradeButtonSheetQuantityInput, controller: quantityController, suffixIcon: TextButton( onPressed: () { @@ -314,6 +305,7 @@ class _TradeBottomSheetTabState extends State provider.fromDirection(direction).margin ?? Amount.zero(), builder: (context, margin, child) { return AmountTextField( + key: tradeButtonSheetMarginField, value: margin, label: "Margin (sats)", ); diff --git a/mobile/lib/features/trade/trade_screen.dart b/mobile/lib/features/trade/trade_screen.dart index fc13a17d2..e6c132b39 100644 --- a/mobile/lib/features/trade/trade_screen.dart +++ b/mobile/lib/features/trade/trade_screen.dart @@ -61,6 +61,7 @@ class TradeScreen extends StatelessWidget { return provider.getAskPrice(); }, builder: (context, price, child) { return LatestPriceWidget( + innerKey: tradeScreenAskPrice, label: "Latest Ask: ", price: Usd.fromDouble(price ?? 0.0), ); @@ -69,6 +70,7 @@ class TradeScreen extends StatelessWidget { return provider.getBidPrice(); }, builder: (context, price, child) { return LatestPriceWidget( + innerKey: tradeScreenBidPrice, label: "Latest Bid: ", price: Usd.fromDouble(price ?? 0.0), ); @@ -261,12 +263,15 @@ class TradeScreen extends StatelessWidget { class LatestPriceWidget extends StatelessWidget { final Usd price; final String label; + final Key innerKey; - const LatestPriceWidget({super.key, required this.price, required this.label}); + const LatestPriceWidget( + {super.key, required this.price, required this.label, required this.innerKey}); @override Widget build(BuildContext context) { return RichText( + key: innerKey, text: TextSpan( text: label, style: DefaultTextStyle.of(context).style, diff --git a/mobile/lib/features/wallet/balance.dart b/mobile/lib/features/wallet/balance.dart index 5ee0762f7..43785b6f3 100644 --- a/mobile/lib/features/wallet/balance.dart +++ b/mobile/lib/features/wallet/balance.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:get_10101/common/bitcoin_balance_field.dart'; import 'package:get_10101/common/color.dart'; import 'package:get_10101/common/domain/model.dart'; import 'package:get_10101/features/trade/domain/contract_symbol.dart'; import 'package:get_10101/features/trade/position_change_notifier.dart'; -import 'package:get_10101/features/wallet/application/util.dart'; import 'package:get_10101/features/wallet/balance_row.dart'; import 'package:get_10101/features/wallet/domain/wallet_type.dart'; import 'package:get_10101/features/wallet/wallet_change_notifier.dart'; @@ -22,30 +22,11 @@ class Balance extends StatelessWidget { total = total.add(position.getAmountWithUnrealizedPnl()); } - var (leading, balance) = getFormattedBalance(total.toInt); - return Theme( data: Theme.of(context).copyWith(dividerColor: Colors.transparent), child: Column( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(leading, - style: const TextStyle( - color: Colors.grey, - fontSize: 28.0, - fontWeight: FontWeight.bold, - )), - Text(balance, - style: const TextStyle( - color: Colors.black87, - fontSize: 28.0, - fontWeight: FontWeight.bold, - )), - const Icon(Icons.currency_bitcoin, size: 28, color: tenTenOnePurple), - ], - ), + BitcoinBalanceField(bitcoinBalance: total), const SizedBox( height: 20, ), diff --git a/mobile/lib/features/wallet/receive_screen.dart b/mobile/lib/features/wallet/receive_screen.dart index fda5e5f60..14f50d3ce 100644 --- a/mobile/lib/features/wallet/receive_screen.dart +++ b/mobile/lib/features/wallet/receive_screen.dart @@ -8,6 +8,7 @@ import 'package:get_10101/common/custom_app_bar.dart'; import 'package:get_10101/common/amount_text.dart'; import 'package:get_10101/common/application/switch.dart'; import 'package:get_10101/common/color.dart'; +import 'package:get_10101/common/custom_qr_code.dart'; import 'package:get_10101/common/dlc_channel_change_notifier.dart'; import 'package:get_10101/common/domain/model.dart'; import 'package:get_10101/common/scrollable_safe_area.dart'; @@ -22,7 +23,6 @@ import 'package:get_10101/logger/logger.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; -import 'package:qr_flutter/qr_flutter.dart'; import 'package:share_plus/share_plus.dart'; class ReceiveScreen extends StatefulWidget { @@ -87,54 +87,37 @@ class _ReceiveScreenState extends State { onDoubleTap: config.network == "regtest" ? () => setState(() => _faucet = !_faucet) : null, child: Center( - child: _faucet - ? Column( - children: [ - const SizedBox(height: 125), - OutlinedButton( - onPressed: _isPayInvoiceButtonDisabled - ? null - : () async { - setState(() => _isPayInvoiceButtonDisabled = true); - final faucetService = context.read(); - faucetService - .payInvoiceWithFaucet(rawInvoice(), amount) - .catchError((error) { - setState(() => _isPayInvoiceButtonDisabled = false); - showSnackBar(ScaffoldMessenger.of(context), error.toString()); - }).then((value) => context.go(WalletScreen.route)); - }, - style: ElevatedButton.styleFrom( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(5.0))), + child: _faucet + ? Column( + children: [ + const SizedBox(height: 125), + OutlinedButton( + onPressed: _isPayInvoiceButtonDisabled + ? null + : () async { + setState(() => _isPayInvoiceButtonDisabled = true); + final faucetService = context.read(); + faucetService + .payInvoiceWithFaucet(rawInvoice(), amount) + .catchError((error) { + setState(() => _isPayInvoiceButtonDisabled = false); + showSnackBar(ScaffoldMessenger.of(context), error.toString()); + }).then((value) => context.go(WalletScreen.route)); + }, + style: ElevatedButton.styleFrom( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5.0))), + ), + child: const Text("Pay with 10101 faucet"), ), - child: const Text("Pay with 10101 faucet"), - ), - const SizedBox(height: 125), - ], - ) - : SizedBox.square( - dimension: 350, - child: QrImageView( + const SizedBox(height: 125), + ], + ) + : CustomQrCode( data: rawInvoice(), - eyeStyle: const QrEyeStyle( - eyeShape: QrEyeShape.square, - color: Colors.black, - ), - dataModuleStyle: const QrDataModuleStyle( - dataModuleShape: QrDataModuleShape.square, - color: Colors.black, - ), embeddedImage: - const AssetImage('assets/10101_logo_icon_white_background.png'), - embeddedImageStyle: const QrEmbeddedImageStyle( - size: Size(50, 50), - ), - version: QrVersions.auto, - padding: const EdgeInsets.all(5), - ), - ), - ), + const AssetImage("assets/10101_logo_icon_white_background.png"), + )), ), ), Container( diff --git a/mobile/lib/features/wallet/wallet_change_notifier.dart b/mobile/lib/features/wallet/wallet_change_notifier.dart index bdc7eb349..72ea8e453 100644 --- a/mobile/lib/features/wallet/wallet_change_notifier.dart +++ b/mobile/lib/features/wallet/wallet_change_notifier.dart @@ -27,7 +27,6 @@ class WalletChangeNotifier extends ChangeNotifier implements Subscriber { this.walletInfo = walletInfo; syncing = false; - logger.t('Successfully synced payment history'); super.notifyListeners(); } diff --git a/mobile/lib/util/constants.dart b/mobile/lib/util/constants.dart index 4364751da..0e902403b 100644 --- a/mobile/lib/util/constants.dart +++ b/mobile/lib/util/constants.dart @@ -31,7 +31,8 @@ const _buy = "buy"; const _sell = "sell"; const _positions = "positions"; const _orders = "orders"; -const _configureChannel = "configure_channel"; +const _confirmationButton = "confirmation_button"; +const _confirmationSlider = "confirmation_slider"; const _openChannel = "open_channel"; const tradeScreenTabsOrders = Key(_trade + _tabs + _orders); @@ -46,9 +47,6 @@ const tradeScreenBottomSheetTabsSell = Key(_trade + _bottomSheet + _tabs + _sell const tradeScreenBottomSheetButtonBuy = Key(_trade + _bottomSheet + _button + _buy); const tradeScreenBottomSheetButtonSell = Key(_trade + _bottomSheet + _button + _sell); -const tradeScreenBottomSheetChannelConfigurationConfirmButton = - Key(_trade + _bottomSheet + _configureChannel); - const tradeScreenBottomSheetConfirmationConfigureChannelSlider = Key(_trade + _bottomSheet + _confirmSheet + _channelConfig + _slider + _openChannel); @@ -65,3 +63,24 @@ const tradeScreenBottomSheetConfirmationSliderButtonSell = const tabStable = Key(_tabs + _stable); const tabWallet = Key(_tabs + _wallet); const tabTrade = Key(_tabs + _trade); + +const _ask = "ask"; +const _bid = "bid"; +const _marketPrice = "marketPrice"; +const _quantityInput = "quantityInput"; +const _marginField = "marginField"; + +const tradeScreenAskPrice = Key(_trade + _tabs + _ask); +const tradeScreenBidPrice = Key(_trade + _tabs + _bid); + +const tradeButtonSheetMarketPrice = Key(_trade + _tabs + _bottomSheet + _marketPrice); +const tradeButtonSheetQuantityInput = Key(_trade + _tabs + _bottomSheet + _quantityInput); +const tradeButtonSheetMarginField = Key(_trade + _tabs + _bottomSheet + _marginField); + +const tradeScreenBottomSheetChannelConfigurationConfirmButton = + Key(_trade + _channelConfig + _confirmationButton); + +const tradeScreenBottomSheetChannelConfigurationConfirmSlider = + Key(_trade + _channelConfig + _confirmationSlider); +const tradeScreenBottomSheetChannelConfigurationFundWithWalletCheckBox = + Key(_trade + _channelConfig + _confirmationSlider); diff --git a/mobile/native/src/api.rs b/mobile/native/src/api.rs index ab6323bcd..a9f913fd1 100644 --- a/mobile/native/src/api.rs +++ b/mobile/native/src/api.rs @@ -29,10 +29,12 @@ use crate::trade::order::api::Order; use crate::trade::position; use crate::trade::position::api::Position; use crate::trade::users; +use crate::unfunded_orders; use anyhow::ensure; use anyhow::Context; use anyhow::Result; use bdk::FeeRate; +use bitcoin::Address; use bitcoin::Amount; use flutter_rust_bridge::StreamSink; use flutter_rust_bridge::SyncReturn; @@ -44,6 +46,7 @@ use std::backtrace::Backtrace; use std::fmt; use std::path::Path; use std::path::PathBuf; +use std::str::FromStr; use std::time::Duration; use time::OffsetDateTime; use tokio::sync::broadcast; @@ -513,6 +516,10 @@ pub fn get_new_address() -> Result { dlc::get_new_address() } +pub fn get_unused_address() -> Result { + dlc::get_unused_address() +} + #[tokio::main(flavor = "current_thread")] pub async fn close_channel() -> Result<()> { event::publish(&EventInternal::BackgroundNotification( @@ -895,3 +902,25 @@ impl From for ReferralStatus { pub fn has_traded_once() -> Result> { Ok(SyncReturn(!db::get_all_trades()?.is_empty())) } + +#[tokio::main(flavor = "current_thread")] +pub async fn submit_unfunded_channel_opening_order( + funding_address: String, + order: NewOrder, + coordinator_reserve: u64, + trader_reserve: u64, + estimated_margin: u64, +) -> Result<()> { + let funding_address = Address::from_str(funding_address.as_str())?.assume_checked(); + + unfunded_orders::submit_unfunded_wallet_channel_opening_order( + funding_address, + order, + coordinator_reserve, + trader_reserve, + estimated_margin + trader_reserve, + ) + .await?; + + Ok(()) +} diff --git a/mobile/native/src/dlc/node.rs b/mobile/native/src/dlc/node.rs index 9053974bb..4066d03e2 100644 --- a/mobile/native/src/dlc/node.rs +++ b/mobile/native/src/dlc/node.rs @@ -21,6 +21,7 @@ use dlc_messages::channel::OfferChannel; use dlc_messages::channel::Reject; use dlc_messages::channel::RenewOffer; use dlc_messages::channel::SettleOffer; +use futures::future::RemoteHandle; use lightning::chain::transaction::OutPoint; use lightning::sign::DelayedPaymentOutputDescriptor; use lightning::sign::SpendableOutputDescriptor; @@ -74,6 +75,8 @@ pub struct Node { // TODO: we should make this persistent as invoices might get paid later - but for now this is // good enough pub pending_usdp_invoices: Arc>>, + + pub unfunded_order_handle: Arc>>>, } impl Node { @@ -91,6 +94,7 @@ impl Node { inner: node, _running: Arc::new(running), pending_usdp_invoices: Arc::new(Default::default()), + unfunded_order_handle: Arc::new(Default::default()), } } } diff --git a/mobile/native/src/event/api.rs b/mobile/native/src/event/api.rs index e675cf798..004a484c1 100644 --- a/mobile/native/src/event/api.rs +++ b/mobile/native/src/event/api.rs @@ -30,6 +30,7 @@ pub enum Event { BackgroundNotification(BackgroundTask), Authenticated(TenTenOneConfig), DlcChannelEvent(DlcChannel), + FundingChannelNotification(FundingChannelTask), } #[frb] @@ -88,6 +89,9 @@ impl From for Event { EventInternal::BidPriceUpdateNotification(price) => { Event::BidPriceUpdateNotification(price.to_f32().expect("to fit")) } + EventInternal::FundingChannelNotification(status) => { + Event::FundingChannelNotification(status.into()) + } } } } @@ -125,6 +129,7 @@ impl Subscriber for FlutterSubscriber { EventType::ServiceHealthUpdate, EventType::ChannelStatusUpdate, EventType::BackgroundNotification, + EventType::FundingChannelNotification, EventType::PaymentClaimed, EventType::PaymentSent, EventType::PaymentFailed, @@ -190,3 +195,25 @@ pub struct Balances { pub on_chain: u64, pub off_chain: Option, } + +#[frb] +#[derive(Clone)] +pub enum FundingChannelTask { + Pending, + Funded, + Failed(String), + OrderCreated(String), +} + +impl From for FundingChannelTask { + fn from(value: event::FundingChannelTask) -> Self { + match value { + event::FundingChannelTask::Pending => FundingChannelTask::Pending, + event::FundingChannelTask::Funded => FundingChannelTask::Funded, + event::FundingChannelTask::Failed(reason) => FundingChannelTask::Failed(reason), + event::FundingChannelTask::OrderCreated(order_id) => { + FundingChannelTask::OrderCreated(order_id) + } + } + } +} diff --git a/mobile/native/src/event/mod.rs b/mobile/native/src/event/mod.rs index 183992712..ac9d09253 100644 --- a/mobile/native/src/event/mod.rs +++ b/mobile/native/src/event/mod.rs @@ -39,8 +39,16 @@ pub enum EventInternal { BackgroundNotification(BackgroundTask), SpendableOutputs, DlcChannelEvent(DlcChannel), + FundingChannelNotification(FundingChannelTask), } +#[derive(Clone, Debug)] +pub enum FundingChannelTask { + Pending, + Funded, + Failed(String), + OrderCreated(String), +} #[derive(Clone, Debug)] pub enum BackgroundTask { Liquidate(TaskStatus), @@ -76,6 +84,7 @@ impl fmt::Display for EventInternal { EventInternal::DlcChannelEvent(_) => "DlcChannelEvent", EventInternal::AskPriceUpdateNotification(_) => "AskPriceUpdateNotification", EventInternal::BidPriceUpdateNotification(_) => "BidPriceUpdateNotification", + EventInternal::FundingChannelNotification(_) => "FundingChannelNotification", } .fmt(f) } @@ -99,6 +108,7 @@ impl From for EventType { EventInternal::DlcChannelEvent(_) => EventType::DlcChannelEvent, EventInternal::AskPriceUpdateNotification(_) => EventType::AskPriceUpdateNotification, EventInternal::BidPriceUpdateNotification(_) => EventType::BidPriceUpdateNotification, + EventInternal::FundingChannelNotification(_) => EventType::FundingChannelNotification, } } } @@ -124,4 +134,5 @@ pub enum EventType { DlcChannelEvent, AskPriceUpdateNotification, BidPriceUpdateNotification, + FundingChannelNotification, } diff --git a/mobile/native/src/lib.rs b/mobile/native/src/lib.rs index 6cca0be69..14b88614f 100644 --- a/mobile/native/src/lib.rs +++ b/mobile/native/src/lib.rs @@ -12,6 +12,7 @@ pub mod logger; pub mod schema; pub mod state; pub mod trade; +pub mod unfunded_orders; mod backup; mod cipher; diff --git a/mobile/native/src/max_quantity.rs b/mobile/native/src/max_quantity.rs index 4d8a97517..dcfd53cef 100644 --- a/mobile/native/src/max_quantity.rs +++ b/mobile/native/src/max_quantity.rs @@ -5,6 +5,7 @@ use crate::dlc; use crate::trade::position; use bitcoin::Amount; use bitcoin::SignedAmount; +use rust_decimal::prelude::FromPrimitive; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use std::cmp::max; @@ -12,13 +13,11 @@ use xxi_node::commons; use xxi_node::commons::Direction; use xxi_node::commons::Price; -/// 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. +/// Calculates the max quantity +/// +/// The max quantity a user can trade is the lower value of either: +/// a) The max coordinator margin which is restricted to a certain max amount. +/// b) The max trader margin which is off-chain balance if a channel already exists. pub fn max_quantity( price: Decimal, trader_leverage: f32, @@ -40,7 +39,21 @@ pub fn max_quantity( let max_coordinator_balance = Amount::from_sat(channel_trade_constraints.max_counterparty_balance_sats); - let max_trader_balance = Amount::from_sat(channel_trade_constraints.max_local_balance_sats); + + // If the trader has a channel, his max balance is the channel balance and we continue, + // otherwise we can return here as the max amount to trade depends on what the coordinator can + // provide + let max_trader_balance = if channel_trade_constraints.is_channel_balance { + Amount::from_sat(channel_trade_constraints.max_local_balance_sats) + } else { + return Ok(Decimal::from_f32(calculations::calculate_quantity( + price.to_f32().expect("to fit"), + max_coordinator_balance.to_sat(), + channel_trade_constraints.coordinator_leverage, + )) + .expect("to fit")); + }; + let order_matching_fee_rate = channel_trade_constraints.order_matching_fee_rate; let order_matching_fee_rate = Decimal::try_from(order_matching_fee_rate).expect("to fit"); @@ -126,7 +139,7 @@ pub fn max_quantity( Ok(max_quantity) } -/// Calculates the max quantity for the given input parameters. If an on-chai fee estimate is +/// Calculates the max quantity. If an on-chain 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. diff --git a/mobile/native/src/unfunded_orders.rs b/mobile/native/src/unfunded_orders.rs new file mode 100644 index 000000000..e099a48da --- /dev/null +++ b/mobile/native/src/unfunded_orders.rs @@ -0,0 +1,117 @@ +use crate::event; +use crate::event::EventInternal; +use crate::event::FundingChannelTask; +use crate::state; +use crate::trade::order; +use crate::trade::order::api::NewOrder; +use anyhow::Result; +use bitcoin::Address; +use bitcoin::Amount; +use futures::FutureExt; +use std::time::Duration; +use xxi_node::commons::ChannelOpeningParams; + +pub(crate) async fn submit_unfunded_wallet_channel_opening_order( + funding_address: Address, + new_order: NewOrder, + coordinator_reserve: u64, + trader_reserve: u64, + needed_channel_size: u64, +) -> Result<()> { + let node = state::get_node().clone(); + let bdk_node = node.inner.clone(); + event::publish(&EventInternal::FundingChannelNotification( + FundingChannelTask::Pending, + )); + let runtime = crate::state::get_or_create_tokio_runtime()?; + let (future, remote_handle) = async move { + loop { + match bdk_node.get_unspent_txs(&funding_address).await { + Ok(ref v) if v.is_empty() => { + tracing::debug!( + address = funding_address.to_string(), + amount = needed_channel_size.to_string(), + "No tx found for address" + ); + } + Ok(txs) => { + // we sum up the total value in this output and check if it is big enough + // for the order + let total_unspent_amount_received = txs + .into_iter() + .map(|(_, amount)| amount.to_sat()) + .sum::(); + + if total_unspent_amount_received >= needed_channel_size { + tracing::info!( + amount = total_unspent_amount_received.to_string(), + address = funding_address.to_string(), + "Address has been funded enough" + ); + break; + } + tracing::debug!( + amount = total_unspent_amount_received.to_string(), + address = funding_address.to_string(), + "Address has not enough funds yet" + ); + } + Err(err) => { + tracing::error!("Could not get utxo for address {err:?}") + } + } + tokio::time::sleep(Duration::from_secs(10)).await; + } + + event::publish(&EventInternal::FundingChannelNotification( + FundingChannelTask::Funded, + )); + + if let Err(error) = bdk_node.sync_on_chain_wallet().await { + tracing::error!("Failed at syncing wallet {error:?}") + } + + let balance = bdk_node.get_on_chain_balance(); + tracing::debug!(balance = balance.to_string(), "Wallet synced"); + + tracing::debug!( + coordinator_reserve, + needed_channel_size, + "Creating new order with values {new_order:?}" + ); + + match order::handler::submit_order( + new_order.into(), + Some(ChannelOpeningParams { + coordinator_reserve: Amount::from_sat(coordinator_reserve), + trader_reserve: Amount::from_sat(trader_reserve), + }), + ) + .await + .map_err(anyhow::Error::new) + .map(|id| id.to_string()) + { + Ok(order_id) => { + tracing::info!(order_id, "Order created"); + event::publish(&EventInternal::FundingChannelNotification( + FundingChannelTask::OrderCreated(order_id), + )); + } + Err(error) => { + tracing::error!("Failed at submitting order {error:?}"); + event::publish(&EventInternal::FundingChannelNotification( + FundingChannelTask::Failed("Failed at posting the order".to_string()), + )); + } + } + } + .remote_handle(); + + // We need to store the handle which will drop any old handler if present. + node.unfunded_order_handle.lock().replace(remote_handle); + + // Only now we can spawn the future, as otherwise we might have two competing handlers + runtime.spawn(future); + + Ok(()) +} diff --git a/mobile/test/trade_test.dart b/mobile/test/trade_test.dart index 6c7ca85cd..d2f8b56be 100644 --- a/mobile/test/trade_test.dart +++ b/mobile/test/trade_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; import 'package:get_10101/common/amount_denomination_change_notifier.dart'; +import 'package:get_10101/common/amount_text_field.dart'; @GenerateNiceMocks([MockSpec()]) import 'package:get_10101/common/application/channel_info_service.dart'; import 'package:get_10101/common/application/tentenone_config_change_notifier.dart'; @@ -16,7 +17,9 @@ import 'package:get_10101/features/trade/application/order_service.dart'; import 'package:get_10101/features/trade/application/position_service.dart'; @GenerateNiceMocks([MockSpec()]) import 'package:get_10101/features/trade/application/trade_values_service.dart'; +import 'package:get_10101/features/trade/channel_creation_flow/channel_configuration_screen.dart'; import 'package:get_10101/features/trade/domain/direction.dart'; +import 'package:get_10101/features/trade/domain/leverage.dart'; import 'package:get_10101/features/trade/order_change_notifier.dart'; import 'package:get_10101/features/trade/position_change_notifier.dart'; import 'package:get_10101/features/trade/submit_order_change_notifier.dart'; @@ -38,28 +41,38 @@ import 'package:slide_to_confirm/slide_to_confirm.dart'; import 'trade_test.mocks.dart'; -final GoRouter _router = GoRouter( - initialLocation: TradeScreen.route, - routes: [ - GoRoute( - path: TradeScreen.route, - builder: (BuildContext context, GoRouterState state) { - return const TradeScreen(); - }), - ], -); +GoRouter buildGoRouterMock(String initialLocation) { + return GoRouter( + initialLocation: initialLocation, + routes: [ + GoRoute( + path: TradeScreen.route, + builder: (BuildContext context, GoRouterState state) { + return const TradeScreen(); + }), + GoRoute( + path: ChannelConfigurationScreen.route, + builder: (BuildContext context, GoRouterState state) { + return const ChannelConfigurationScreen( + direction: Direction.long, + ); + }), + ], + ); +} class TestWrapperWithTradeTheme extends StatelessWidget { final Widget child; + final RouterConfig router; - const TestWrapperWithTradeTheme({super.key, required this.child}); + const TestWrapperWithTradeTheme({super.key, required this.child, required this.router}); @override Widget build(BuildContext context) { return MaterialApp.router( // TODO: We could consider using the Navigator instead of GoRouter to close the bottom sheet again // Need GoRouter otherwise closing the bottom sheet after confirmation fails - routerConfig: _router, + routerConfig: router, theme: ThemeData( primarySwatch: Colors.blue, extensions: const >[ @@ -81,15 +94,79 @@ void main() { MockDlcChannelService dlcChannelService = MockDlcChannelService(); MockOrderService orderService = MockOrderService(); - testWidgets('Given trade screen when completing first buy flow then market order is submitted', - (tester) async { - // TODO: we could make this more resilient in the underlying components... - // return dummies otherwise the fields won't be initialized correctly + testWidgets('Given rates, the trade screen show bid/ask price', (tester) async { + final tradeValuesChangeNotifier = TradeValuesChangeNotifier(tradeValueService); + SubmitOrderChangeNotifier submitOrderChangeNotifier = SubmitOrderChangeNotifier(orderService); + PositionChangeNotifier positionChangeNotifier = PositionChangeNotifier(positionService); + + const askPrice = 30001.0; + const bidPrice = 30000.0; + + positionChangeNotifier.askPrice = askPrice; + positionChangeNotifier.bidPrice = bidPrice; + tradeValuesChangeNotifier.updatePrice(askPrice, Direction.short); + tradeValuesChangeNotifier.updatePrice(bidPrice, Direction.long); + + // We start the trade screen + await tester.pumpWidget(MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) => tradeValuesChangeNotifier), + ChangeNotifierProvider(create: (context) => submitOrderChangeNotifier), + ChangeNotifierProvider(create: (context) => OrderChangeNotifier(orderService)), + ChangeNotifierProvider(create: (context) => positionChangeNotifier), + ], + child: TestWrapperWithTradeTheme( + router: buildGoRouterMock(TradeScreen.route), + child: const TradeScreen(), + ))); + logger.i("Trade screen started"); + + // We check if all the widgets are here which we want to see + var tradeScreenAskPriceWidget = find.byKey(tradeScreenAskPrice); + expect(tradeScreenAskPriceWidget, findsOneWidget); + var assertedPrice = assertPrice(tester, tradeScreenAskPriceWidget, "\$ 30,001"); + logger.i("Ask price found: $assertedPrice"); + var tradeScreenBidPriceWidget = find.byKey(tradeScreenBidPrice); + expect(tradeScreenBidPriceWidget, findsOneWidget); + assertedPrice = assertPrice(tester, tradeScreenBidPriceWidget, "\$ 30,000"); + logger.i("Bid price found: $assertedPrice"); + + // Buy and sell buttons are also here + expect(find.byKey(tradeScreenButtonBuy), findsOneWidget); + logger.i("Buy button found"); + expect(find.byKey(tradeScreenButtonSell), findsOneWidget); + logger.i("Sell button found"); + + // The two tabs for positions and orders are also here + expect(find.byKey(tradeScreenTabsPositions), findsOneWidget); + logger.i("Positions tab button found"); + expect(find.byKey(tradeScreenTabsOrders), findsOneWidget); + logger.i("Orders tab button found"); + }); + + testWidgets('Given price and balance we see maximum quantity and margin set', (tester) async { + final tradeValuesChangeNotifier = TradeValuesChangeNotifier(tradeValueService); + SubmitOrderChangeNotifier submitOrderChangeNotifier = SubmitOrderChangeNotifier(orderService); + PositionChangeNotifier positionChangeNotifier = PositionChangeNotifier(positionService); + TenTenOneConfigChangeNotifier configChangeNotifier = + TenTenOneConfigChangeNotifier(channelConstraintsService); + DlcChannelChangeNotifier dlcChannelChangeNotifier = DlcChannelChangeNotifier(dlcChannelService); + OrderChangeNotifier orderChangeNotifier = OrderChangeNotifier(orderService); + + const askPrice = 30001.0; + const bidPrice = 30000.0; + + positionChangeNotifier.askPrice = askPrice; + positionChangeNotifier.bidPrice = bidPrice; + tradeValuesChangeNotifier.updatePrice(askPrice, Direction.short); + tradeValuesChangeNotifier.updatePrice(bidPrice, Direction.long); + + var mockedDefaultMargin = Amount(1000); when(tradeValueService.calculateMargin( price: anyNamed('price'), quantity: anyNamed('quantity'), leverage: anyNamed('leverage'))) - .thenReturn(Amount(1000)); + .thenReturn(mockedDefaultMargin); when(tradeValueService.calculateLiquidationPrice( price: anyNamed('price'), leverage: anyNamed('leverage'), @@ -102,15 +179,14 @@ void main() { when(tradeValueService.orderMatchingFee( quantity: anyNamed('quantity'), price: anyNamed('price'))) .thenReturn(Amount(42)); + var mockedMaxQuantity = Usd(2500); when(tradeValueService.calculateMaxQuantity( price: anyNamed('price'), leverage: anyNamed('leverage'), direction: anyNamed('direction'))) - .thenReturn(Usd(2500)); - - when(dlcChannelService.getEstimatedChannelFeeReserve()).thenReturn((Amount(500))); - - when(dlcChannelService.getEstimatedFundingTxFee()).thenReturn((Amount(300))); + .thenReturn(mockedMaxQuantity); + when(dlcChannelService.getEstimatedChannelFeeReserve()).thenReturn(Amount(123)); + when(dlcChannelService.getEstimatedFundingTxFee()).thenReturn(Amount(42)); when(channelConstraintsService.getTradeConstraints()).thenAnswer((_) => const bridge.TradeConstraints( @@ -123,71 +199,213 @@ void main() { maintenanceMarginRate: 0.1, orderMatchingFeeRate: 0.003)); - SubmitOrderChangeNotifier submitOrderChangeNotifier = SubmitOrderChangeNotifier(orderService); + // We start the trade screen + await tester.pumpWidget(MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) => tradeValuesChangeNotifier), + ChangeNotifierProvider(create: (context) => configChangeNotifier), + ChangeNotifierProvider(create: (context) => dlcChannelChangeNotifier), + ChangeNotifierProvider(create: (context) => orderChangeNotifier), + ChangeNotifierProvider(create: (context) => submitOrderChangeNotifier), + ChangeNotifierProvider(create: (context) => positionChangeNotifier), + ChangeNotifierProvider(create: (context) => AmountDenominationChangeNotifier()), + ], + child: TestWrapperWithTradeTheme( + router: buildGoRouterMock(TradeScreen.route), + child: const TradeScreen(), + ))); - WalletChangeNotifier walletChangeNotifier = WalletChangeNotifier(walletService); + logger.i("Trade screen started"); - PositionChangeNotifier positionChangeNotifier = PositionChangeNotifier(positionService); + // Just check for the buy button to open the bottom sheet + expect(find.byKey(tradeScreenButtonBuy), findsOneWidget); + logger.i("Buy button found"); - TenTenOneConfigChangeNotifier configChangeNotifier = - TenTenOneConfigChangeNotifier(channelConstraintsService); + // Open bottom sheet + await tester.tap(find.byKey(tradeScreenButtonBuy)); + await tester.pumpAndSettle(); + logger.i("Trade bottom sheet opened"); + + // Assert market price + { + var marketPriceWidget = find.byKey(tradeButtonSheetMarketPrice); + expect(marketPriceWidget, findsOneWidget); + logger.i("Market price field found"); + + // Find the Text widget within the marketPriceWidget + final usdWidgetTextFields = find.descendant( + of: marketPriceWidget, + matching: find.byType(Text), + ); + + // Verify the Text widget is found + expect(usdWidgetTextFields, findsWidgets); + + // Check if the widget contains our market price + bool containsDesiredString = false; + usdWidgetTextFields.evaluate().forEach((element) { + final textWidget = element.widget as Text; + if (textWidget.data == "30,001") { + containsDesiredString = true; + } + }); + expect(containsDesiredString, isTrue); + logger.i("Market price found"); + } + + // Find quantity input field and assert this field is set + { + var quantityInputFieldWidget = find.byKey(tradeButtonSheetQuantityInput); + expect(quantityInputFieldWidget, findsOneWidget); + logger.i("Quantity input field found"); + // Find the input field widget + final quantityInputField = find.descendant( + of: quantityInputFieldWidget, + matching: find.byType(TextFormField), + ); + expect(quantityInputField, findsOneWidget); + + // Verify the default text in input field + final textFormField = tester.widget(quantityInputField); + expect(textFormField.controller?.text, mockedMaxQuantity.formatted()); + logger.i("Initial quantity field was set to: ${textFormField.controller?.text}"); + } + + // Find margin field and verify it has been set correctly + { + verifyMarginFieldValueSet(tester, mockedDefaultMargin); + } + + // Update the input field and verify that margin has been recomputed + { + var quantityInputFieldWidget = find.byKey(tradeButtonSheetQuantityInput); + expect(quantityInputFieldWidget, findsOneWidget); + logger.i("Quantity input field widget found"); + // Find the input field widget + final quantityInputField = find.descendant( + of: quantityInputFieldWidget, + matching: find.byType(TextFormField), + ); + expect(quantityInputField, findsOneWidget); + logger.i("Quantity input field found"); + + // Verify the default text in input field + final textFormField = tester.widget(quantityInputField); + // Enter text into the TextFormField + await tester.enterText(quantityInputField, '100'); + var inputQuantity = Usd(100); + expect(textFormField.controller?.text, inputQuantity.formatted()); + logger.i("Updated quantity field was set to: ${textFormField.controller?.text}"); + + verify(tradeValueService.calculateMargin( + price: 30001.0, quantity: inputQuantity, leverage: Leverage(2))) + .called(greaterThan(1)); + + logger.i("Margin has been recalculated"); + } + + // we verify again if we can find the buy button but do not click it + // our test setup does not support navigating unfortunately + expect(find.byKey(tradeScreenBottomSheetButtonBuy), findsOneWidget); + logger.i("Found buy button"); + }); - DlcChannelChangeNotifier dlcChannelChangeNotifier = DlcChannelChangeNotifier(dlcChannelService); + testWidgets('when funding with internal wallet, then market buy order is created', + (tester) async { + // This is to ensure we don't get random overflows. The dimensions are from an iPhone 15 + await tester.binding.setSurfaceSize(const Size(2556, 1179)); final tradeValuesChangeNotifier = TradeValuesChangeNotifier(tradeValueService); + SubmitOrderChangeNotifier submitOrderChangeNotifier = SubmitOrderChangeNotifier(orderService); + PositionChangeNotifier positionChangeNotifier = PositionChangeNotifier(positionService); + TenTenOneConfigChangeNotifier configChangeNotifier = + TenTenOneConfigChangeNotifier(channelConstraintsService); + DlcChannelChangeNotifier dlcChannelChangeNotifier = DlcChannelChangeNotifier(dlcChannelService); + OrderChangeNotifier orderChangeNotifier = OrderChangeNotifier(orderService); - const askPrice = 30000.0; + const askPrice = 30001.0; const bidPrice = 30000.0; - // We have to have current price, otherwise we can't take order + var mockedDefaultMargin = Amount(1000); + when(tradeValueService.calculateMargin( + price: anyNamed('price'), + quantity: anyNamed('quantity'), + leverage: anyNamed('leverage'))) + .thenReturn(mockedDefaultMargin); + when(tradeValueService.calculateLiquidationPrice( + price: anyNamed('price'), + leverage: anyNamed('leverage'), + direction: anyNamed('direction'))) + .thenReturn(10000); + when(tradeValueService.calculateQuantity( + price: anyNamed('price'), leverage: anyNamed('leverage'), margin: anyNamed('margin'))) + .thenReturn(Usd(1)); + when(tradeValueService.getExpiryTimestamp()).thenReturn(DateTime.now()); + when(tradeValueService.orderMatchingFee( + quantity: anyNamed('quantity'), price: anyNamed('price'))) + .thenReturn(Amount(42)); + var mockedMaxQuantity = Usd(2500); + when(tradeValueService.calculateMaxQuantity( + price: anyNamed('price'), + leverage: anyNamed('leverage'), + direction: anyNamed('direction'))) + .thenReturn(mockedMaxQuantity); + when(dlcChannelService.getEstimatedChannelFeeReserve()).thenReturn(Amount(123)); + when(dlcChannelService.getEstimatedFundingTxFee()).thenReturn(Amount(42)); + positionChangeNotifier.askPrice = askPrice; positionChangeNotifier.bidPrice = bidPrice; + tradeValuesChangeNotifier.maxQuantityLock = false; tradeValuesChangeNotifier.updatePrice(askPrice, Direction.short); tradeValuesChangeNotifier.updatePrice(bidPrice, Direction.long); - await tester.pumpWidget(MultiProvider(providers: [ - ChangeNotifierProvider(create: (context) => tradeValuesChangeNotifier), - ChangeNotifierProvider(create: (context) => submitOrderChangeNotifier), - ChangeNotifierProvider(create: (context) => OrderChangeNotifier(orderService)), - ChangeNotifierProvider(create: (context) => positionChangeNotifier), - ChangeNotifierProvider(create: (context) => AmountDenominationChangeNotifier()), - ChangeNotifierProvider(create: (context) => walletChangeNotifier), - ChangeNotifierProvider(create: (context) => configChangeNotifier), - ChangeNotifierProvider(create: (context) => dlcChannelChangeNotifier), - ], child: const TestWrapperWithTradeTheme(child: TradeScreen()))); - - // We have to pretend that we have a balance, because otherwise the trade bottom sheet validation will not allow us to go to the confirmation screen - walletChangeNotifier.update(WalletInfo( - balances: WalletBalances(onChain: Amount(251000), offChain: Amount(100000)), history: [])); - - await tester.pumpAndSettle(); - - expect(find.byKey(tradeScreenButtonBuy), findsOneWidget); - - // Open bottom sheet - await tester.tap(find.byKey(tradeScreenButtonBuy)); - await tester.pumpAndSettle(); + when(channelConstraintsService.getTradeConstraints()).thenAnswer((_) => + const bridge.TradeConstraints( + maxLocalBalanceSats: 10000000, + maxCounterpartyBalanceSats: 20000000, + coordinatorLeverage: 2, + minQuantity: 1, + isChannelBalance: true, + minMargin: 1, + maintenanceMarginRate: 0.1, + orderMatchingFeeRate: 0.003)); - expect(find.byKey(tradeScreenBottomSheetButtonBuy), findsOneWidget); + // We start the trade screen + await tester.pumpWidget(MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) => tradeValuesChangeNotifier), + ChangeNotifierProvider(create: (context) => configChangeNotifier), + ChangeNotifierProvider(create: (context) => dlcChannelChangeNotifier), + ChangeNotifierProvider(create: (context) => orderChangeNotifier), + ChangeNotifierProvider(create: (context) => submitOrderChangeNotifier), + ChangeNotifierProvider(create: (context) => positionChangeNotifier), + ChangeNotifierProvider(create: (context) => AmountDenominationChangeNotifier()), + ], + child: TestWrapperWithTradeTheme( + router: buildGoRouterMock(ChannelConfigurationScreen.route), + child: const ChannelConfigurationScreen(direction: Direction.long), + ))); - // click buy button in bottom sheet - await tester.tap(find.byKey(tradeScreenBottomSheetButtonBuy)); - await tester.pumpAndSettle(); + logger.i("Channel configuration screen started"); expect(find.byKey(tradeScreenBottomSheetChannelConfigurationConfirmButton), findsOneWidget); + logger.i("Confirmation button is present"); + var checkboxFinder = + find.byKey(tradeScreenBottomSheetChannelConfigurationFundWithWalletCheckBox); + expect(checkboxFinder, findsOneWidget); + logger.i("Checkbox is present"); + + // Tap the checkbox to check it + await tester.tap(checkboxFinder); await tester.pumpAndSettle(); + logger.i("Checked the checkbox"); - await tester.ensureVisible(find.byKey(tradeScreenBottomSheetChannelConfigurationConfirmButton)); - - // click confirm button to go to confirmation screen - await tester.tap(find.byKey(tradeScreenBottomSheetChannelConfigurationConfirmButton)); - await tester.pumpAndSettle(); + // Verify the checkbox is checked + expect(tester.widget(checkboxFinder).value, true); + logger.i("Verified that it is checked"); - // TODO: Use `find.byKey(tradeScreenBottomSheetConfirmationConfigureChannelSlider)`. - // For some reason the specific widget cannot be found. - expect(find.byType(ConfirmationSlider), findsOneWidget); - - // Drag to confirm + expect(find.byKey(tradeScreenBottomSheetChannelConfigurationConfirmSlider), findsOneWidget); + logger.i("Confirmation slider is now present"); // TODO: This is not optimal because if we re-style the component this test will likely break. final Offset sliderLocation = tester.getBottomLeft(find.byType(ConfirmationSlider)); @@ -265,16 +483,21 @@ void main() { tradeValuesChangeNotifier.updatePrice(askPrice, Direction.short); tradeValuesChangeNotifier.updatePrice(bidPrice, Direction.long); - await tester.pumpWidget(MultiProvider(providers: [ - ChangeNotifierProvider(create: (context) => tradeValuesChangeNotifier), - ChangeNotifierProvider(create: (context) => submitOrderChangeNotifier), - ChangeNotifierProvider(create: (context) => OrderChangeNotifier(orderService)), - ChangeNotifierProvider(create: (context) => positionChangeNotifier), - ChangeNotifierProvider(create: (context) => AmountDenominationChangeNotifier()), - ChangeNotifierProvider(create: (context) => walletChangeNotifier), - ChangeNotifierProvider(create: (context) => configChangeNotifier), - ChangeNotifierProvider(create: (context) => dlcChannelChangeNotifier), - ], child: const TestWrapperWithTradeTheme(child: TradeScreen()))); + await tester.pumpWidget(MultiProvider( + providers: [ + ChangeNotifierProvider(create: (context) => tradeValuesChangeNotifier), + ChangeNotifierProvider(create: (context) => submitOrderChangeNotifier), + ChangeNotifierProvider(create: (context) => OrderChangeNotifier(orderService)), + ChangeNotifierProvider(create: (context) => positionChangeNotifier), + ChangeNotifierProvider(create: (context) => AmountDenominationChangeNotifier()), + ChangeNotifierProvider(create: (context) => walletChangeNotifier), + ChangeNotifierProvider(create: (context) => configChangeNotifier), + ChangeNotifierProvider(create: (context) => dlcChannelChangeNotifier), + ], + child: TestWrapperWithTradeTheme( + router: buildGoRouterMock(TradeScreen.route), + child: const TradeScreen(), + ))); // We have to pretend that we have a balance, because otherwise the trade bottom sheet validation will not allow us to go to the confirmation screen walletChangeNotifier.update(WalletInfo( @@ -303,3 +526,21 @@ void main() { verify(orderService.submitMarketOrder(any, any, any, any, any)).called(1); }); } + +void verifyMarginFieldValueSet(WidgetTester tester, Amount mockedDefaultMargin) { + var marginFieldWidget = find.byKey(tradeButtonSheetMarginField); + expect(marginFieldWidget, findsOneWidget); + logger.i("Margin field found"); + final amountField = tester.widget(marginFieldWidget); + expect(amountField.value, mockedDefaultMargin); + logger.i("Margin field set correctly to $mockedDefaultMargin"); +} + +String assertPrice(WidgetTester tester, Finder byKey, String priceString) { + final textWidget = tester.widget(byKey); + var text = textWidget.text as TextSpan; + var children = text.children!.first; + var plainText = children.toPlainText(); + expect(plainText, priceString); + return plainText; +}