From 84a47917a8508cd3ef35755ce0be21fd03b7a53d Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Wed, 7 Feb 2024 16:48:05 +1100 Subject: [PATCH 1/2] feat: Add channel-opening flow to first trade flow --- coordinator/src/node.rs | 69 +++-- coordinator/src/routes.rs | 8 +- crates/commons/src/trade.rs | 13 +- crates/tests-e2e/src/app.rs | 15 ++ crates/tests-e2e/src/setup.rs | 9 +- crates/tests-e2e/tests/e2e_close_position.rs | 18 +- crates/tests-e2e/tests/e2e_open_position.rs | 9 +- .../tests/e2e_open_position_small_utxos.rs | 9 +- crates/tests-e2e/tests/e2e_reject_offer.rs | 16 +- .../tests/e2e_restore_from_backup.rs | 5 +- .../application/lsp_change_notifier.dart | 1 + .../trade/application/order_service.dart | 22 ++ .../features/trade/channel_configuration.dart | 250 ++++++++++++++++++ .../trade/domain/channel_opening_params.dart | 8 + .../features/trade/domain/trade_values.dart | 6 + .../trade/submit_order_change_notifier.dart | 28 +- .../trade_bottom_sheet_confirmation.dart | 107 +++++--- .../trade/trade_bottom_sheet_tab.dart | 93 +++++-- mobile/lib/features/trade/trade_screen.dart | 3 +- mobile/lib/logger/logger.dart | 13 + mobile/lib/util/constants.dart | 9 + .../down.sql | 1 + .../up.sql | 6 + mobile/native/src/api.rs | 25 +- .../native/src/channel_trade_constraints.rs | 1 + mobile/native/src/db/mod.rs | 21 +- mobile/native/src/db/models.rs | 75 ++++++ mobile/native/src/lib.rs | 16 +- mobile/native/src/ln_dlc/mod.rs | 25 +- mobile/native/src/schema.rs | 10 + mobile/native/src/trade/order/handler.rs | 37 ++- mobile/native/src/trade/order/mod.rs | 1 + mobile/native/src/trade/position/handler.rs | 42 ++- mobile/test/trade_test.dart | 163 +++++++++++- webapp/src/api.rs | 29 +- 35 files changed, 972 insertions(+), 191 deletions(-) create mode 100644 mobile/lib/features/trade/channel_configuration.dart create mode 100644 mobile/lib/features/trade/domain/channel_opening_params.dart create mode 100644 mobile/native/migrations/2024-02-07-031110_add_channel_opening_params_table/down.sql create mode 100644 mobile/native/migrations/2024-02-07-031110_add_channel_opening_params_table/up.sql diff --git a/coordinator/src/node.rs b/coordinator/src/node.rs index e17d388cb..f423ec2c0 100644 --- a/coordinator/src/node.rs +++ b/coordinator/src/node.rs @@ -20,6 +20,7 @@ use bitcoin::secp256k1::PublicKey; use commons::order_matching_fee_taker; use commons::MatchState; use commons::OrderState; +use commons::TradeAndChannelParams; use commons::TradeParams; use diesel::r2d2::ConnectionManager; use diesel::r2d2::Pool; @@ -144,13 +145,13 @@ impl Node { !usable_channels.is_empty() } - pub async fn trade(&self, trade_params: &TradeParams) -> Result<()> { + pub async fn trade(&self, params: &TradeAndChannelParams) -> Result<()> { let mut connection = self.pool.get()?; - let order_id = trade_params.filled_with.order_id; - let trader_id = trade_params.pubkey; + let order_id = params.trade_params.filled_with.order_id; + let trader_id = params.trade_params.pubkey; - match self.trade_internal(trade_params, &mut connection).await { + match self.trade_internal(params, &mut connection).await { Ok(()) => { tracing::info!( %trader_id, @@ -185,11 +186,11 @@ impl Node { async fn trade_internal( &self, - trade_params: &TradeParams, + params: &TradeAndChannelParams, connection: &mut PgConnection, ) -> Result<()> { - let order_id = trade_params.filled_with.order_id; - let trader_id = trade_params.pubkey.to_string(); + let order_id = params.trade_params.filled_with.order_id; + let trader_id = params.trade_params.pubkey.to_string(); let order = orders::get_with_id(connection, order_id)?.context("Could not find order")?; ensure!( @@ -202,24 +203,21 @@ impl Node { order.order_state ); - let order_id = trade_params.filled_with.order_id.to_string(); + let order_id = params.trade_params.filled_with.order_id.to_string(); tracing::info!(trader_id, order_id, "Executing match"); - self.execute_trade_action(connection, trade_params, order.stable) + self.execute_trade_action(connection, params, order.stable) .await?; Ok(()) } - // For now we assume that the first position has equal margin to the size of the DLC channel to - // be opened. - // - // TODO: Introduce separation between creating the DLC channel (reserving liquidity to trade) - // and opening the first position. async fn open_dlc_channel( &self, conn: &mut PgConnection, trade_params: &TradeParams, + collateral_reserve_coordinator: bitcoin::Amount, + collateral_reserve_trader: bitcoin::Amount, stable: bool, ) -> Result<()> { let peer_id = trade_params.pubkey; @@ -236,6 +234,11 @@ impl Node { ) .to_sat(); + // The coordinator gets the `order_matching_fee` directly in the collateral reserve. + let collateral_reserve_with_fee_coordinator = + collateral_reserve_coordinator.to_sat() + order_matching_fee; + let collateral_reserve_trader = collateral_reserve_trader.to_sat(); + let initial_price = trade_params.filled_with.average_execution_price(); let coordinator_direction = trade_params.direction.opposite(); @@ -248,6 +251,8 @@ impl Node { margin_coordinator_sat = %margin_coordinator, margin_trader_sat = %margin_trader, order_matching_fee_sat = %order_matching_fee, + collateral_reserve_with_fee_coordinator = %collateral_reserve_with_fee_coordinator, + collateral_reserve_trader = %collateral_reserve_trader, "Opening DLC channel and position" ); @@ -258,9 +263,8 @@ impl Node { leverage_coordinator, leverage_trader, coordinator_direction, - // The coordinator gets the `order_matching_fee` directly in the collateral reserve. - order_matching_fee, - 0, + collateral_reserve_with_fee_coordinator, + collateral_reserve_trader, trade_params.quantity, trade_params.contract_symbol, ) @@ -292,10 +296,10 @@ impl Node { ); let contract_input = ContractInput { - offer_collateral: margin_coordinator, + offer_collateral: margin_coordinator + collateral_reserve_coordinator.to_sat(), // The accept party has do bring additional collateral to pay for the // `order_matching_fee`. - accept_collateral: margin_trader + order_matching_fee, + accept_collateral: margin_trader + collateral_reserve_trader + order_matching_fee, fee_rate, contract_infos: vec![ContractInputInfo { contract_descriptor, @@ -677,10 +681,10 @@ impl Node { pub async fn execute_trade_action( &self, conn: &mut PgConnection, - trade_params: &TradeParams, + params: &TradeAndChannelParams, is_stable_order: bool, ) -> Result<()> { - let trader_peer_id = trade_params.pubkey; + let trader_peer_id = params.trade_params.pubkey; match self .inner @@ -702,9 +706,22 @@ impl Node { "Previous DLC Channel offer still pending." ); - self.open_dlc_channel(conn, trade_params, is_stable_order) - .await - .context("Failed to open DLC channel")?; + let collateral_reserve_coordinator = params + .coordinator_reserve + .context("Missing coordinator collateral reserve")?; + let collateral_reserve_trader = params + .trader_reserve + .context("Missing trader collateral reserve")?; + + self.open_dlc_channel( + conn, + ¶ms.trade_params, + collateral_reserve_coordinator, + collateral_reserve_trader, + is_stable_order, + ) + .await + .context("Failed to open DLC channel")?; } Some(SignedChannel { channel_id, @@ -724,7 +741,7 @@ impl Node { self.open_position( conn, channel_id, - trade_params, + ¶ms.trade_params, own_payout, counter_payout, is_stable_order, @@ -737,6 +754,8 @@ impl Node { channel_id: dlc_channel_id, .. }) => { + let trade_params = ¶ms.trade_params; + let position = db::positions::Position::get_position_by_trader( conn, trader_peer_id, diff --git a/coordinator/src/routes.rs b/coordinator/src/routes.rs index d76d193c3..095f56acd 100644 --- a/coordinator/src/routes.rs +++ b/coordinator/src/routes.rs @@ -67,7 +67,7 @@ use commons::PollAnswers; use commons::RegisterParams; use commons::Restore; use commons::RouteHintHop; -use commons::TradeParams; +use commons::TradeAndChannelParams; use commons::UpdateUsernameParams; use diesel::r2d2::ConnectionManager; use diesel::r2d2::Pool; @@ -364,18 +364,16 @@ pub async fn get_invoice( // TODO: We might want to have our own ContractInput type here so we can potentially map fields if // the library changes? #[instrument(skip_all, err(Debug))] - pub async fn post_trade( State(state): State>, - trade_params: Json, + params: Json, ) -> Result<(), AppError> { - state.node.trade(&trade_params.0).await.map_err(|e| { + state.node.trade(¶ms.0).await.map_err(|e| { AppError::InternalServerError(format!("Could not handle trade request: {e:#}")) }) } #[instrument(skip_all, err(Debug))] - pub async fn rollover( State(state): State>, Path(dlc_channel_id): Path, diff --git a/crates/commons/src/trade.rs b/crates/commons/src/trade.rs index eb4e46442..f34cc7684 100644 --- a/crates/commons/src/trade.rs +++ b/crates/commons/src/trade.rs @@ -1,3 +1,4 @@ +use bitcoin::Amount; use rust_decimal::Decimal; use secp256k1::PublicKey; use secp256k1::XOnlyPublicKey; @@ -8,9 +9,19 @@ use trade::ContractSymbol; use trade::Direction; use uuid::Uuid; -/// The trade parameters defining the trade execution +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TradeAndChannelParams { + pub trade_params: TradeParams, + #[serde(with = "bitcoin::util::amount::serde::as_sat::opt")] + pub trader_reserve: Option, + #[serde(with = "bitcoin::util::amount::serde::as_sat::opt")] + pub coordinator_reserve: Option, +} + +/// The trade parameters defining the trade execution. /// /// Emitted by the orderbook when a match is found. +/// /// Both trading parties will receive trade params and then request trade execution with said trade /// parameters from the coordinator. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/crates/tests-e2e/src/app.rs b/crates/tests-e2e/src/app.rs index 924fc4012..9600be46d 100644 --- a/crates/tests-e2e/src/app.rs +++ b/crates/tests-e2e/src/app.rs @@ -3,6 +3,7 @@ use crate::test_subscriber::ThreadSafeSenders; use crate::wait_until; use native::api; use native::api::DlcChannel; +use native::trade::order::api::NewOrder; use tempfile::TempDir; use tokio::task::block_in_place; @@ -107,6 +108,20 @@ pub fn get_dlc_channels() -> Vec { block_in_place(move || api::list_dlc_channels().unwrap()) } +pub fn submit_order(order: NewOrder) { + block_in_place(move || api::submit_order(order).unwrap()); +} + +pub fn submit_channel_opening_order( + order: NewOrder, + coordinator_reserve: u64, + trader_reserve: u64, +) { + block_in_place(move || { + api::submit_channel_opening_order(order, coordinator_reserve, trader_reserve).unwrap() + }); +} + // Values mostly taken from `environment.dart` fn test_config() -> native::config::api::Config { native::config::api::Config { diff --git a/crates/tests-e2e/src/setup.rs b/crates/tests-e2e/src/setup.rs index 8cf58cf07..0b3fac4c9 100644 --- a/crates/tests-e2e/src/setup.rs +++ b/crates/tests-e2e/src/setup.rs @@ -1,5 +1,6 @@ use crate::app::refresh_wallet_info; use crate::app::run_app; +use crate::app::submit_channel_opening_order; use crate::app::sync_dlc_channels; use crate::app::AppHandle; use crate::bitcoind::Bitcoind; @@ -13,7 +14,6 @@ use native::api::ContractSymbol; use native::trade::order::api::NewOrder; use native::trade::order::api::OrderType; use native::trade::position::PositionState; -use tokio::task::spawn_blocking; pub struct TestSetup { pub app: AppHandle, @@ -119,12 +119,7 @@ impl TestSetup { tracing::info!("Opening a position"); let order = dummy_order(); - spawn_blocking({ - let order = order.clone(); - move || api::submit_order(order).unwrap() - }) - .await - .unwrap(); + submit_channel_opening_order(order.clone(), 0, 0); wait_until!(rx.order().is_some()); diff --git a/crates/tests-e2e/tests/e2e_close_position.rs b/crates/tests-e2e/tests/e2e_close_position.rs index 2b292e16b..441ef0502 100644 --- a/crates/tests-e2e/tests/e2e_close_position.rs +++ b/crates/tests-e2e/tests/e2e_close_position.rs @@ -5,10 +5,10 @@ use native::api::ContractSymbol; use native::trade::order::api::NewOrder; use native::trade::order::api::OrderType; use native::trade::position::PositionState; +use tests_e2e::app::submit_order; use tests_e2e::setup; use tests_e2e::setup::dummy_order; use tests_e2e::wait_until; -use tokio::task::spawn_blocking; // Comments are based on a fixed price of 40_000. // TODO: Add assertions when the maker price can be fixed. @@ -33,10 +33,7 @@ async fn can_open_close_open_close_position() { tracing::info!("Closing first position"); - spawn_blocking(move || api::submit_order(closing_order).unwrap()) - .await - .unwrap(); - + submit_order(closing_order.clone()); wait_until!(test.app.rx.position_close().is_some()); tokio::time::sleep(std::time::Duration::from_secs(10)).await; @@ -57,12 +54,7 @@ async fn can_open_close_open_close_position() { stable: false, }; - spawn_blocking({ - let order = order.clone(); - move || api::submit_order(order).unwrap() - }) - .await - .unwrap(); + submit_order(order.clone()); wait_until!(test.app.rx.position().is_some()); wait_until!(test.app.rx.position().unwrap().position_state == PositionState::Open); @@ -83,9 +75,7 @@ async fn can_open_close_open_close_position() { ..order }; - spawn_blocking(move || api::submit_order(closing_order).unwrap()) - .await - .unwrap(); + submit_order(closing_order); wait_until!(test.app.rx.position_close().is_some()); diff --git a/crates/tests-e2e/tests/e2e_open_position.rs b/crates/tests-e2e/tests/e2e_open_position.rs index 4b555d483..4d22a0320 100644 --- a/crates/tests-e2e/tests/e2e_open_position.rs +++ b/crates/tests-e2e/tests/e2e_open_position.rs @@ -5,9 +5,9 @@ use native::health::ServiceStatus; use native::trade::order::api::NewOrder; use native::trade::order::api::OrderType; use native::trade::position::PositionState; +use tests_e2e::app::submit_channel_opening_order; use tests_e2e::setup::TestSetup; use tests_e2e::wait_until; -use tokio::task::spawn_blocking; #[tokio::test(flavor = "multi_thread")] #[ignore = "need to be run with 'just e2e' command"] @@ -23,12 +23,7 @@ async fn can_open_position() { order_type: Box::new(OrderType::Market), stable: false, }; - spawn_blocking({ - let order = order.clone(); - move || api::submit_order(order).unwrap() - }) - .await - .unwrap(); + submit_channel_opening_order(order.clone(), 10_000, 10_000); assert_eq!(app.rx.status(Service::Orderbook), ServiceStatus::Online); assert_eq!(app.rx.status(Service::Coordinator), ServiceStatus::Online); diff --git a/crates/tests-e2e/tests/e2e_open_position_small_utxos.rs b/crates/tests-e2e/tests/e2e_open_position_small_utxos.rs index 0d2f87f2c..dc1a6e724 100644 --- a/crates/tests-e2e/tests/e2e_open_position_small_utxos.rs +++ b/crates/tests-e2e/tests/e2e_open_position_small_utxos.rs @@ -8,9 +8,9 @@ use native::trade::position::PositionState; use rust_decimal::prelude::ToPrimitive; use std::str::FromStr; use tests_e2e::app::refresh_wallet_info; +use tests_e2e::app::submit_channel_opening_order; use tests_e2e::setup::TestSetup; use tests_e2e::wait_until; -use tokio::task::spawn_blocking; #[tokio::test(flavor = "multi_thread")] #[ignore = "need to be run with 'just e2e' command"] @@ -75,12 +75,7 @@ async fn can_open_position_with_multiple_small_utxos() { // Act - spawn_blocking({ - let order = order.clone(); - move || api::submit_order(order).unwrap() - }) - .await - .unwrap(); + submit_channel_opening_order(order.clone(), 0, 0); // Assert diff --git a/crates/tests-e2e/tests/e2e_reject_offer.rs b/crates/tests-e2e/tests/e2e_reject_offer.rs index e63419f74..7748633e0 100644 --- a/crates/tests-e2e/tests/e2e_reject_offer.rs +++ b/crates/tests-e2e/tests/e2e_reject_offer.rs @@ -7,9 +7,9 @@ use native::trade::order::api::NewOrder; use native::trade::order::api::OrderType; use native::trade::order::OrderState; use native::trade::position::PositionState; +use tests_e2e::app::submit_channel_opening_order; use tests_e2e::setup::TestSetup; use tests_e2e::wait_until; -use tokio::task::spawn_blocking; #[tokio::test(flavor = "multi_thread")] #[ignore = "need to be run with 'just e2e' command"] @@ -31,12 +31,7 @@ async fn reject_offer() { // submit order for which the app does not have enough liquidity. will fail with `Failed to // accept dlc channel offer. Invalid state: Not enough UTXOs for amount` - spawn_blocking({ - let order = invalid_order.clone(); - move || api::submit_order(order).unwrap() - }) - .await - .unwrap(); + submit_channel_opening_order(invalid_order.clone(), 0, 0); assert_eq!(app.rx.status(Service::Orderbook), ServiceStatus::Online); assert_eq!(app.rx.status(Service::Coordinator), ServiceStatus::Online); @@ -70,12 +65,7 @@ async fn reject_offer() { stable: false, }; - spawn_blocking({ - let order = order.clone(); - move || api::submit_order(order).unwrap() - }) - .await - .unwrap(); + submit_channel_opening_order(order.clone(), 0, 0); // Assert that the order was posted wait_until!(app.rx.order().is_some()); diff --git a/crates/tests-e2e/tests/e2e_restore_from_backup.rs b/crates/tests-e2e/tests/e2e_restore_from_backup.rs index eaf65e8a0..1ab27465b 100644 --- a/crates/tests-e2e/tests/e2e_restore_from_backup.rs +++ b/crates/tests-e2e/tests/e2e_restore_from_backup.rs @@ -1,6 +1,7 @@ use native::api; use native::trade::position::PositionState; use tests_e2e::app::run_app; +use tests_e2e::app::submit_order; use tests_e2e::logger::init_tracing; use tests_e2e::setup; use tests_e2e::setup::dummy_order; @@ -43,9 +44,7 @@ async fn app_can_be_restored_from_a_backup() { }; tracing::info!("Closing a position"); - spawn_blocking(move || api::submit_order(closing_order).unwrap()) - .await - .unwrap(); + submit_order(closing_order); wait_until!(test.app.rx.position().unwrap().position_state == PositionState::Closing); wait_until!(test.app.rx.position_close().is_some()); diff --git a/mobile/lib/common/application/lsp_change_notifier.dart b/mobile/lib/common/application/lsp_change_notifier.dart index e6f6fdb62..f0b00a66c 100644 --- a/mobile/lib/common/application/lsp_change_notifier.dart +++ b/mobile/lib/common/application/lsp_change_notifier.dart @@ -5,6 +5,7 @@ import 'package:get_10101/common/application/event_service.dart'; import 'package:get_10101/common/domain/liquidity_option.dart'; import 'package:get_10101/common/domain/model.dart'; +// TODO: Name seems wrong as we use this to get on-chain sats too. class LspChangeNotifier extends ChangeNotifier implements Subscriber { ChannelInfoService channelInfoService; diff --git a/mobile/lib/features/trade/application/order_service.dart b/mobile/lib/features/trade/application/order_service.dart index 6a43faa2d..2d887212b 100644 --- a/mobile/lib/features/trade/application/order_service.dart +++ b/mobile/lib/features/trade/application/order_service.dart @@ -19,6 +19,28 @@ class OrderService { return await rust.api.submitOrder(order: order); } + Future submitChannelOpeningMarketOrder( + Leverage leverage, + Amount quantity, + ContractSymbol contractSymbol, + Direction direction, + bool stable, + Amount coordinatorReserve, + Amount traderReserve) 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); + + return await rust.api.submitChannelOpeningOrder( + order: order, + coordinatorReserve: coordinatorReserve.sats, + traderReserve: traderReserve.sats); + } + 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 new file mode 100644 index 000000000..0d5d9e15a --- /dev/null +++ b/mobile/lib/features/trade/channel_configuration.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import 'package:get_10101/common/amount_text_field.dart'; +import 'package:get_10101/common/amount_text_input_form_field.dart'; +import 'package:get_10101/common/application/lsp_change_notifier.dart'; +import 'package:get_10101/common/domain/model.dart'; +import 'package:get_10101/common/value_data_row.dart'; +import 'package:get_10101/features/trade/domain/channel_opening_params.dart'; +import 'package:get_10101/features/trade/domain/leverage.dart'; +import 'package:get_10101/features/trade/domain/trade_values.dart'; +import 'package:get_10101/util/constants.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +// TODO: Fetch from backend. +Amount openingFee = Amount(0); + +// TODO: Include fee reserve. + +channelConfiguration({ + required BuildContext context, + required TradeValues tradeValues, + required Function(ChannelOpeningParams channelOpeningParams) onConfirmation, +}) { + showModalBottomSheet( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + clipBehavior: Clip.antiAlias, + isScrollControlled: true, + useRootNavigator: true, + barrierColor: Colors.black.withOpacity(0), + context: context, + builder: (BuildContext context) { + return SafeArea( + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + // the GestureDetector ensures that we can close the keyboard by tapping into the modal + child: GestureDetector( + onTap: () { + FocusScopeNode currentFocus = FocusScope.of(context); + + if (!currentFocus.hasPrimaryFocus) { + currentFocus.unfocus(); + } + }, + child: SingleChildScrollView( + child: SizedBox( + height: 450, + child: ChannelConfiguration( + tradeValues: tradeValues, + onConfirmation: onConfirmation, + ), + ), + )))); + }); +} + +class ChannelConfiguration extends StatefulWidget { + final TradeValues tradeValues; + + final Function(ChannelOpeningParams channelOpeningParams) onConfirmation; + + const ChannelConfiguration({super.key, required this.tradeValues, required this.onConfirmation}); + + @override + State createState() => _ChannelConfiguration(); +} + +class _ChannelConfiguration extends State { + late final LspChangeNotifier lspChangeNotifier; + + 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(); + + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + + lspChangeNotifier = context.read(); + var tradeConstraints = lspChangeNotifier.channelInfoService.getTradeConstraints(); + + maxCounterpartyCollateral = Amount(tradeConstraints.maxCounterpartyMarginSats); + + maxOnChainSpending = Amount(tradeConstraints.maxLocalMarginSats); + counterpartyLeverage = tradeConstraints.coordinatorLeverage; + + counterpartyMargin = widget.tradeValues.calculateMargin(Leverage(counterpartyLeverage)); + + minMargin = Amount(tradeConstraints.minMargin); + + ownTotalCollateral = tradeConstraints.minMargin > widget.tradeValues.margin!.sats + ? Amount(tradeConstraints.minMargin) + : widget.tradeValues.margin!; + + orderMatchingFees = widget.tradeValues.fee ?? Amount.zero(); + + updateCounterpartyCollateral(); + + // 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) { + return Form( + key: _formKey, + child: Container( + padding: const EdgeInsets.only(top: 20, left: 20, right: 20), + child: Column(children: [ + const Text("DLC Channel Configuration", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 17)), + const SizedBox( + height: 20, + ), + RichText( + text: TextSpan( + text: + "This is your first trade. 10101 will open a DLC channel with you, creating your position in the process.\n\nPlease specify your preferred channel size, impacting how much you will be able to win up to.", + style: DefaultTextStyle.of(context).style, + )), + const SizedBox( + height: 20, + ), + Center( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + children: [ + Wrap( + runSpacing: 10, + children: [ + SizedBox( + height: 80, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: AmountInputField( + value: ownTotalCollateral, + label: 'Your collateral (sats)', + onChanged: (value) { + setState(() { + ownTotalCollateral = Amount.parseAmount(value); + + updateCounterpartyCollateral(); + }); + }, + validator: (value) { + if (ownTotalCollateral.sats < minMargin.sats) { + return "Min collateral: $minMargin"; + } + + // TODO(holzeis): Add validation considering the on-chain fees + + if (ownTotalCollateral.add(orderMatchingFees).sats > + maxOnChainSpending.sats) { + return "Max on-chain: ${Amount(maxOnChainSpending.sats - orderMatchingFees.sats)}"; + } + + if (maxCounterpartyCollateral.sats < + counterpartyCollateral.sats) { + return "Over limit: $counterpartyCollateral"; + } + + return null; + }, + infoText: + "Your total collateral in the dlc channel.\n\nChoose a bigger amount here if you plan to make bigger trades in the future and don't want to open a new channel.", + )), + const SizedBox( + width: 10, + ), + Flexible( + child: AmountTextField( + value: counterpartyCollateral, + label: 'Win up to (sats)', + )) + ], + ), + ), + const SizedBox(height: 30), + ValueDataRow( + type: ValueType.amount, + value: ownTotalCollateral, + label: 'Your collateral'), + ValueDataRow( + type: ValueType.amount, + value: openingFee, + label: 'Channel-opening fee', + ), + ], + ), + const Divider(), + ValueDataRow( + type: ValueType.amount, + value: ownTotalCollateral.add(openingFee), + label: "Total"), + const SizedBox(height: 30), + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton( + key: tradeScreenBottomSheetChannelConfigurationConfirmButton, + onPressed: + _formKey.currentState != null && _formKey.currentState!.validate() + ? () { + GoRouter.of(context).pop(); + widget.onConfirmation(ChannelOpeningParams( + coordinatorCollateral: counterpartyCollateral, + traderCollateral: ownTotalCollateral)); + } + : null, + style: ElevatedButton.styleFrom(minimumSize: const Size.fromHeight(50)), + child: const Text("Confirm"), + ), + ], + ) + ], + ), + ), + ) + ]))); + } + + void updateCounterpartyCollateral() { + final collateral = (ownTotalCollateral.sats / counterpartyLeverage).floor(); + counterpartyCollateral = + counterpartyMargin.sats > collateral ? counterpartyMargin : Amount(collateral); + } +} diff --git a/mobile/lib/features/trade/domain/channel_opening_params.dart b/mobile/lib/features/trade/domain/channel_opening_params.dart new file mode 100644 index 000000000..abb14845c --- /dev/null +++ b/mobile/lib/features/trade/domain/channel_opening_params.dart @@ -0,0 +1,8 @@ +import 'package:get_10101/common/domain/model.dart'; + +class ChannelOpeningParams { + Amount coordinatorCollateral; + Amount traderCollateral; + + ChannelOpeningParams({required this.coordinatorCollateral, required this.traderCollateral}); +} diff --git a/mobile/lib/features/trade/domain/trade_values.dart b/mobile/lib/features/trade/domain/trade_values.dart index c48798ce3..31bccd5aa 100644 --- a/mobile/lib/features/trade/domain/trade_values.dart +++ b/mobile/lib/features/trade/domain/trade_values.dart @@ -126,6 +126,12 @@ class TradeValues { _recalculateLiquidationPrice(); } + // Can be used to calculate the counterparty's margin, based on their + // leverage. + calculateMargin(Leverage leverage) { + return tradeValuesService.calculateMargin(price: price, quantity: quantity, leverage: leverage); + } + _recalculateMargin() { Amount? margin = tradeValuesService.calculateMargin(price: price, quantity: quantity, leverage: leverage); diff --git a/mobile/lib/features/trade/submit_order_change_notifier.dart b/mobile/lib/features/trade/submit_order_change_notifier.dart index bde7e44ec..0d861418d 100644 --- a/mobile/lib/features/trade/submit_order_change_notifier.dart +++ b/mobile/lib/features/trade/submit_order_change_notifier.dart @@ -1,4 +1,6 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge.dart'; +import 'package:get_10101/features/trade/domain/channel_opening_params.dart'; +import 'package:get_10101/features/trade/domain/leverage.dart'; import 'package:get_10101/logger/logger.dart'; import 'package:flutter/material.dart'; import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; @@ -46,7 +48,7 @@ class SubmitOrderChangeNotifier extends ChangeNotifier implements Subscriber { SubmitOrderChangeNotifier(this.orderService); submitPendingOrder(TradeValues tradeValues, PositionAction positionAction, - {Amount? pnl, bool stable = false}) async { + {ChannelOpeningParams? channelOpeningParams, Amount? pnl, bool stable = false}) async { _pendingOrder = PendingOrder(tradeValues, positionAction, pnl); // notify listeners about pending order in state "pending" @@ -54,8 +56,28 @@ class SubmitOrderChangeNotifier extends ChangeNotifier implements Subscriber { try { assert(tradeValues.quantity != null, 'Quantity cannot be null when submitting order'); - _pendingOrder!.id = await orderService.submitMarketOrder(tradeValues.leverage, - tradeValues.quantity!, ContractSymbol.btcusd, tradeValues.direction, stable); + + if (channelOpeningParams != null) { + // TODO(holzeis): The coordinator leverage should not be hard coded here. + final coordinatorCollateral = tradeValues.calculateMargin(Leverage(2.0)); + + final coordinatorReserve = + channelOpeningParams.coordinatorCollateral.sub(coordinatorCollateral); + final traderReserve = channelOpeningParams.traderCollateral.sub(tradeValues.margin!); + + _pendingOrder!.id = await orderService.submitChannelOpeningMarketOrder( + tradeValues.leverage, + tradeValues.quantity!, + ContractSymbol.btcusd, + tradeValues.direction, + stable, + coordinatorReserve, + traderReserve); + } else { + _pendingOrder!.id = await orderService.submitMarketOrder(tradeValues.leverage, + tradeValues.quantity!, ContractSymbol.btcusd, tradeValues.direction, stable); + } + _pendingOrder!.state = PendingOrderState.submittedSuccessfully; } on FfiException catch (exception) { logger.e("Failed to submit order: $exception"); diff --git a/mobile/lib/features/trade/trade_bottom_sheet_confirmation.dart b/mobile/lib/features/trade/trade_bottom_sheet_confirmation.dart index 9e2261f4c..c987018e0 100644 --- a/mobile/lib/features/trade/trade_bottom_sheet_confirmation.dart +++ b/mobile/lib/features/trade/trade_bottom_sheet_confirmation.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:get_10101/common/amount_text.dart'; import 'package:get_10101/common/domain/model.dart'; import 'package:get_10101/common/value_data_row.dart'; +import 'package:get_10101/features/trade/channel_configuration.dart'; import 'package:get_10101/features/trade/contract_symbol_icon.dart'; +import 'package:get_10101/features/trade/domain/channel_opening_params.dart'; import 'package:get_10101/features/trade/domain/contract_symbol.dart'; import 'package:get_10101/features/trade/domain/direction.dart'; import 'package:get_10101/features/trade/domain/trade_values.dart'; @@ -13,11 +15,18 @@ import 'package:get_10101/util/constants.dart'; import 'package:provider/provider.dart'; import 'package:slide_to_confirm/slide_to_confirm.dart'; +enum TradeAction { + openChannel, + trade, + closePosition, +} + tradeBottomSheetConfirmation( {required BuildContext context, required Direction direction, + required TradeAction tradeAction, required Function() onConfirmation, - bool close = false}) { + required ChannelOpeningParams? channelOpeningParams}) { final sliderKey = direction == Direction.long ? tradeScreenBottomSheetConfirmationSliderBuy : tradeScreenBottomSheetConfirmationSliderSell; @@ -36,10 +45,11 @@ tradeBottomSheetConfirmation( isScrollControlled: true, useRootNavigator: true, context: context, + barrierColor: Colors.black.withOpacity(TradeAction.closePosition == tradeAction ? 0.4 : 0), builder: (BuildContext context) { return SafeArea( - child: Padding( - // padding: MediaQuery.of(context).viewInsets, + child: Container( + // decoration: BoxDecoration(border: Border.all(color: Colors.black)), padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), // the GestureDetector ensures that we can close the keyboard by tapping into the modal child: GestureDetector( @@ -52,13 +62,14 @@ tradeBottomSheetConfirmation( }, child: SingleChildScrollView( child: SizedBox( - height: 350, + height: TradeAction.closePosition == tradeAction ? 320 : 450, child: TradeBottomSheetConfirmation( direction: direction, sliderButtonKey: sliderButtonKey, sliderKey: sliderKey, onConfirmation: onConfirmation, - close: close, + tradeAction: tradeAction, + traderCollateral: channelOpeningParams?.traderCollateral, )), ), ), @@ -68,12 +79,38 @@ tradeBottomSheetConfirmation( ); } +// TODO: Include slider/button too. +RichText confirmationText(BuildContext context, TradeAction tradeAction, Amount total) { + switch (tradeAction) { + case TradeAction.closePosition: + return RichText( + text: TextSpan( + text: + '\nBy confirming, a closing market order will be created. Once the order is matched, your position will be closed.', + style: DefaultTextStyle.of(context).style)); + case TradeAction.openChannel: + case TradeAction.trade: + return RichText( + text: TextSpan( + text: '\nBy confirming, a new order will be created. Once the order is matched, ', + style: DefaultTextStyle.of(context).style, + children: [ + TextSpan(text: formatSats(total), style: const TextStyle(fontWeight: FontWeight.bold)), + const TextSpan(text: ' will be locked up in a DLC channel!'), + ], + ), + ); + } +} + class TradeBottomSheetConfirmation extends StatelessWidget { final Direction direction; final Key sliderKey; final Key sliderButtonKey; final Function() onConfirmation; - final bool close; + final TradeAction tradeAction; + + final Amount? traderCollateral; const TradeBottomSheetConfirmation( {required this.direction, @@ -81,7 +118,8 @@ class TradeBottomSheetConfirmation extends StatelessWidget { required this.sliderButtonKey, required this.sliderKey, required this.onConfirmation, - required this.close}); + required this.tradeAction, + this.traderCollateral}); @override Widget build(BuildContext context) { @@ -91,9 +129,20 @@ class TradeBottomSheetConfirmation extends StatelessWidget { TradeValues tradeValues = Provider.of(context).fromDirection(direction); + bool isClose = tradeAction == TradeAction.closePosition; + bool isChannelOpen = tradeAction == TradeAction.openChannel; + + final traderCollateral1 = traderCollateral ?? Amount.zero(); + + Amount reserve = isChannelOpen + ? (tradeValues.margin?.sats ?? 0) > traderCollateral1.sats + ? Amount.zero() + : traderCollateral1.sub(tradeValues.margin ?? Amount.zero()) + : Amount.zero(); + // Fallback to 0 if we can't get the fee or the margin Amount total = tradeValues.fee != null && tradeValues.margin != null - ? Amount(tradeValues.fee!.sats + tradeValues.margin!.sats) + ? Amount(tradeValues.fee!.sats + tradeValues.margin!.sats).add(reserve) : Amount(0); Amount pnl = Amount(0); if (context.read().positions.containsKey(ContractSymbol.btcusd)) { @@ -118,19 +167,19 @@ class TradeBottomSheetConfirmation extends StatelessWidget { Wrap( runSpacing: 10, children: [ - if (!close) + if (!isClose) ValueDataRow( type: ValueType.date, value: tradeValues.expiry.toLocal(), label: 'Expiry'), - close + isClose ? ValueDataRow( type: ValueType.fiat, value: tradeValues.price ?? 0.0, label: 'Market Price') : ValueDataRow( type: ValueType.amount, value: tradeValues.margin, label: 'Margin'), - close + isClose ? ValueDataRow( type: ValueType.amount, value: pnl, @@ -146,36 +195,30 @@ class TradeBottomSheetConfirmation extends StatelessWidget { ValueDataRow( type: ValueType.amount, value: tradeValues.fee ?? Amount.zero(), - label: "Fee estimate", + label: "Order-matching fee", ), + isChannelOpen + ? ValueDataRow( + type: ValueType.amount, value: reserve, label: 'Channel reserve') + : const SizedBox(height: 0), + isChannelOpen + ? ValueDataRow( + type: ValueType.amount, + value: openingFee, + label: 'Channel-opening fee', + ) + : const SizedBox(height: 0), ], ), - !close ? const Divider() : const SizedBox(height: 0), - !close + !isClose ? const Divider() : const SizedBox(height: 0), + !isClose ? ValueDataRow(type: ValueType.amount, value: total, label: "Total") : const SizedBox(height: 0), ], ), ), ), - close - ? RichText( - text: TextSpan( - text: - '\nBy confirming, a closing market order will be created. Once the order is matched your position will be closed.', - style: DefaultTextStyle.of(context).style)) - : RichText( - text: TextSpan( - text: 'By confirming a new order will be created. Once the order is matched ', - style: DefaultTextStyle.of(context).style, - children: [ - TextSpan( - text: formatSats(total), - style: const TextStyle(fontWeight: FontWeight.bold)), - const TextSpan(text: ' will be locked up in a DLC channel!'), - ], - ), - ), + confirmationText(context, tradeAction, total), const Spacer(), ConfirmationSlider( key: sliderKey, diff --git a/mobile/lib/features/trade/trade_bottom_sheet_tab.dart b/mobile/lib/features/trade/trade_bottom_sheet_tab.dart index 3da0acd76..76a8fb1e1 100644 --- a/mobile/lib/features/trade/trade_bottom_sheet_tab.dart +++ b/mobile/lib/features/trade/trade_bottom_sheet_tab.dart @@ -4,7 +4,10 @@ import 'package:get_10101/common/amount_text_field.dart'; import 'package:get_10101/common/amount_text_input_form_field.dart'; import 'package:get_10101/common/application/channel_info_service.dart'; import 'package:get_10101/common/application/lsp_change_notifier.dart'; +import 'package:get_10101/common/dlc_channel_change_notifier.dart'; import 'package:get_10101/common/domain/model.dart'; +import 'package:get_10101/features/trade/channel_configuration.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/modal_bottom_sheet_info.dart'; import 'package:get_10101/common/value_data_row.dart'; @@ -20,7 +23,6 @@ import 'package:get_10101/features/trade/trade_dialog.dart'; import 'package:get_10101/features/trade/trade_theme.dart'; import 'package:get_10101/features/trade/trade_value_change_notifier.dart'; import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; const contractSymbol = ContractSymbol.btcusd; @@ -53,6 +55,8 @@ class _TradeBottomSheetTabState extends State { provider = context.read(); lspChangeNotifier = context.read(); positionChangeNotifier = context.read(); + + context.read().refreshDlcChannels(); super.initState(); } @@ -68,6 +72,7 @@ class _TradeBottomSheetTabState extends State { @override Widget build(BuildContext context) { TradeTheme tradeTheme = Theme.of(context).extension()!; + DlcChannelChangeNotifier dlcChannelChangeNotifier = context.watch(); Direction direction = widget.direction; String label = direction == Direction.long ? "Buy" : "Sell"; @@ -76,6 +81,8 @@ class _TradeBottomSheetTabState extends State { final channelInfoService = lspChangeNotifier.channelInfoService; final channelTradeConstraints = channelInfoService.getTradeConstraints(); + final hasChannel = dlcChannelChangeNotifier.hasDlcChannel(); + return Form( key: _formKey, child: Column( @@ -100,30 +107,43 @@ class _TradeBottomSheetTabState extends State { onPressed: () { TradeValues tradeValues = context.read().fromDirection(direction); - if (_formKey.currentState!.validate() && - channelTradeConstraints.minMargin <= (tradeValues.margin?.sats ?? 0)) { + if (_formKey.currentState!.validate()) { final submitOrderChangeNotifier = context.read(); - tradeBottomSheetConfirmation( - context: context, - direction: direction, - onConfirmation: () { - submitOrderChangeNotifier.submitPendingOrder( - tradeValues, PositionAction.open); - - // Return to the trade screen before submitting the pending order so that the dialog is displayed under the correct context - GoRouter.of(context).pop(); - GoRouter.of(context).pop(); - - // Show immediately the pending dialog, when submitting a market order. - // TODO(holzeis): We should only show the dialog once we've received a match. - showDialog( - context: context, - useRootNavigator: true, - barrierDismissible: false, // Prevent user from leaving - builder: (BuildContext context) { - return const TradeDialog(); - }); - }); + + final tradeAction = hasChannel ? TradeAction.trade : TradeAction.openChannel; + + 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, + ); + }, + ); + break; + } + case TradeAction.trade: + case TradeAction.closePosition: + tradeBottomSheetConfirmation( + context: context, + direction: direction, + tradeAction: tradeAction, + onConfirmation: () => + onConfirmation(submitOrderChangeNotifier, tradeValues, null), + channelOpeningParams: null, + ); + } } }, style: ElevatedButton.styleFrom( @@ -136,6 +156,26 @@ class _TradeBottomSheetTabState extends State { ); } + void onConfirmation(SubmitOrderChangeNotifier submitOrderChangeNotifier, TradeValues tradeValues, + ChannelOpeningParams? channelOpeningParams) { + submitOrderChangeNotifier.submitPendingOrder(tradeValues, PositionAction.open, + channelOpeningParams: channelOpeningParams); + + // Return to the trade screen before submitting the pending order so that the dialog is displayed under the correct context + GoRouter.of(context).pop(); + GoRouter.of(context).pop(); + + // Show immediately the pending dialog, when submitting a market order. + // TODO(holzeis): We should only show the dialog once we've received a match. + showDialog( + context: context, + useRootNavigator: true, + barrierDismissible: false, // Prevent user from leaving + builder: (BuildContext context) { + return const TradeDialog(); + }); + } + Wrap buildChildren(Direction direction, rust.TradeConstraints channelTradeConstraints, BuildContext context, ChannelInfoService channelInfoService, GlobalKey formKey) { final tradeValues = context.read().fromDirection(direction); @@ -167,8 +207,6 @@ class _TradeBottomSheetTabState extends State { "\nWith your current balance, the maximum you can trade is ${formatUsd(Usd(maxQuantity.toInt()))}"; } - var amountFormatter = NumberFormat.compact(locale: "en_UK"); - return Wrap( runSpacing: 12, children: [ @@ -279,9 +317,6 @@ class _TradeBottomSheetTabState extends State { return AmountTextField( value: margin, label: "Margin (sats)", - error: channelTradeConstraints.minMargin > margin.sats - ? "Min margin is ${amountFormatter.format(channelTradeConstraints.minMargin)} sats" - : null, suffixIcon: showCapacityInfo ? ModalBottomSheetInfo( closeButtonText: "Back to order", diff --git a/mobile/lib/features/trade/trade_screen.dart b/mobile/lib/features/trade/trade_screen.dart index a88caa5d2..784677235 100644 --- a/mobile/lib/features/trade/trade_screen.dart +++ b/mobile/lib/features/trade/trade_screen.dart @@ -150,6 +150,7 @@ class TradeScreen extends StatelessWidget { tradeBottomSheetConfirmation( context: context, direction: direction, + channelOpeningParams: null, onConfirmation: () { submitOrderChangeNotifier.closePosition( position, tradeValues.price, tradeValues.fee); @@ -167,7 +168,7 @@ class TradeScreen extends StatelessWidget { return const TradeDialog(); }); }, - close: true); + tradeAction: TradeAction.closePosition); }, ); }, diff --git a/mobile/lib/logger/logger.dart b/mobile/lib/logger/logger.dart index 680d24f38..c97d21884 100644 --- a/mobile/lib/logger/logger.dart +++ b/mobile/lib/logger/logger.dart @@ -22,3 +22,16 @@ void buildLogger(bool isLogLevelTrace) { AppLogger.instance = logger; } + +void buildTestLogger(bool isLogLevelTrace) { + final logger = Logger( + filter: ProductionFilter(), + level: isLogLevelTrace ? Level.trace : Level.debug, + printer: SimpleUTCPrinter( + // Colorful log messages + colors: false, + // Should each log print contain a timestamp + printTime: true)); + + AppLogger.instance = logger; +} diff --git a/mobile/lib/util/constants.dart b/mobile/lib/util/constants.dart index 2e30209fa..5dc65efa5 100644 --- a/mobile/lib/util/constants.dart +++ b/mobile/lib/util/constants.dart @@ -23,6 +23,7 @@ const _stable = "stable/"; const _bottomSheet = "bottom_sheet/"; const _confirmSheet = "confirm/"; +const _channelConfig = "channel_config/"; // concrete selectors @@ -30,6 +31,8 @@ const _buy = "buy"; const _sell = "sell"; const _positions = "positions"; const _orders = "orders"; +const _configureChannel = "configure_channel"; +const _openChannel = "open_channel"; const tradeScreenTabsOrders = Key(_trade + _tabs + _orders); const tradeScreenTabsPositions = Key(_trade + _tabs + _positions); @@ -43,6 +46,12 @@ 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 + _configureChannel); + +const tradeScreenBottomSheetConfirmationConfigureChannelSlider = + Key(_trade + _bottomSheet + _confirmSheet + _channelConfig + _slider + _openChannel); + const tradeScreenBottomSheetConfirmationSliderBuy = Key(_trade + _bottomSheet + _confirmSheet + _slider + _buy); const tradeScreenBottomSheetConfirmationSliderSell = diff --git a/mobile/native/migrations/2024-02-07-031110_add_channel_opening_params_table/down.sql b/mobile/native/migrations/2024-02-07-031110_add_channel_opening_params_table/down.sql new file mode 100644 index 000000000..0fb28e843 --- /dev/null +++ b/mobile/native/migrations/2024-02-07-031110_add_channel_opening_params_table/down.sql @@ -0,0 +1 @@ +DROP TABLE "channel_opening_params"; diff --git a/mobile/native/migrations/2024-02-07-031110_add_channel_opening_params_table/up.sql b/mobile/native/migrations/2024-02-07-031110_add_channel_opening_params_table/up.sql new file mode 100644 index 000000000..83f3b07dc --- /dev/null +++ b/mobile/native/migrations/2024-02-07-031110_add_channel_opening_params_table/up.sql @@ -0,0 +1,6 @@ +CREATE TABLE "channel_opening_params" ( + order_id TEXT PRIMARY KEY NOT NULL, + coordinator_reserve BIGINT NOT NULL, + trader_reserve BIGINT NOT NULL, + created_at BIGINT NOT NULL +); diff --git a/mobile/native/src/api.rs b/mobile/native/src/api.rs index 68fbb7c31..2a81d0785 100644 --- a/mobile/native/src/api.rs +++ b/mobile/native/src/api.rs @@ -17,6 +17,7 @@ use crate::event::api::FlutterSubscriber; use crate::health; use crate::ln_dlc; use crate::ln_dlc::get_storage; +use crate::ln_dlc::ChannelOpeningParams; use crate::ln_dlc::FUNDING_TX_WEIGHT_ESTIMATE; use crate::logger; use crate::orderbook; @@ -333,12 +334,34 @@ pub fn order_matching_fee(quantity: f32, price: f32) -> SyncReturn { #[tokio::main(flavor = "current_thread")] pub async fn submit_order(order: NewOrder) -> Result { - order::handler::submit_order(order.into()) + order::handler::submit_order(order.into(), None) .await .map_err(anyhow::Error::new) .map(|id| id.to_string()) } +#[tokio::main(flavor = "current_thread")] +pub async fn submit_channel_opening_order( + order: NewOrder, + coordinator_reserve: u64, + trader_reserve: u64, +) -> Result { + let order = crate::trade::order::Order::from(order); + let order_id = order.id; + + order::handler::submit_order( + order, + Some(ChannelOpeningParams { + order_id, + 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()) +} + #[tokio::main(flavor = "current_thread")] pub async fn get_orders() -> Result> { let orders = order::handler::get_orders_for_ui() diff --git a/mobile/native/src/channel_trade_constraints.rs b/mobile/native/src/channel_trade_constraints.rs index 9a6595d7c..66a12c5e6 100644 --- a/mobile/native/src/channel_trade_constraints.rs +++ b/mobile/native/src/channel_trade_constraints.rs @@ -26,6 +26,7 @@ pub fn channel_trade_constraints() -> Result { let dlc_channels = ln_dlc::get_signed_dlc_channels()?; + // FIXME: This doesn't work if the channel is in `Closing` and related states. let maybe_channel = dlc_channels.first(); let trade_constraints = match maybe_channel { diff --git a/mobile/native/src/db/mod.rs b/mobile/native/src/db/mod.rs index 7c62685f4..e6c371438 100644 --- a/mobile/native/src/db/mod.rs +++ b/mobile/native/src/db/mod.rs @@ -1,6 +1,7 @@ use crate::config; use crate::db::models::base64_engine; use crate::db::models::Channel; +use crate::db::models::ChannelOpeningParams; use crate::db::models::FailureReason; use crate::db::models::NewTrade; use crate::db::models::Order; @@ -559,8 +560,6 @@ pub fn get_all_transactions_without_fees() -> Result Result> { let mut db = connection()?; @@ -602,3 +601,21 @@ pub fn delete_answered_poll_cache() -> Result<()> { polls::delete_all(&mut db)?; Ok(()) } + +pub fn insert_channel_opening_params( + conn: &mut SqliteConnection, + channel_opening_params: crate::ln_dlc::ChannelOpeningParams, +) -> Result<()> { + ChannelOpeningParams::insert(conn, channel_opening_params.into())?; + + Ok(()) +} + +pub fn get_channel_opening_params_by_order_id( + order_id: Uuid, +) -> Result> { + let mut db = connection()?; + let params = ChannelOpeningParams::get_by_order_id(&mut db, order_id)?; + + Ok(params.map(crate::ln_dlc::ChannelOpeningParams::from)) +} diff --git a/mobile/native/src/db/models.rs b/mobile/native/src/db/models.rs index ed8795172..afc4b0ce8 100644 --- a/mobile/native/src/db/models.rs +++ b/mobile/native/src/db/models.rs @@ -1,4 +1,5 @@ use crate::schema; +use crate::schema::channel_opening_params; use crate::schema::channels; use crate::schema::orders; use crate::schema::payments; @@ -1463,6 +1464,61 @@ impl From for crate::trade::Trade { } } +#[derive(Queryable, QueryableByName, Insertable, Debug, Clone, PartialEq)] +#[diesel(table_name = channel_opening_params)] +pub struct ChannelOpeningParams { + order_id: String, + coordinator_reserve: i64, + trader_reserve: i64, + created_at: i64, +} + +impl ChannelOpeningParams { + pub fn insert( + conn: &mut SqliteConnection, + channel_opening_params: ChannelOpeningParams, + ) -> Result<()> { + let affected_rows = diesel::insert_into(channel_opening_params::table) + .values(channel_opening_params) + .execute(conn)?; + + ensure!(affected_rows > 0, "Could not insert channel-opening params"); + + Ok(()) + } + + pub fn get_by_order_id( + conn: &mut SqliteConnection, + order_id: Uuid, + ) -> QueryResult> { + channel_opening_params::table + .filter(channel_opening_params::order_id.eq(order_id.to_string())) + .first(conn) + .optional() + } +} + +impl From for ChannelOpeningParams { + fn from(value: crate::ln_dlc::ChannelOpeningParams) -> Self { + Self { + order_id: value.order_id.to_string(), + coordinator_reserve: value.coordinator_reserve.to_sat() as i64, + trader_reserve: value.trader_reserve.to_sat() as i64, + created_at: OffsetDateTime::now_utc().unix_timestamp(), + } + } +} + +impl From for crate::ln_dlc::ChannelOpeningParams { + fn from(value: ChannelOpeningParams) -> Self { + Self { + order_id: Uuid::parse_str(value.order_id.as_str()).expect("valid UUID"), + coordinator_reserve: Amount::from_sat(value.coordinator_reserve as u64), + trader_reserve: Amount::from_sat(value.trader_reserve as u64), + } + } +} + #[cfg(test)] pub mod test { use super::*; @@ -1970,4 +2026,23 @@ pub mod test { let transactions = Transaction::get_all_without_fees(&mut connection).unwrap(); assert_eq!(1, transactions.len()) } + + #[test] + fn channel_opening_params_round_trip() { + let mut connection = SqliteConnection::establish(":memory:").unwrap(); + connection.run_pending_migrations(MIGRATIONS).unwrap(); + + let params = crate::ln_dlc::ChannelOpeningParams { + order_id: Uuid::from_str("9b05e1d2-6895-4c6e-a929-ee2094a702c8").unwrap(), + coordinator_reserve: Amount::from_sat(10_000), + trader_reserve: Amount::from_sat(20_000), + }; + + ChannelOpeningParams::insert(&mut connection, params.into()).unwrap(); + + let loaded_params = ChannelOpeningParams::get_by_order_id(&mut connection, params.order_id) + .unwrap() + .unwrap(); + assert_eq!(params, loaded_params.into()); + } } diff --git a/mobile/native/src/lib.rs b/mobile/native/src/lib.rs index 23c305923..2e0fb0552 100644 --- a/mobile/native/src/lib.rs +++ b/mobile/native/src/lib.rs @@ -14,7 +14,15 @@ pub mod schema; pub mod state; mod backup; +mod channel_trade_constraints; +mod cipher; +mod destination; +mod dlc_channel; +mod dlc_handler; +mod names; mod orderbook; +mod polls; +mod storage; #[allow( clippy::all, @@ -23,11 +31,3 @@ mod orderbook; unused_qualifications )] mod bridge_generated; -mod channel_trade_constraints; -mod cipher; -mod destination; -mod dlc_channel; -mod dlc_handler; -mod names; -mod polls; -mod storage; diff --git a/mobile/native/src/ln_dlc/mod.rs b/mobile/native/src/ln_dlc/mod.rs index 7ccd55e87..7ab7682c1 100644 --- a/mobile/native/src/ln_dlc/mod.rs +++ b/mobile/native/src/ln_dlc/mod.rs @@ -54,7 +54,7 @@ use commons::CollaborativeRevertTraderResponse; use commons::LegacyCollaborativeRevertTraderResponse; use commons::OnboardingParam; use commons::RouteHintHop; -use commons::TradeParams; +use commons::TradeAndChannelParams; use dlc::PartyParams; use dlc_manager::channel::Channel as DlcChannel; use dlc_manager::subchannel::LnDlcChannelSigner; @@ -105,10 +105,12 @@ use tokio::runtime::Runtime; use tokio::sync::watch; use tokio::task::spawn_blocking; use trade::ContractSymbol; +use uuid::Uuid; -mod lightning_subscriber; pub mod node; +mod lightning_subscriber; + const PROCESS_INCOMING_DLC_MESSAGES_INTERVAL: Duration = Duration::from_millis(200); const UPDATE_WALLET_HISTORY_INTERVAL: Duration = Duration::from_secs(5); const CHECK_OPEN_ORDERS_INTERVAL: Duration = Duration::from_secs(60); @@ -126,6 +128,17 @@ const NUMBER_OF_CONFIRMATION_FOR_BEING_CONFIRMED: u64 = 1; /// exact fee will be know. pub const FUNDING_TX_WEIGHT_ESTIMATE: u64 = 220; +/// Extra information required to open a DLC channel, independent of the [`TradeParams`] associated +/// with the filled order. +/// +/// [`TradeParams`]: commons::TradeParams +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ChannelOpeningParams { + pub order_id: Uuid, + pub coordinator_reserve: Amount, + pub trader_reserve: Amount, +} + /// Triggers an update to the wallet balance and history, without an on-chain sync. pub fn refresh_lightning_wallet() -> Result<()> { let node = state::get_node(); @@ -862,7 +875,7 @@ fn update_state_after_collab_revert( Ok(order) => order, Err(_) => { let order = Order { - id: uuid::Uuid::new_v4(), + id: Uuid::new_v4(), leverage: position.leverage, quantity: position.quantity, contract_symbol: position.contract_symbol, @@ -1093,7 +1106,7 @@ fn update_state_after_legacy_collab_revert( Ok(order) => order, Err(_) => { let order = Order { - id: uuid::Uuid::new_v4(), + id: Uuid::new_v4(), leverage: position.leverage, quantity: position.quantity, contract_symbol: position.contract_symbol, @@ -1438,7 +1451,9 @@ pub async fn estimate_payment_fee_msat(payment: SendPayment) -> Result { } } -pub async fn trade(trade_params: TradeParams) -> Result<(), (FailureReason, anyhow::Error)> { +pub async fn trade( + trade_params: TradeAndChannelParams, +) -> Result<(), (FailureReason, anyhow::Error)> { let client = reqwest_client(); let response = client .post(format!("http://{}/api/trade", config::get_http_endpoint())) diff --git a/mobile/native/src/schema.rs b/mobile/native/src/schema.rs index 505a50e17..c7f4b4fad 100644 --- a/mobile/native/src/schema.rs +++ b/mobile/native/src/schema.rs @@ -1,5 +1,14 @@ // @generated automatically by Diesel CLI. +diesel::table! { + channel_opening_params (order_id) { + order_id -> Text, + coordinator_reserve -> BigInt, + trader_reserve -> BigInt, + created_at -> BigInt, + } +} + diesel::table! { answered_polls (id) { id -> Integer, @@ -143,6 +152,7 @@ diesel::joinable!(last_outbound_dlc_messages -> dlc_messages (message_hash)); diesel::allow_tables_to_appear_in_same_query!( answered_polls, + channel_opening_params, channels, dlc_messages, ignored_polls, diff --git a/mobile/native/src/trade/order/handler.rs b/mobile/native/src/trade/order/handler.rs index 22e759c17..f3da80f1b 100644 --- a/mobile/native/src/trade/order/handler.rs +++ b/mobile/native/src/trade/order/handler.rs @@ -5,6 +5,8 @@ use crate::db::maybe_get_open_orders; use crate::event; use crate::event::EventInternal; use crate::ln_dlc::is_dlc_channel_confirmed; +use crate::ln_dlc::ChannelOpeningParams; +use crate::state::get_or_create_tokio_runtime; use crate::trade::order::orderbook_client::OrderbookClient; use crate::trade::order::FailureReason; use crate::trade::order::Order; @@ -16,6 +18,7 @@ use anyhow::anyhow; use anyhow::bail; use anyhow::Context; use anyhow::Result; +use diesel::Connection; use reqwest::Url; use time::Duration; use time::OffsetDateTime; @@ -46,7 +49,10 @@ pub enum SubmitOrderError { Orderbook(anyhow::Error), } -pub async fn submit_order(order: Order) -> Result { +pub async fn submit_order( + order: Order, + channel_opening_params: Option, +) -> Result { // If we have an open position, we should not allow any further trading until the current DLC // channel is confirmed on-chain. Otherwise we can run into pesky DLC protocol failures. if position::handler::get_positions() @@ -84,7 +90,34 @@ pub async fn submit_order(order: Order) -> Result { db::insert_order(order.clone()).map_err(SubmitOrderError::Storage)?; - if let Err(err) = orderbook_client.post_new_order(order.clone().into()).await { + let runtime = get_or_create_tokio_runtime().expect("to be ablet to get runtime"); + if let Err(err) = runtime + .spawn_blocking({ + let order = order.clone(); + move || { + let mut db = db::connection()?; + // We need to ensure that the channel-opening parameters are inserted after the + // order has been posted, but before order has been match and we need them to open + // the DLC channel. Without this database transaction we can easily end up with a + // match before we insert them! + db.transaction(|conn| { + if let Some(channel_opening_params) = channel_opening_params { + tracing::debug!( + ?channel_opening_params, + "Recording channel-opening parameters" + ); + + db::insert_channel_opening_params(conn, channel_opening_params) + .map_err(SubmitOrderError::Storage)?; + } + + runtime.block_on(orderbook_client.post_new_order(order.into())) + }) + } + }) + .await + .expect("task to complete") + { let order_id = order.id.clone().to_string(); tracing::error!(order_id, "Failed to post new order: {err:#}"); diff --git a/mobile/native/src/trade/order/mod.rs b/mobile/native/src/trade/order/mod.rs index d72c3f965..53986f60a 100644 --- a/mobile/native/src/trade/order/mod.rs +++ b/mobile/native/src/trade/order/mod.rs @@ -151,6 +151,7 @@ pub struct Order { pub order_expiry_timestamp: OffsetDateTime, pub reason: OrderReason, pub stable: bool, + // FIXME: Why is this failure_reason duplicated? It's also in the `order_state`? pub failure_reason: Option, } diff --git a/mobile/native/src/trade/position/handler.rs b/mobile/native/src/trade/position/handler.rs index 3efdd7302..3c1daadd7 100644 --- a/mobile/native/src/trade/position/handler.rs +++ b/mobile/native/src/trade/position/handler.rs @@ -15,21 +15,35 @@ use anyhow::Context; use anyhow::Result; use commons::FilledWith; use commons::Prices; +use commons::TradeAndChannelParams; use commons::TradeParams; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use time::OffsetDateTime; use trade::ContractSymbol; -/// Sets up a trade with the counterparty +/// Sets up a trade with the counterparty. /// -/// In a success scenario this results in creating, updating or deleting a position. -/// The DLC that represents the position will be stored in the database. -/// Errors are handled within the scope of this function. +/// In a success scenario, this results in creating, updating or deleting a position. pub async fn trade(filled: FilledWith) -> Result<()> { let order = db::get_order(filled.order_id).context("Could not load order from db")?; - tracing::debug!(?order, ?filled, "Filling order with id: {}", order.id); + tracing::debug!(?order, ?filled, "Filling order"); + + let (coordinator_reserve, trader_reserve) = + match db::get_channel_opening_params_by_order_id(filled.order_id) + .context("Could not load channel-opening params from db")? + { + Some(channel_opening_params) => { + tracing::debug!(?channel_opening_params, "Found channel-opening parameters"); + + ( + Some(channel_opening_params.coordinator_reserve), + Some(channel_opening_params.trader_reserve), + ) + } + None => (None, None), + }; let trade_params = TradeParams { pubkey: ln_dlc::get_node_pubkey(), @@ -69,7 +83,13 @@ pub async fn trade(filled: FilledWith) -> Result<()> { } } - if let Err((reason, e)) = ln_dlc::trade(trade_params).await { + let params = TradeAndChannelParams { + trade_params, + coordinator_reserve, + trader_reserve, + }; + + if let Err((reason, e)) = ln_dlc::trade(params).await { order::handler::order_failed(Some(order.id), reason, e) .context("Could not set order to failed")?; } @@ -119,7 +139,15 @@ pub async fn async_trade(order: commons::Order, filled_with: FilledWith) -> Resu filled_with, }; - if let Err((reason, e)) = ln_dlc::trade(trade_params).await { + let params = TradeAndChannelParams { + trade_params, + // An "async" trade is only triggered to close and expired position. We don't need + // channel-opening parameters to close a position. + coordinator_reserve: None, + trader_reserve: None, + }; + + if let Err((reason, e)) = ln_dlc::trade(params).await { order::handler::order_failed(Some(order.id), reason, e) .context("Could not set order to failed")?; } diff --git a/mobile/test/trade_test.dart b/mobile/test/trade_test.dart index ce79a6127..4e18d06f6 100644 --- a/mobile/test/trade_test.dart +++ b/mobile/test/trade_test.dart @@ -6,6 +6,11 @@ import 'package:get_10101/common/amount_denomination_change_notifier.dart'; @GenerateNiceMocks([MockSpec()]) import 'package:get_10101/common/application/channel_info_service.dart'; import 'package:get_10101/common/application/lsp_change_notifier.dart'; +@GenerateNiceMocks([MockSpec()]) +import 'package:get_10101/common/dlc_channel_service.dart'; +import 'package:get_10101/common/dlc_channel_change_notifier.dart'; +import 'package:get_10101/common/domain/channel.dart'; +import 'package:get_10101/common/domain/dlc_channel.dart'; import 'package:get_10101/common/domain/model.dart'; @GenerateNiceMocks([MockSpec()]) import 'package:get_10101/features/trade/application/candlestick_service.dart'; @@ -34,6 +39,7 @@ import 'package:go_router/go_router.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:provider/provider.dart'; +import 'package:slide_to_confirm/slide_to_confirm.dart'; import 'trade_test.mocks.dart'; @@ -71,16 +77,18 @@ class TestWrapperWithTradeTheme extends StatelessWidget { } void main() { - testWidgets('Given trade screen when completing buy flow then market order is submitted', - (tester) async { - MockOrderService orderService = MockOrderService(); - MockPositionService positionService = MockPositionService(); - MockTradeValuesService tradeValueService = MockTradeValuesService(); - MockChannelInfoService channelConstraintsService = MockChannelInfoService(); - MockWalletService walletService = MockWalletService(); - MockCandlestickService candlestickService = MockCandlestickService(); - buildLogger(true); + buildTestLogger(true); + + MockPositionService positionService = MockPositionService(); + MockTradeValuesService tradeValueService = MockTradeValuesService(); + MockChannelInfoService channelConstraintsService = MockChannelInfoService(); + MockWalletService walletService = MockWalletService(); + MockCandlestickService candlestickService = MockCandlestickService(); + 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 when(tradeValueService.calculateMargin( @@ -146,6 +154,8 @@ void main() { LspChangeNotifier lspChangeNotifier = LspChangeNotifier(channelConstraintsService); + DlcChannelChangeNotifier dlcChannelChangeNotifier = DlcChannelChangeNotifier(dlcChannelService); + final tradeValuesChangeNotifier = TradeValuesChangeNotifier(tradeValueService); final price = Price(bid: 30000.0, ask: 30000.0); @@ -161,7 +171,8 @@ void main() { ChangeNotifierProvider(create: (context) => AmountDenominationChangeNotifier()), ChangeNotifierProvider(create: (context) => walletChangeNotifier), ChangeNotifierProvider(create: (context) => candlestickChangeNotifier), - ChangeNotifierProvider(create: (context) => lspChangeNotifier) + ChangeNotifierProvider(create: (context) => lspChangeNotifier), + 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 @@ -182,10 +193,138 @@ void main() { await tester.tap(find.byKey(tradeScreenBottomSheetButtonBuy)); await tester.pumpAndSettle(); - expect(find.byKey(tradeScreenBottomSheetConfirmationSliderButtonBuy), findsOneWidget); + expect(find.byKey(tradeScreenBottomSheetChannelConfigurationConfirmButton), findsOneWidget); + + // click confirm button to go to confirmation screen + await tester.tap(find.byKey(tradeScreenBottomSheetChannelConfigurationConfirmButton)); + await tester.pumpAndSettle(); + + // TODO: Use `find.byKey(tradeScreenBottomSheetConfirmationConfigureChannelSlider)`. + // For some reason the specific widget cannot be found. + expect(find.byType(ConfirmationSlider), findsOneWidget); - // TODO: This is not optimal because if we re-style the component this test will likely break. // Drag to confirm + + // 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)); + await tester.timedDragFrom( + sliderLocation + const Offset(10, -15), const Offset(280, 0), const Duration(seconds: 2), + pointer: 7); + + verify(orderService.submitChannelOpeningMarketOrder(any, any, any, any, any, any, any)) + .called(1); + }); + + testWidgets('Trade with open channel', (tester) async { + when(tradeValueService.calculateMargin( + price: anyNamed('price'), + quantity: anyNamed('quantity'), + leverage: anyNamed('leverage'))) + .thenReturn(Amount(1000)); + 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(Amount(1)); + when(tradeValueService.getExpiryTimestamp()).thenReturn(DateTime.now()); + when(tradeValueService.orderMatchingFee( + quantity: anyNamed('quantity'), price: anyNamed('price'))) + .thenReturn(Amount(42)); + + when(channelConstraintsService.getChannelInfo()).thenAnswer((_) async { + return ChannelInfo(Amount(100000), Amount(100000), null); + }); + + when(channelConstraintsService.getMaxCapacity()).thenAnswer((_) async { + return Amount(20000); + }); + + when(channelConstraintsService.getMinTradeMargin()).thenReturn(Amount(1000)); + + when(channelConstraintsService.getInitialReserve()).thenReturn(Amount(1000)); + + when(channelConstraintsService.getContractTxFeeRate()).thenAnswer((_) async { + return 1; + }); + + when(channelConstraintsService.getTradeConstraints()).thenAnswer((_) => + const bridge.TradeConstraints( + maxLocalMarginSats: 20000000000, + maxCounterpartyMarginSats: 200000000000, + coordinatorLeverage: 2, + minQuantity: 1, + isChannelBalance: true, + minMargin: 1)); + + when(candlestickService.fetchCandles(1000)).thenAnswer((_) async { + return getDummyCandles(1000); + }); + when(candlestickService.fetchCandles(1)).thenAnswer((_) async { + return getDummyCandles(1); + }); + + when(dlcChannelService.getDlcChannels()).thenAnswer((_) async { + return List.filled(1, DlcChannel(id: "foo", state: ChannelState.signed)); + }); + + CandlestickChangeNotifier candlestickChangeNotifier = + CandlestickChangeNotifier(candlestickService); + candlestickChangeNotifier.initialize(); + + SubmitOrderChangeNotifier submitOrderChangeNotifier = SubmitOrderChangeNotifier(orderService); + + WalletChangeNotifier walletChangeNotifier = WalletChangeNotifier(walletService); + + PositionChangeNotifier positionChangeNotifier = PositionChangeNotifier(positionService); + + LspChangeNotifier lspChangeNotifier = LspChangeNotifier(channelConstraintsService); + + DlcChannelChangeNotifier dlcChannelChangeNotifier = DlcChannelChangeNotifier(dlcChannelService); + + final tradeValuesChangeNotifier = TradeValuesChangeNotifier(tradeValueService); + + final price = Price(bid: 30000.0, ask: 30000.0); + // We have to have current price, otherwise we can't take order + positionChangeNotifier.price = price; + tradeValuesChangeNotifier.updatePrice(price); + + 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) => candlestickChangeNotifier), + ChangeNotifierProvider(create: (context) => lspChangeNotifier), + ChangeNotifierProvider(create: (context) => dlcChannelChangeNotifier), + ], child: const TestWrapperWithTradeTheme(child: TradeScreen()))); + + dlcChannelChangeNotifier.refreshDlcChannels(); + + // 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(0), offChain: Amount(10000)), history: [])); + + await tester.pumpAndSettle(); + + expect(find.byKey(tradeScreenButtonBuy), findsOneWidget); + + // Open bottom sheet + await tester.tap(find.byKey(tradeScreenButtonBuy)); + await tester.pumpAndSettle(); + + expect(find.byKey(tradeScreenBottomSheetButtonBuy), findsOneWidget); + + // click buy button in bottom sheet + await tester.tap(find.byKey(tradeScreenBottomSheetButtonBuy)); + await tester.pumpAndSettle(); + + expect(find.byKey(tradeScreenBottomSheetConfirmationSliderButtonBuy), findsOneWidget); + await tester.timedDrag(find.byKey(tradeScreenBottomSheetConfirmationSliderButtonBuy), const Offset(275, 0), const Duration(seconds: 2), pointer: 7); diff --git a/webapp/src/api.rs b/webapp/src/api.rs index 8eb1bc963..a876a39d9 100644 --- a/webapp/src/api.rs +++ b/webapp/src/api.rs @@ -20,6 +20,8 @@ use native::api::SendPayment; use native::api::WalletHistoryItemType; use native::calculations::calculate_pnl; use native::ln_dlc; +use native::ln_dlc::is_dlc_channel_confirmed; +use native::ln_dlc::ChannelOpeningParams; use native::trade::order::FailureReason; use native::trade::order::InvalidSubchannelOffer; use native::trade::order::OrderType; @@ -212,13 +214,26 @@ impl TryFrom for native::trade::order::Order { } pub async fn post_new_order(params: Json) -> Result, AppError> { - let order_id = native::trade::order::handler::submit_order( - params - .0 - .try_into() - .context("Could not parse order request")?, - ) - .await?; + let order: native::trade::order::Order = params + .0 + .try_into() + .context("Could not parse order request")?; + + let is_dlc_channel_confirmed = is_dlc_channel_confirmed()?; + + let channel_opening_params = if is_dlc_channel_confirmed { + None + } else { + Some(ChannelOpeningParams { + order_id: order.id, + // TODO: Allow webapp to open a DLC channel with additional reserve. + coordinator_reserve: Amount::ZERO, + trader_reserve: Amount::ZERO, + }) + }; + + let order_id = + native::trade::order::handler::submit_order(order, channel_opening_params).await?; Ok(Json(OrderId { id: order_id })) } From 782843e65dc4bcce8f644e7d8d6058e119c6ed28 Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Fri, 9 Feb 2024 16:54:37 +1100 Subject: [PATCH 2/2] chore(webapp): Clarify README.md In particular, how to run the `webapp` with and without TLS. And where to find the website based on that. --- webapp/README.md | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/webapp/README.md b/webapp/README.md index 837d5f8a7..c98b21c7f 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -1,4 +1,4 @@ -# A simple webfrontend to be self-hosted +# A simple web frontend to be self-hosted ## Run frontend only in dev mode @@ -7,31 +7,43 @@ cd frontend flutter run -d chrome ``` -## Build the frontend to be served by rust +## Build the frontend to be served by Rust ```bash flutter build web ``` -## Run the rust app +## Run the Rust app + +### With TLS + +```bash +cargo run -- --cert-dir certs --data-dir ../data --secure +``` + +The web interface will be reachable under `https://localhost:3001`. + +### Without TLS ```bash cargo run -- --cert-dir certs --data-dir ../data ``` -The webinterface will be reachable under `https://localhost:3001` +The web interface will be reachable under `http://localhost:3001` + +### Troubleshooting -Note: if you can't see anything, you probably forgot to run `flutter build web` before +If you can't see anything, you probably forgot to run `flutter build web` before. -## How to use curl with webapp +## How to interact with the backend with `curl` -We need to store cookies between two curl calls, for that you can use the cookie jar of curl: +We need to store cookies between `curl` calls. For that you can use the `curl`'s cookie jar: ```bash curl -b .cookie-jar.txt -c .cookie-jar.txt -X POST http://localhost:3001/api/login -d '{ "password": "satoshi" }' -H "Content-Type: application/json" -v ``` -This will read and store the cookies in `.cookie-jar.txt`. So on the next call you can reference it the same way, e.g. +This will read and store the cookies in `.cookie-jar.txt`. So on the next call you can reference it the same way: ```bash curl -b .cookie-jar.txt -c .cookie-jar.txt http://localhost:3001/api/balance