Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Send and receive on-chain funds #1857

Merged
merged 3 commits into from
Jan 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions webapp/frontend/lib/common/balance.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ class Balance {

const Balance(this.offChain, this.onChain);

factory Balance.fromJson(Map<String, dynamic> json) {
return switch (json) {
{
'on_chain': int onChain,
'off_chain': int offChain,
} =>
Balance(Amount(offChain), Amount(onChain)),
_ => throw const FormatException('Failed to load balance.'),
};
}

Balance.zero()
: offChain = Amount.zero(),
onChain = Amount.zero();
Expand Down
32 changes: 28 additions & 4 deletions webapp/frontend/lib/wallet/send_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ class SendScreen extends StatefulWidget {

class _SendScreenState extends State<SendScreen> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();

final TextEditingController _addressController = TextEditingController();
final TextEditingController _amountController = TextEditingController();
final TextEditingController _feeController = TextEditingController();

String? address;
Amount? amount;
Expand Down Expand Up @@ -72,6 +75,7 @@ class _SendScreenState extends State<SendScreen> {
AmountInputField(
value: amount != null ? amount! : Amount.zero(),
label: "Amount in sats",
controller: _amountController,
validator: (value) {
return null;
Comment on lines 75 to 80
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When merged I'll see to make this look the same as the other input fields

},
Expand All @@ -86,6 +90,7 @@ class _SendScreenState extends State<SendScreen> {
AmountInputField(
value: fee != null ? fee! : Amount.zero(),
label: "Sats/vb",
controller: _feeController,
validator: (value) {
if (value == null || value == "0") {
return "The fee rate must be greater than 0";
Expand All @@ -111,6 +116,20 @@ class _SendScreenState extends State<SendScreen> {
await context
.read<WalletService>()
.sendPayment(address!, amount!, fee!);

setState(() {
_formKey.currentState!.reset();
_addressController.clear();
address = null;
_amountController.clear();
amount = null;
_feeController.clear();
fee = null;

_formKey.currentState!.validate();
});

showSnackBar(messenger, "Payment has been sent.");
} catch (e) {
showSnackBar(messenger, "Failed to send payment. $e");
}
Expand Down Expand Up @@ -150,8 +169,13 @@ class _SendScreenState extends State<SendScreen> {
],
);
}
}

/*
,
*/
@override
void dispose() {
super.dispose();

_addressController.dispose();
_amountController.dispose();
_feeController.dispose();
}
}
47 changes: 43 additions & 4 deletions webapp/frontend/lib/wallet/wallet_service.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:get_10101/common/model.dart';
import 'package:get_10101/common/balance.dart';
Expand All @@ -7,8 +9,21 @@ class WalletService {
const WalletService();

Future<Balance> getBalance() async {
// todo: fetch balance from backend
return Balance(Amount(123454), Amount(124145214));
// 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/balance'));

if (response.statusCode == 200) {
return Balance.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
} else {
throw FlutterError("Failed to fetch balance");
}
} catch (e) {
throw FlutterError("Failed to fetch balance. $e");
}
}

Future<String> getNewAddress() async {
Expand All @@ -30,7 +45,31 @@ class WalletService {
}

Future<void> sendPayment(String address, Amount amount, Amount fee) async {
// todo: send payment
throw UnimplementedError("todo");
// TODO(holzeis): this should come from the config
const port = "3001";
const host = "localhost";

try {
final response = await http.post(Uri.http('$host:$port', '/api/sendpayment'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(
<String, dynamic>{'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");
}
}
}

class Payment {
final String address;
final int amount;
final int fee;

const Payment({required this.address, required this.amount, required this.fee});
}
73 changes: 73 additions & 0 deletions webapp/src/api.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,38 @@
use crate::subscribers::AppSubscribers;
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;
use native::api::Fee;
use native::api::SendPayment;
use native::ln_dlc;
use serde::Deserialize;
use serde::Serialize;
use std::sync::Arc;

pub struct AppError(anyhow::Error);

impl IntoResponse for AppError {
fn into_response(self) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {}", self.0),
)
.into_response()
}
}

impl<E> From<E> for AppError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}

#[derive(Serialize)]
pub struct Version {
Expand All @@ -17,3 +48,45 @@ pub async fn version() -> Json<Version> {
pub async fn get_unused_address() -> impl IntoResponse {
api::get_unused_address().0
}

#[derive(Serialize)]
pub struct Balance {
on_chain: u64,
off_chain: u64,
}

pub async fn get_balance(
State(subscribers): State<Arc<AppSubscribers>>,
) -> Result<Json<Balance>, AppError> {
ln_dlc::refresh_wallet_info().await?;
let balance = subscribers
.wallet_info()
.map(|wallet_info| Balance {
on_chain: wallet_info.balances.on_chain,
off_chain: wallet_info.balances.off_chain,
})
.unwrap_or(Balance {
on_chain: 0,
off_chain: 0,
});
Comment on lines +68 to +71
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably return an error to the caller instead of setting it to 0 here. Could be quite scary if the balance shows suddenly 0.

This comment was marked as outdated.

This comment was marked as outdated.

Copy link
Contributor Author

@holzeis holzeis Jan 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest we do that when we consider implementing polling or push. Otherwise, the user would simply get an error only because his request came in earlier than the wallet info event has been published.


Ok(Json(balance))
}

#[derive(Deserialize)]
pub struct Payment {
address: String,
amount: u64,
fee: u64,
}

pub async fn send_payment(params: Json<Payment>) -> Result<(), AppError> {
ln_dlc::send_payment(SendPayment::OnChain {
address: params.0.address,
amount: params.0.amount,
fee: Fee::FeeRate { sats: params.0.fee },
})
.await?;

Ok(())
}
16 changes: 14 additions & 2 deletions webapp/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
mod api;
mod cli;
mod logger;
mod subscribers;

use crate::api::get_balance;
use crate::api::get_unused_address;
use crate::api::send_payment;
use crate::api::version;
use crate::cli::Opts;
use crate::subscribers::AppSubscribers;
use anyhow::Context;
use anyhow::Result;
use axum::http::header;
Expand All @@ -15,9 +19,11 @@ use axum::response::Html;
use axum::response::IntoResponse;
use axum::response::Response;
use axum::routing::get;
use axum::routing::post;
use axum::Router;
use rust_embed::RustEmbed;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tower_http::classify::ServerErrorsFailureClass;
use tower_http::trace::TraceLayer;
Expand Down Expand Up @@ -70,16 +76,21 @@ async fn main() -> Result<()> {
})
.await;

serve(using_serve_dir(), 3001).await?;
let (rx, tx) = AppSubscribers::new().await;
native::event::subscribe(tx);

serve(using_serve_dir(Arc::new(rx)), 3001).await?;

Ok(())
}

fn using_serve_dir() -> Router {
fn using_serve_dir(subscribers: Arc<AppSubscribers>) -> Router {
Router::new()
.route("/", get(index_handler))
.route("/api/version", get(version))
.route("/api/balance", get(get_balance))
.route("/api/newaddress", get(get_unused_address))
.route("/api/sendpayment", post(send_payment))
.route("/main.dart.js", get(main_dart_handler))
.route("/flutter.js", get(flutter_js))
.route("/index.html", get(index_handler))
Expand All @@ -103,6 +114,7 @@ fn using_serve_dir() -> Router {
},
),
)
.with_state(subscribers)
}

// We use static route matchers ("/" and "/index.html") to serve our home
Expand Down
80 changes: 80 additions & 0 deletions webapp/src/subscribers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
use native::api::WalletInfo;
use native::event::subscriber::Subscriber;
use native::event::EventInternal;
use native::event::EventType;
use parking_lot::Mutex;
use std::sync::Arc;
use tokio::sync::watch;

pub struct Senders {
wallet_info: watch::Sender<Option<WalletInfo>>,
}

/// Subscribes to events destined for the frontend (typically Flutter app) and
/// provides a convenient way to access the current state.
pub struct AppSubscribers {
wallet_info: watch::Receiver<Option<WalletInfo>>,
}

impl AppSubscribers {
pub async fn new() -> (Self, ThreadSafeSenders) {
let (wallet_info_tx, wallet_info_rx) = watch::channel(None);

let senders = Senders {
wallet_info: wallet_info_tx,
};

let subscriber = Self {
wallet_info: wallet_info_rx,
};
(subscriber, ThreadSafeSenders(Arc::new(Mutex::new(senders))))
}

pub fn wallet_info(&self) -> Option<WalletInfo> {
self.wallet_info.borrow().as_ref().cloned()
}
}

impl Subscriber for Senders {
fn notify(&self, event: &EventInternal) {
if let Err(e) = self.handle_event(event) {
tracing::error!(?e, ?event, "Failed to handle event");
}
}

fn events(&self) -> Vec<EventType> {
vec![
EventType::Init,
EventType::WalletInfoUpdateNotification,
EventType::OrderUpdateNotification,
EventType::PositionUpdateNotification,
EventType::PositionClosedNotification,
EventType::PriceUpdateNotification,
EventType::ServiceHealthUpdate,
EventType::ChannelStatusUpdate,
]
}
}

impl Senders {
fn handle_event(&self, event: &EventInternal) -> anyhow::Result<()> {
tracing::trace!(?event, "Received event");
if let EventInternal::WalletInfoUpdateNotification(wallet_info) = event {
self.wallet_info.send(Some(wallet_info.clone()))?;
}
Ok(())
}
}

#[derive(Clone)]
pub struct ThreadSafeSenders(Arc<Mutex<Senders>>);

impl Subscriber for ThreadSafeSenders {
fn notify(&self, event: &EventInternal) {
self.0.lock().notify(event)
}

fn events(&self) -> Vec<EventType> {
self.0.lock().events()
}
}
Loading