From c37289dfe107d73ee8cdddbff0e385a7bd42aa3e Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Mon, 22 Jan 2024 13:11:30 +0100 Subject: [PATCH 1/9] chore: ingore data dir from webapp Signed-off-by: Philipp Hoenisch --- webapp/.gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/webapp/.gitignore b/webapp/.gitignore index 4770a1ba7..6f4e8a44b 100644 --- a/webapp/.gitignore +++ b/webapp/.gitignore @@ -110,3 +110,6 @@ Cargo.lock *.pdb # End of https://www.toptal.com/developers/gitignore/api/flutter,dart,rust + +# data directory for webapp +data From 101cfbfcee359c76b2e0466af6cbbd654eefbfcb Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Mon, 22 Jan 2024 13:13:13 +0100 Subject: [PATCH 2/9] feat: allow creating new order through webapp Signed-off-by: Philipp Hoenisch --- CHANGELOG.md | 1 + Cargo.lock | 3 + webapp/Cargo.toml | 3 + .../frontend/lib/trade/new_order_service.dart | 48 ++++++++++++++ .../lib/trade/trade_screen_order_form.dart | 12 +++- webapp/src/api.rs | 63 +++++++++++++++++++ webapp/src/main.rs | 2 + 7 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 webapp/frontend/lib/trade/new_order_service.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 141e028d6..7ccd8f6a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Feat: Allow continuing from an offered dlc channel state (offered, settle offered and collab close offered) - Feat: add a new project `webapp`. Eventually this will have the same functionality as our app (and more) and can be run on a self-hosted server - Chore: In Webapp API allow requests from any origin (CORS) +- Feat: Allow creating new orders through `webapp` ## [1.7.4] - 2023-12-20 diff --git a/Cargo.lock b/Cargo.lock index c5443ada7..c4697b96c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4632,6 +4632,8 @@ dependencies = [ "native", "parking_lot 0.12.1", "rust-embed", + "rust_decimal", + "rust_decimal_macros", "serde", "serde_json", "time", @@ -4640,6 +4642,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/webapp/Cargo.toml b/webapp/Cargo.toml index e5e6c396c..0e861876a 100644 --- a/webapp/Cargo.toml +++ b/webapp/Cargo.toml @@ -16,6 +16,8 @@ mime_guess = "2.0.4" native = { path = "../mobile/native" } parking_lot = { version = "0.12.1" } rust-embed = "8.2.0" +rust_decimal = { version = "1", features = ["serde-with-float"] } +rust_decimal_macros = "1" serde = "1.0.147" serde_json = "1" time = "0.3" @@ -24,3 +26,4 @@ tower = { version = "0.4", features = ["util"] } tower-http = { version = "0.5", features = ["fs", "trace", "cors"] } tracing = "0.1.37" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1.3.0", features = ["v4"] } diff --git a/webapp/frontend/lib/trade/new_order_service.dart b/webapp/frontend/lib/trade/new_order_service.dart new file mode 100644 index 000000000..39a9f118b --- /dev/null +++ b/webapp/frontend/lib/trade/new_order_service.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:get_10101/common/model.dart'; +import 'package:get_10101/logger/logger.dart'; +import 'package:http/http.dart' as http; + +class OrderId { + final String orderId; + + const OrderId({required this.orderId}); + + factory OrderId.fromJson(Map json) { + return switch (json) { + { + 'id': String orderId, + } => + OrderId(orderId: orderId), + _ => throw const FormatException('Failed to parse order id.'), + }; + } +} + +class NewOrderService { + const NewOrderService(); + + static Future postNewOrder(Leverage leverage, Usd quantity, bool isLong) async { + // TODO(holzeis): this should come from the config + const port = "3001"; + const host = "localhost"; + + final response = await http.post(Uri.http('$host:$port', '/api/orders'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: jsonEncode({ + 'leverage': leverage.asDouble, + 'quantity': quantity.asDouble, + 'direction': isLong ? "Long" : "Short", + })); + + if (response.statusCode == 200) { + return OrderId.fromJson(jsonDecode(response.body) as Map).orderId; + } else { + throw FlutterError("Failed to post new order. Response ${response.body}"); + } + } +} diff --git a/webapp/frontend/lib/trade/trade_screen_order_form.dart b/webapp/frontend/lib/trade/trade_screen_order_form.dart index 94fcfcc23..c1df02510 100644 --- a/webapp/frontend/lib/trade/trade_screen_order_form.dart +++ b/webapp/frontend/lib/trade/trade_screen_order_form.dart @@ -3,7 +3,10 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get_10101/common/amount_text_input_form_field.dart'; import 'package:get_10101/common/model.dart'; +import 'package:get_10101/common/snack_bar.dart'; import 'package:get_10101/common/theme.dart'; +import 'package:get_10101/logger/logger.dart'; +import 'package:get_10101/trade/new_order_service.dart'; class NewOrderForm extends StatefulWidget { final bool isLong; @@ -116,7 +119,14 @@ class _NewOrderForm extends State { Align( alignment: AlignmentDirectional.center, child: ElevatedButton( - onPressed: () {}, + onPressed: () { + final messenger = ScaffoldMessenger.of(context); + NewOrderService.postNewOrder(_leverage, _quantity!, isBuy).then((orderId) { + showSnackBar(messenger, "Order created $orderId."); + }).catchError((error) { + showSnackBar(messenger, "Posting a new order failed $error"); + }); + }, style: ElevatedButton.styleFrom( backgroundColor: isBuy ? buyButtonColor : sellButtonColor, minimumSize: const Size.fromHeight(50)), diff --git a/webapp/src/api.rs b/webapp/src/api.rs index a8f2a0e1a..13406dcbe 100644 --- a/webapp/src/api.rs +++ b/webapp/src/api.rs @@ -1,17 +1,26 @@ use crate::subscribers::AppSubscribers; +use anyhow::Context; use anyhow::Result; use axum::extract::State; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::response::Response; use axum::Json; +use native::api::ContractSymbol; +use native::api::Direction; use native::api::Fee; use native::api::SendPayment; use native::api::WalletHistoryItemType; use native::ln_dlc; +use native::trade::order::OrderState; +use native::trade::order::OrderType; +use rust_decimal::prelude::ToPrimitive; +use rust_decimal::Decimal; use serde::Deserialize; use serde::Serialize; use std::sync::Arc; +use time::OffsetDateTime; +use uuid::Uuid; pub struct AppError(anyhow::Error); @@ -131,3 +140,57 @@ pub async fn send_payment(params: Json) -> Result<(), AppError> { Ok(()) } + +#[derive(Serialize)] +pub struct OrderId { + id: Uuid, +} + +#[derive(Deserialize)] +pub struct NewOrderParams { + #[serde(with = "rust_decimal::serde::float")] + pub leverage: Decimal, + #[serde(with = "rust_decimal::serde::float")] + pub quantity: Decimal, + pub direction: Direction, +} + +impl TryFrom for native::trade::order::Order { + type Error = anyhow::Error; + fn try_from(value: NewOrderParams) -> Result { + Ok(native::trade::order::Order { + id: Uuid::new_v4(), + leverage: value + .leverage + .to_f32() + .context("To be able to parse leverage into f32")?, + quantity: value + .quantity + .to_f32() + .context("To be able to parse leverage into f32")?, + contract_symbol: ContractSymbol::BtcUsd, + direction: value.direction, + // We only support market orders for now + order_type: OrderType::Market, + state: OrderState::Initial, + creation_timestamp: OffsetDateTime::now_utc(), + // We do not support setting order expiry from the frontend for now + order_expiry_timestamp: OffsetDateTime::now_utc() + time::Duration::minutes(1), + reason: native::trade::order::OrderReason::Manual, + stable: false, + failure_reason: None, + }) + } +} + +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?; + + Ok(Json(OrderId { id: order_id })) +} diff --git a/webapp/src/main.rs b/webapp/src/main.rs index 7e941c7b6..e909402a9 100644 --- a/webapp/src/main.rs +++ b/webapp/src/main.rs @@ -6,6 +6,7 @@ mod subscribers; use crate::api::get_balance; use crate::api::get_onchain_payment_history; use crate::api::get_unused_address; +use crate::api::post_new_order; use crate::api::send_payment; use crate::api::version; use crate::cli::Opts; @@ -95,6 +96,7 @@ fn using_serve_dir(subscribers: Arc, network: Network) -> Router .route("/api/newaddress", get(get_unused_address)) .route("/api/sendpayment", post(send_payment)) .route("/api/history", get(get_onchain_payment_history)) + .route("/api/orders", post(post_new_order)) .route("/main.dart.js", get(main_dart_handler)) .route("/flutter.js", get(flutter_js)) .route("/index.html", get(index_handler)) From 13fb60be4f03a8c028adaec3c8c8b445945bc3a1 Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Mon, 22 Jan 2024 13:21:47 +0100 Subject: [PATCH 3/9] chore: disable post new order button while submitting Signed-off-by: Philipp Hoenisch --- .../lib/trade/trade_screen_order_form.dart | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/webapp/frontend/lib/trade/trade_screen_order_form.dart b/webapp/frontend/lib/trade/trade_screen_order_form.dart index c1df02510..fe3047517 100644 --- a/webapp/frontend/lib/trade/trade_screen_order_form.dart +++ b/webapp/frontend/lib/trade/trade_screen_order_form.dart @@ -28,6 +28,7 @@ class _NewOrderForm extends State { Usd? _quantity; Leverage _leverage = Leverage(1); bool isBuy = true; + bool _isLoading = false; final TextEditingController _marginController = TextEditingController(); final TextEditingController _liquidationPriceController = TextEditingController(); @@ -39,6 +40,7 @@ class _NewOrderForm extends State { _quote = widget.quote; _quantity = Usd(100); isBuy = widget.isLong; + _isLoading = false; updateOrderValues(); } @@ -119,18 +121,25 @@ class _NewOrderForm extends State { Align( alignment: AlignmentDirectional.center, child: ElevatedButton( - onPressed: () { - final messenger = ScaffoldMessenger.of(context); - NewOrderService.postNewOrder(_leverage, _quantity!, isBuy).then((orderId) { - showSnackBar(messenger, "Order created $orderId."); - }).catchError((error) { - showSnackBar(messenger, "Posting a new order failed $error"); - }); - }, + onPressed: _isLoading + ? null + : () { + final messenger = ScaffoldMessenger.of(context); + setState(() => _isLoading = true); + NewOrderService.postNewOrder(_leverage, _quantity!, isBuy).then((orderId) { + showSnackBar(messenger, "Order created $orderId."); + setState(() => _isLoading = false); + }).catchError((error) { + showSnackBar(messenger, "Posting a new order failed $error"); + setState(() => _isLoading = false); + }); + }, style: ElevatedButton.styleFrom( backgroundColor: isBuy ? buyButtonColor : sellButtonColor, minimumSize: const Size.fromHeight(50)), - child: isBuy ? const Text("Buy") : const Text("Sell")), + child: _isLoading + ? const CircularProgressIndicator() + : (isBuy ? const Text("Buy") : const Text("Sell"))), ), ], ); From a6f4cb6537d7d09ba526f0a71d9a08517df751cd Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Mon, 22 Jan 2024 14:05:09 +0100 Subject: [PATCH 4/9] chore: don't rethrow errors with added text This will just add the text from `FlutterError` in front of the already thrown exception. Signed-off-by: Philipp Hoenisch --- .../frontend/lib/wallet/wallet_service.dart | 70 +++++++------------ 1 file changed, 27 insertions(+), 43 deletions(-) diff --git a/webapp/frontend/lib/wallet/wallet_service.dart b/webapp/frontend/lib/wallet/wallet_service.dart index 3ba6af9f0..8c0d0c1b4 100644 --- a/webapp/frontend/lib/wallet/wallet_service.dart +++ b/webapp/frontend/lib/wallet/wallet_service.dart @@ -14,16 +14,12 @@ class WalletService { const port = "3001"; const host = "localhost"; - try { - final response = await http.get(Uri.http('$host:$port', '/api/balance')); + final response = await http.get(Uri.http('$host:$port', '/api/balance')); - if (response.statusCode == 200) { - return Balance.fromJson(jsonDecode(response.body) as Map); - } else { - throw FlutterError("Failed to fetch balance"); - } - } catch (e) { - throw FlutterError("Failed to fetch balance. $e"); + if (response.statusCode == 200) { + return Balance.fromJson(jsonDecode(response.body) as Map); + } else { + throw FlutterError("Failed to fetch balance"); } } @@ -32,16 +28,12 @@ class WalletService { const port = "3001"; const host = "localhost"; - try { - final response = await http.get(Uri.http('$host:$port', '/api/newaddress')); + final response = await http.get(Uri.http('$host:$port', '/api/newaddress')); - if (response.statusCode == 200) { - return response.body; - } else { - throw FlutterError("Failed to fetch new address"); - } - } catch (e) { - throw FlutterError("Failed to fetch new address. $e"); + if (response.statusCode == 200) { + return response.body; + } else { + throw FlutterError("Failed to fetch new address"); } } @@ -50,19 +42,15 @@ class WalletService { const port = "3001"; const host = "localhost"; - try { - final response = await http.post(Uri.http('$host:$port', '/api/sendpayment'), - headers: { - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: jsonEncode( - {'address': address, 'amount': amount.sats, 'fee': fee.sats})); + final response = await http.post(Uri.http('$host:$port', '/api/sendpayment'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: jsonEncode( + {'address': address, 'amount': amount.sats, 'fee': fee.sats})); - if (response.statusCode != 200) { - throw FlutterError("Failed to send payment"); - } - } catch (e) { - throw FlutterError("Failed to send payment. $e"); + if (response.statusCode != 200) { + throw FlutterError("Failed to send payment"); } } @@ -71,21 +59,17 @@ class WalletService { const port = "3001"; const host = "localhost"; - try { - final response = await http.get(Uri.http('$host:$port', '/api/history')); + final response = await http.get(Uri.http('$host:$port', '/api/history')); - if (response.statusCode == 200) { - List history = []; - Iterable list = json.decode(response.body); - for (int i = 0; i < list.length; i++) { - history.add(OnChainPayment.fromJson(list.elementAt(i))); - } - return history; - } else { - throw FlutterError("Failed to fetch onchain payment history"); + if (response.statusCode == 200) { + List history = []; + Iterable list = json.decode(response.body); + for (int i = 0; i < list.length; i++) { + history.add(OnChainPayment.fromJson(list.elementAt(i))); } - } catch (e) { - throw FlutterError("Failed to fetch onchain payment history. $e"); + return history; + } else { + throw FlutterError("Failed to fetch onchain payment history"); } } } From dd57e3e6ff645685267929440c50daf9a86b9f40 Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Mon, 22 Jan 2024 14:18:55 +0100 Subject: [PATCH 5/9] chore: introduce custom http client to overwrite url with this we can at ease call endpoints from rust while in dev and while in deploy mode Signed-off-by: Philipp Hoenisch --- webapp/frontend/lib/common/http_client.dart | 59 +++++++++++++++++++ .../frontend/lib/common/version_service.dart | 8 +-- .../frontend/lib/trade/new_order_service.dart | 9 +-- .../frontend/lib/wallet/wallet_service.dart | 28 +++------ 4 files changed, 70 insertions(+), 34 deletions(-) create mode 100644 webapp/frontend/lib/common/http_client.dart diff --git a/webapp/frontend/lib/common/http_client.dart b/webapp/frontend/lib/common/http_client.dart new file mode 100644 index 000000000..89b5caaf1 --- /dev/null +++ b/webapp/frontend/lib/common/http_client.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; +import 'package:http/http.dart'; + +class HttpClientManager { + static final CustomHttpClient _httpClient = CustomHttpClient(Client(), true); + + static CustomHttpClient get instance => _httpClient; +} + +class CustomHttpClient extends BaseClient { + // TODO: this should come from the settings + + // if this is true, we assume the website is running in dev mode and need to add _host:_port to be able to do http calls + final bool _dev; + + final String _port = "3001"; + final String _host = "localhost"; + + final Client _inner; + + CustomHttpClient(this._inner, this._dev); + + Future send(BaseRequest request) { + return _inner.send(request); + } + + @override + Future delete(Uri url, + {Map? headers, Object? body, Encoding? encoding}) { + if (_dev && url.host == '') { + url = Uri.parse('http://$_host:$_port${url.toString()}'); + } + return _inner.delete(url, headers: headers, body: body, encoding: encoding); + } + + @override + Future put(Uri url, {Map? headers, Object? body, Encoding? encoding}) { + if (_dev && url.host == '') { + url = Uri.parse('http://$_host:$_port${url.toString()}'); + } + return _inner.put(url, headers: headers, body: body, encoding: encoding); + } + + @override + Future post(Uri url, {Map? headers, Object? body, Encoding? encoding}) { + if (_dev && url.host == '') { + url = Uri.parse('http://$_host:$_port${url.toString()}'); + } + return _inner.post(url, headers: headers, body: body, encoding: encoding); + } + + @override + Future get(Uri url, {Map? headers}) { + if (_dev && url.host == '') { + url = Uri.parse('http://$_host:$_port${url.toString()}'); + } + return _inner.get(url, headers: headers); + } +} diff --git a/webapp/frontend/lib/common/version_service.dart b/webapp/frontend/lib/common/version_service.dart index 460c5156d..5d22bd4b8 100644 --- a/webapp/frontend/lib/common/version_service.dart +++ b/webapp/frontend/lib/common/version_service.dart @@ -1,5 +1,5 @@ -import 'package:http/http.dart' as http; import 'dart:convert'; +import 'package:get_10101/common/http_client.dart'; class Version { final String version; @@ -21,12 +21,8 @@ class VersionService { const VersionService(); Future fetchVersion() async { - // TODO(holzeis): this should come from the config - const port = "3001"; - const host = "localhost"; - try { - final response = await http.get(Uri.http('$host:$port', '/api/version')); + final response = await HttpClientManager.instance.get(Uri(path: '/api/version')); if (response.statusCode == 200) { return Version.fromJson(jsonDecode(response.body) as Map).version; diff --git a/webapp/frontend/lib/trade/new_order_service.dart b/webapp/frontend/lib/trade/new_order_service.dart index 39a9f118b..7af274a0f 100644 --- a/webapp/frontend/lib/trade/new_order_service.dart +++ b/webapp/frontend/lib/trade/new_order_service.dart @@ -1,9 +1,8 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:get_10101/common/http_client.dart'; import 'package:get_10101/common/model.dart'; -import 'package:get_10101/logger/logger.dart'; -import 'package:http/http.dart' as http; class OrderId { final String orderId; @@ -25,11 +24,7 @@ class NewOrderService { const NewOrderService(); static Future postNewOrder(Leverage leverage, Usd quantity, bool isLong) async { - // TODO(holzeis): this should come from the config - const port = "3001"; - const host = "localhost"; - - final response = await http.post(Uri.http('$host:$port', '/api/orders'), + final response = await HttpClientManager.instance.post(Uri(path: '/api/orders'), headers: { 'Content-Type': 'application/json; charset=UTF-8', }, diff --git a/webapp/frontend/lib/wallet/wallet_service.dart b/webapp/frontend/lib/wallet/wallet_service.dart index 8c0d0c1b4..d0350871a 100644 --- a/webapp/frontend/lib/wallet/wallet_service.dart +++ b/webapp/frontend/lib/wallet/wallet_service.dart @@ -1,22 +1,20 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:get_10101/common/http_client.dart'; import 'package:get_10101/common/model.dart'; import 'package:get_10101/common/balance.dart'; import 'package:get_10101/common/payment.dart'; -import 'package:http/http.dart' as http; +import 'package:get_10101/logger/logger.dart'; class WalletService { const WalletService(); Future getBalance() async { - // TODO(holzeis): this should come from the config - const port = "3001"; - const host = "localhost"; - - final response = await http.get(Uri.http('$host:$port', '/api/balance')); + final response = await HttpClientManager.instance.get(Uri(path: '/api/balance')); if (response.statusCode == 200) { + logger.i("body ${response.body}"); return Balance.fromJson(jsonDecode(response.body) as Map); } else { throw FlutterError("Failed to fetch balance"); @@ -24,11 +22,7 @@ class WalletService { } Future getNewAddress() async { - // TODO(holzeis): this should come from the config - const port = "3001"; - const host = "localhost"; - - final response = await http.get(Uri.http('$host:$port', '/api/newaddress')); + final response = await HttpClientManager.instance.get(Uri(path: '/api/newaddress')); if (response.statusCode == 200) { return response.body; @@ -38,11 +32,7 @@ class WalletService { } Future sendPayment(String address, Amount amount, Amount fee) async { - // TODO(holzeis): this should come from the config - const port = "3001"; - const host = "localhost"; - - final response = await http.post(Uri.http('$host:$port', '/api/sendpayment'), + final response = await HttpClientManager.instance.post(Uri(path: '/api/sendpayment'), headers: { 'Content-Type': 'application/json; charset=UTF-8', }, @@ -55,11 +45,7 @@ class WalletService { } Future> getOnChainPaymentHistory() async { - // TODO(holzeis): this should come from the config - const port = "3001"; - const host = "localhost"; - - final response = await http.get(Uri.http('$host:$port', '/api/history')); + final response = await HttpClientManager.instance.get(Uri(path: '/api/history')); if (response.statusCode == 200) { List history = []; From 8dc08954d382d87df959accdcce01e298e312e7b Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Mon, 22 Jan 2024 16:51:57 +0100 Subject: [PATCH 6/9] chore: extract new order form into own widget Signed-off-by: Philipp Hoenisch --- webapp/frontend/lib/trade/trade_screen.dart | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/webapp/frontend/lib/trade/trade_screen.dart b/webapp/frontend/lib/trade/trade_screen.dart index b91ceaf54..0d8ac6d64 100644 --- a/webapp/frontend/lib/trade/trade_screen.dart +++ b/webapp/frontend/lib/trade/trade_screen.dart @@ -19,10 +19,24 @@ class _TradeScreenState extends State with SingleTickerProviderStat super.initState(); } + @override + Widget build(BuildContext context) { + return NewOrderWidget(tabController: _tabController); + } +} + +class NewOrderWidget extends StatelessWidget { + const NewOrderWidget({ + super.key, + required TabController tabController, + }) : _tabController = tabController; + + final TabController _tabController; + @override Widget build(BuildContext context) { return SizedBox( - width: 500, + width: 300, child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, From 80ca1097c6cfa1c42732827f45b3eead3a80a888 Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Mon, 22 Jan 2024 17:14:03 +0100 Subject: [PATCH 7/9] feat: show open positions Note: it's not responsive yet and doesn't look good on a small screen Signed-off-by: Philipp Hoenisch --- CHANGELOG.md | 5 +- mobile/native/src/trade/position/mod.rs | 8 +- webapp/frontend/lib/common/model.dart | 14 +- .../lib/trade/open_position_service.dart | 63 ++++++++ .../lib/trade/order_and_position_table.dart | 150 ++++++++++++++++++ webapp/frontend/lib/trade/trade_screen.dart | 45 +++--- .../lib/trade/trade_screen_order_form.dart | 3 +- webapp/src/api.rs | 10 ++ webapp/src/main.rs | 2 + 9 files changed, 266 insertions(+), 34 deletions(-) create mode 100644 webapp/frontend/lib/trade/open_position_service.dart create mode 100644 webapp/frontend/lib/trade/order_and_position_table.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ccd8f6a6..ea89a5894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Feat: update api to collaboratively revert a dlc-channel - Feat: Allow continuing from an offered dlc channel state (offered, settle offered and collab close offered) - Feat: add a new project `webapp`. Eventually this will have the same functionality as our app (and more) and can be run on a self-hosted server -- Chore: In Webapp API allow requests from any origin (CORS) -- Feat: Allow creating new orders through `webapp` +- Chore (webapp): Add API allow requests from any origin (CORS) +- Feat (webapp): Allow creating new orders through `webapp` +- Feat (webapp): Show open position in trade screen ## [1.7.4] - 2023-12-20 diff --git a/mobile/native/src/trade/position/mod.rs b/mobile/native/src/trade/position/mod.rs index b433e4d4b..eed6629ef 100644 --- a/mobile/native/src/trade/position/mod.rs +++ b/mobile/native/src/trade/position/mod.rs @@ -14,6 +14,7 @@ use rust_decimal::prelude::FromPrimitive; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use rust_decimal::RoundingStrategy; +use serde::Serialize; use time::OffsetDateTime; use trade::ContractSymbol; use trade::Direction; @@ -21,7 +22,7 @@ use trade::Direction; pub mod api; pub mod handler; -#[derive(Debug, Clone, PartialEq, Copy)] +#[derive(Debug, Clone, PartialEq, Copy, Serialize)] pub enum PositionState { /// The position is open /// @@ -57,7 +58,7 @@ pub enum PositionState { Resizing, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct Position { pub leverage: f32, pub quantity: f32, @@ -67,8 +68,11 @@ pub struct Position { pub liquidation_price: f32, pub position_state: PositionState, pub collateral: u64, + #[serde(with = "time::serde::rfc3339")] pub expiry: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] pub updated: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] pub created: OffsetDateTime, pub stable: bool, } diff --git a/webapp/frontend/lib/common/model.dart b/webapp/frontend/lib/common/model.dart index f9a757193..3e416717e 100644 --- a/webapp/frontend/lib/common/model.dart +++ b/webapp/frontend/lib/common/model.dart @@ -79,8 +79,8 @@ class Amount implements Formattable { class Usd implements Formattable { Decimal _usd = Decimal.zero; - Usd(int usd) { - _usd = Decimal.fromInt(usd); + Usd(double usd) { + _usd = Decimal.parse(usd.toString()); } int get usd => _usd.toBigInt().toInt(); @@ -170,15 +170,13 @@ class Price implements Formattable { } class Leverage implements Formattable { - int _leverage = 1; - - Leverage.one() : _leverage = 1; + double _leverage = 1; - int get toInt => _leverage; + Leverage.one() : _leverage = 1.0; - double get asDouble => _leverage as double; + double get asDouble => _leverage; - Leverage(int leverage) { + Leverage(double leverage) { _leverage = leverage; } diff --git a/webapp/frontend/lib/trade/open_position_service.dart b/webapp/frontend/lib/trade/open_position_service.dart new file mode 100644 index 000000000..babc6ac07 --- /dev/null +++ b/webapp/frontend/lib/trade/open_position_service.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; +import 'package:flutter/cupertino.dart'; +import 'package:get_10101/common/http_client.dart'; +import 'package:get_10101/common/model.dart'; + +class OpenPositionsService { + const OpenPositionsService(); + + static Future> fetchOpenPositions() async { + final response = await HttpClientManager.instance.get(Uri(path: '/api/positions')); + + if (response.statusCode == 200) { + final List jsonData = jsonDecode(response.body); + return jsonData.map((positionData) => Position.fromJson(positionData)).toList(); + } else { + throw FlutterError("Could not fetch positions"); + } + } +} + +class Position { + final Leverage leverage; + final Usd quantity; + final String contractSymbol; + final String direction; + final Usd averageEntryPrice; + final Usd liquidationPrice; + final String positionState; + final Amount collateral; + final DateTime expiry; + final DateTime updated; + final DateTime created; + + Position({ + required this.leverage, + required this.quantity, + required this.contractSymbol, + required this.direction, + required this.averageEntryPrice, + required this.liquidationPrice, + required this.positionState, + required this.collateral, + required this.expiry, + required this.updated, + required this.created, + }); + + factory Position.fromJson(Map json) { + return Position( + leverage: Leverage(json['leverage'] as double), + quantity: Usd(json['quantity'] as double), + contractSymbol: json['contract_symbol'] as String, + direction: json['direction'] as String, + averageEntryPrice: Usd(json['average_entry_price'] as double), + liquidationPrice: Usd(json['liquidation_price'] as double), + positionState: json['position_state'] as String, + collateral: Amount(json['collateral']), + expiry: DateTime.parse(json['expiry'] as String), + updated: DateTime.parse(json['updated'] as String), + created: DateTime.parse(json['created'] as String), + ); + } +} diff --git a/webapp/frontend/lib/trade/order_and_position_table.dart b/webapp/frontend/lib/trade/order_and_position_table.dart new file mode 100644 index 000000000..9962763c2 --- /dev/null +++ b/webapp/frontend/lib/trade/order_and_position_table.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:get_10101/common/color.dart'; +import 'package:get_10101/logger/logger.dart'; +import 'package:get_10101/trade/open_position_service.dart'; +import 'package:intl/intl.dart'; + +class OrderAndPositionTable extends StatefulWidget { + const OrderAndPositionTable({super.key}); + + @override + OrderAndPositionTableState createState() => OrderAndPositionTableState(); +} + +class OrderAndPositionTableState extends State + with SingleTickerProviderStateMixin { + late final _tabController = TabController(length: 2, vsync: this); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + TabBar( + unselectedLabelColor: Colors.black, + labelColor: tenTenOnePurple, + controller: _tabController, + isScrollable: false, + tabs: const [ + Tab( + text: 'Open', + ), + Tab( + text: 'Pending', + ), + ], + ), + Container( + constraints: const BoxConstraints( + // Adding constraints to avoid unbounded height, 400 is a random number to avoid pixel overflow + maxHeight: 200, + ), + child: TabBarView( + controller: _tabController, + children: const [ + SimpleTableWidget(), + Text("Pending"), + ], + ), + ), + ], + ); + } +} + +class SimpleTableWidget extends StatelessWidget { + const SimpleTableWidget({super.key}); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: OpenPositionsService.fetchOpenPositions(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + logger.i("received ${snapshot.error}"); + return const Center(child: Text('Error loading data')); + } else if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center(child: Text('No data available')); + } else { + return buildTable(snapshot.data!); + } + }, + ); + } + + Widget buildTable(List positions) { + return Container( + decoration: BoxDecoration( + border: Border.all( + width: 1, + ), + borderRadius: const BorderRadius.all(Radius.circular(10)), + ), + child: Table( + border: TableBorder.symmetric(inside: const BorderSide(width: 2, color: Colors.black)), + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + columnWidths: const { + 0: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), + 1: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), + 2: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), + 3: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), + 4: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), + 5: MinColumnWidth(FixedColumnWidth(100.0), FractionColumnWidth(0.1)), + 6: MinColumnWidth(FixedColumnWidth(200.0), FractionColumnWidth(0.2)), + }, + children: [ + TableRow( + decoration: BoxDecoration( + color: tenTenOnePurple.shade300, + border: Border.all( + width: 1, + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(10), topRight: Radius.circular(10)), + ), + children: [ + buildHeaderCell('Quantity'), + buildHeaderCell('Entry Price'), + buildHeaderCell('Liquidation Price'), + buildHeaderCell('Margin'), + buildHeaderCell('Leverage'), + buildHeaderCell('Unrealized PnL'), + buildHeaderCell('Expiry'), + ], + ), + for (var position in positions) + TableRow( + children: [ + buildTableCell(position.quantity.formatted()), + buildTableCell(position.averageEntryPrice.formatted()), + buildTableCell(position.liquidationPrice.formatted()), + buildTableCell(position.collateral.formatted()), + buildTableCell(position.leverage.formatted()), + // TODO: we need to get the latest quote to be able to calculate this + buildTableCell("0.0"), + buildTableCell("${DateFormat('dd-MM-yyyy – kk:mm').format(position.expiry)} CET"), + ], + ), + ], + ), + ); + } + + TableCell buildHeaderCell(String text) { + return TableCell( + child: Container( + padding: const EdgeInsets.all(10), + alignment: Alignment.center, + child: Text(text, + textAlign: TextAlign.center, + style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white)))); + } + + TableCell buildTableCell(String text) => TableCell( + child: Center( + child: Container( + padding: const EdgeInsets.all(10), alignment: Alignment.center, child: Text(text)))); +} diff --git a/webapp/frontend/lib/trade/trade_screen.dart b/webapp/frontend/lib/trade/trade_screen.dart index 0d8ac6d64..4072b0df1 100644 --- a/webapp/frontend/lib/trade/trade_screen.dart +++ b/webapp/frontend/lib/trade/trade_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get_10101/common/color.dart'; +import 'package:get_10101/trade/order_and_position_table.dart'; import 'package:get_10101/trade/trade_screen_order_form.dart'; class TradeScreen extends StatefulWidget { @@ -21,7 +22,9 @@ class _TradeScreenState extends State with SingleTickerProviderStat @override Widget build(BuildContext context) { - return NewOrderWidget(tabController: _tabController); + return ListView( + children: [NewOrderWidget(tabController: _tabController), OrderAndPositionTable()], + ); } } @@ -35,13 +38,13 @@ class NewOrderWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return SizedBox( - width: 300, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - TabBar( + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 300, + child: TabBar( unselectedLabelColor: Colors.black, labelColor: tenTenOnePurple, controller: _tabController, @@ -54,19 +57,21 @@ class NewOrderWidget extends StatelessWidget { ), ], ), - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - NewOrderForm(isLong: true), - NewOrderForm( - isLong: false, - ) - ], - ), + ), + SizedBox( + height: 400, + width: 300, + child: TabBarView( + controller: _tabController, + children: [ + NewOrderForm(isLong: true), + NewOrderForm( + isLong: false, + ) + ], ), - ], - ), + ), + ], ); } } diff --git a/webapp/frontend/lib/trade/trade_screen_order_form.dart b/webapp/frontend/lib/trade/trade_screen_order_form.dart index fe3047517..b92307e22 100644 --- a/webapp/frontend/lib/trade/trade_screen_order_form.dart +++ b/webapp/frontend/lib/trade/trade_screen_order_form.dart @@ -5,7 +5,6 @@ import 'package:get_10101/common/amount_text_input_form_field.dart'; import 'package:get_10101/common/model.dart'; import 'package:get_10101/common/snack_bar.dart'; import 'package:get_10101/common/theme.dart'; -import 'package:get_10101/logger/logger.dart'; import 'package:get_10101/trade/new_order_service.dart'; class NewOrderForm extends StatefulWidget { @@ -78,7 +77,7 @@ class _NewOrderForm extends State { label: "Leverage", textAlign: TextAlign.right, onChanged: (leverage) => setState(() { - _leverage = Leverage(int.parse(leverage)); + _leverage = Leverage(double.parse(leverage)); updateOrderValues(); }), ), diff --git a/webapp/src/api.rs b/webapp/src/api.rs index 13406dcbe..fe64a0048 100644 --- a/webapp/src/api.rs +++ b/webapp/src/api.rs @@ -14,6 +14,8 @@ use native::api::WalletHistoryItemType; use native::ln_dlc; use native::trade::order::OrderState; use native::trade::order::OrderType; +use native::trade::position; +use native::trade::position::Position; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use serde::Deserialize; @@ -194,3 +196,11 @@ pub async fn post_new_order(params: Json) -> Result Result>, AppError> { + let positions = position::handler::get_positions()? + .into_iter() + .collect::>(); + + Ok(Json(positions)) +} diff --git a/webapp/src/main.rs b/webapp/src/main.rs index e909402a9..05fad742a 100644 --- a/webapp/src/main.rs +++ b/webapp/src/main.rs @@ -5,6 +5,7 @@ mod subscribers; use crate::api::get_balance; use crate::api::get_onchain_payment_history; +use crate::api::get_positions; use crate::api::get_unused_address; use crate::api::post_new_order; use crate::api::send_payment; @@ -97,6 +98,7 @@ fn using_serve_dir(subscribers: Arc, network: Network) -> Router .route("/api/sendpayment", post(send_payment)) .route("/api/history", get(get_onchain_payment_history)) .route("/api/orders", post(post_new_order)) + .route("/api/positions", get(get_positions)) .route("/main.dart.js", get(main_dart_handler)) .route("/flutter.js", get(flutter_js)) .route("/index.html", get(index_handler)) From b449bb27137e4c5c3a17607af65511a88beef804 Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Tue, 23 Jan 2024 08:10:09 +0100 Subject: [PATCH 8/9] chore: make trade screen responsive and redesign a bit Signed-off-by: Philipp Hoenisch --- .../lib/trade/order_and_position_table.dart | 27 ++-- webapp/frontend/lib/trade/trade_screen.dart | 132 +++++++++++++++++- 2 files changed, 138 insertions(+), 21 deletions(-) diff --git a/webapp/frontend/lib/trade/order_and_position_table.dart b/webapp/frontend/lib/trade/order_and_position_table.dart index 9962763c2..99e15ab2d 100644 --- a/webapp/frontend/lib/trade/order_and_position_table.dart +++ b/webapp/frontend/lib/trade/order_and_position_table.dart @@ -35,19 +35,14 @@ class OrderAndPositionTableState extends State ), ], ), - Container( - constraints: const BoxConstraints( - // Adding constraints to avoid unbounded height, 400 is a random number to avoid pixel overflow - maxHeight: 200, - ), - child: TabBarView( - controller: _tabController, - children: const [ - SimpleTableWidget(), - Text("Pending"), - ], - ), - ), + Expanded( + child: TabBarView( + controller: _tabController, + children: const [ + SimpleTableWidget(), + Text("Pending"), + ], + )) ], ); } @@ -77,12 +72,6 @@ class SimpleTableWidget extends StatelessWidget { Widget buildTable(List positions) { return Container( - decoration: BoxDecoration( - border: Border.all( - width: 1, - ), - borderRadius: const BorderRadius.all(Radius.circular(10)), - ), child: Table( border: TableBorder.symmetric(inside: const BorderSide(width: 2, color: Colors.black)), defaultVerticalAlignment: TableCellVerticalAlignment.middle, diff --git a/webapp/frontend/lib/trade/trade_screen.dart b/webapp/frontend/lib/trade/trade_screen.dart index 4072b0df1..fd712dd8f 100644 --- a/webapp/frontend/lib/trade/trade_screen.dart +++ b/webapp/frontend/lib/trade/trade_screen.dart @@ -22,10 +22,138 @@ class _TradeScreenState extends State with SingleTickerProviderStat @override Widget build(BuildContext context) { - return ListView( - children: [NewOrderWidget(tabController: _tabController), OrderAndPositionTable()], + return LayoutBuilder(builder: (context, constraints) { + if (constraints.maxWidth > 600) { + return _buildHorizontalWidget(constraints); + } else { + return _buildHVerticalWidget(constraints, constraints); + } + }); + } + + Widget _buildHorizontalWidget(BoxConstraints constraints) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 1, + child: Column( + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: constraints.maxHeight - 16, + ), + child: Container( + height: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[100], + ), + child: Row( + children: [ + Expanded( + child: Center(child: NewOrderWidget(tabController: _tabController))), + ], + ), + ), + ), + ], + ), + ), + Expanded( + flex: 2, + child: Column( + children: [ + Visibility( + visible: false, + child: Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), + child: Container( + height: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[100], + ), + child: const Row( + children: [Expanded(child: Center(child: Text("Chart")))], + ), + ), + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 8, right: 8), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[100], + ), + child: const Row( + children: [Expanded(child: OrderAndPositionTable())], + ), + ), + ), + ), + ], + ), + ), + ], + ), ); } + + Widget _buildHVerticalWidget(BoxConstraints constraints, BoxConstraints viewportConstraints) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + children: [ + Container( + height: 480.0, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[100], + ), + child: NewOrderWidget(tabController: _tabController), + ), + Expanded( + child: Container( + height: 120.0, + alignment: Alignment.center, + child: Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.grey[100], + ), + child: const Row( + children: [Expanded(child: OrderAndPositionTable())], + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + )))); + } } class NewOrderWidget extends StatelessWidget { From 342468a54efe94fa5c30f0a9d1e2a882771b5b2c Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Tue, 23 Jan 2024 08:13:14 +0100 Subject: [PATCH 9/9] fix: use 00 format instead of 24 Signed-off-by: Philipp Hoenisch --- webapp/frontend/lib/trade/order_and_position_table.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/frontend/lib/trade/order_and_position_table.dart b/webapp/frontend/lib/trade/order_and_position_table.dart index 99e15ab2d..d49f339c2 100644 --- a/webapp/frontend/lib/trade/order_and_position_table.dart +++ b/webapp/frontend/lib/trade/order_and_position_table.dart @@ -114,7 +114,7 @@ class SimpleTableWidget extends StatelessWidget { buildTableCell(position.leverage.formatted()), // TODO: we need to get the latest quote to be able to calculate this buildTableCell("0.0"), - buildTableCell("${DateFormat('dd-MM-yyyy – kk:mm').format(position.expiry)} CET"), + buildTableCell("${DateFormat('dd-MM-yyyy – HH:mm').format(position.expiry)} CET"), ], ), ],