Skip to content

Commit

Permalink
Calculate and show channel opening fees including modal info
Browse files Browse the repository at this point in the history
If we don't have a `ChannelInfo` yet we know this is an initial channel open and estimate the channel opening fee.
The fee information is based on a transaction weight estimate of `220` for the funding transaction estimate with two inputs. For most users this will likely be cheaper because only one UTXO will be used but it's better to safe funds than to have to pay more.

If we have a fee on record when creating an invoice we add this fee to the minimum receive value to ensure the user will be able to pay the fee and trade.
We pass a potential fee on to the share invoice screen where the fee is displayed including an info button that pops up an explanatory modal.
  • Loading branch information
da-kami committed Jul 7, 2023
1 parent c4274ef commit 88c9fca
Show file tree
Hide file tree
Showing 10 changed files with 119 additions and 26 deletions.
7 changes: 6 additions & 1 deletion crates/ln-dlc-node/src/ldk_node_wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use bdk::blockchain::EsploraBlockchain;
use bdk::blockchain::GetHeight;
use bdk::database::BatchDatabase;
use bdk::wallet::AddressIndex;
use bdk::FeeRate;
use bdk::SignOptions;
use bdk::SyncOptions;
use bdk::TransactionDetails;
Expand Down Expand Up @@ -93,6 +94,10 @@ where
Ok(())
}

pub fn get_fee_rate(&self, confirmation_target: ConfirmationTarget) -> FeeRate {
self.fee_rate_estimator.get(confirmation_target)
}

#[autometrics]
pub(crate) async fn create_funding_transaction(
&self,
Expand All @@ -103,7 +108,7 @@ where
let locked_wallet = self.bdk_lock();
let mut tx_builder = locked_wallet.build_tx();

let fee_rate = self.fee_rate_estimator.get(confirmation_target);
let fee_rate = self.get_fee_rate(confirmation_target);
tx_builder
.add_recipient(output_script, value_sats)
.fee_rate(fee_rate)
Expand Down
1 change: 1 addition & 0 deletions crates/ln-dlc-node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ mod util;
pub mod node;
pub mod seed;

pub use ln::CONFIRMATION_TARGET;
pub use ln::CONTRACT_TX_FEE_RATE;
pub use ln::JUST_IN_TIME_CHANNEL_OUTBOUND_LIQUIDITY_SAT_MAX;
pub use ln::LIQUIDITY_MULTIPLIER;
Expand Down
6 changes: 1 addition & 5 deletions crates/ln-dlc-node/src/ln/event_handler.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::dlc_custom_signer::CustomKeysManager;
use crate::fee_rate_estimator::FeeRateEstimator;
use crate::ln::coordinator_config;
use crate::ln::CONFIRMATION_TARGET;
use crate::ln::HTLC_INTERCEPTED_CONNECTION_TIMEOUT;
use crate::ln::JUST_IN_TIME_CHANNEL_OUTBOUND_LIQUIDITY_SAT_MAX;
use crate::ln::LIQUIDITY_MULTIPLIER;
Expand Down Expand Up @@ -41,11 +42,6 @@ use std::time::Duration;
use time::OffsetDateTime;
use tokio::sync::watch;

/// The speed at which we want a transaction to confirm used for feerate estimation.
///
/// We set it to high priority because the channel funding transaction should be included fast.
const CONFIRMATION_TARGET: ConfirmationTarget = ConfirmationTarget::HighPriority;

pub struct EventHandler<S> {
channel_manager: Arc<ChannelManager>,
wallet: Arc<LnDlcWallet>,
Expand Down
6 changes: 6 additions & 0 deletions crates/ln-dlc-node/src/ln/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub(crate) use config::app_config;
pub(crate) use config::coordinator_config;
pub use dlc_channel_details::DlcChannelDetails;
pub(crate) use event_handler::EventHandler;
use lightning::chain::chaininterface::ConfirmationTarget;
pub(crate) use logger::TracingLogger;

/// When handling the [`Event::HTLCIntercepted`], we may need to
Expand All @@ -35,6 +36,11 @@ pub const LIQUIDITY_MULTIPLIER: u64 = 2;
/// The coordinator and the app have to align on this to agree on the fees.
pub const CONTRACT_TX_FEE_RATE: u64 = 4;

/// The speed at which we want a transaction to confirm used for feerate estimation.
///
/// We set it to high priority because the channel funding transaction should be included fast.
pub const CONFIRMATION_TARGET: ConfirmationTarget = ConfirmationTarget::HighPriority;

/// When handling the [`Event::HTLCIntercepted`], the user might not be online right away. This
/// could be because she is funding the wallet through another wallet. In order to give the user
/// some time to open 10101 again we wait for a bit to see if we can establish a connection.
Expand Down
5 changes: 5 additions & 0 deletions mobile/lib/common/application/channel_info_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ class ChannelInfoService {
return channelInfo != null ? ChannelInfo.fromApi(channelInfo) : null;
}

Future<Amount> getChannelOpenFeeEstimate() async {
int feeEstimate = await rust.api.getChannelOpenFeeEstimateSat();
return Amount(feeEstimate);
}

/// The multiplier that is used to determine the coordinator liquidity
///
/// This value is an arbitrary number that may be subject to change.
Expand Down
25 changes: 22 additions & 3 deletions mobile/lib/features/wallet/create_invoice_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,17 @@ class _CreateInvoiceScreenState extends State<CreateInvoiceScreen> {
final WalletService walletService = const WalletService();

final ChannelInfoService channelInfoService = const ChannelInfoService();

/// The channel info if a channel already exists
///
/// If no channel exists yet this field will be null.
ChannelInfo? channelInfo;

/// Estimated fees for receiving
///
/// These fees have to be added on top of the receive amount because they are collected after receiving the funds.
Amount? feeEstimate;

@override
void dispose() {
_amountController.dispose();
Expand All @@ -51,6 +60,11 @@ class _CreateInvoiceScreenState extends State<CreateInvoiceScreen> {

Future<void> initChannelInfo() async {
channelInfo = await channelInfoService.getChannelInfo();

// initial channel opening
if (channelInfo == null) {
feeEstimate = await channelInfoService.getChannelOpenFeeEstimate();
}
}

@override
Expand All @@ -72,7 +86,10 @@ class _CreateInvoiceScreenState extends State<CreateInvoiceScreen> {
// the minimum amount that has to be in the wallet to be able to trade
Amount minAmountToBeAbleToTrade = Amount((channelInfo?.reserve.sats ?? initialReserve.sats) +
tradeFeeReserve.sats +
minTradeMargin.sats);
minTradeMargin.sats +
// make sure that the amount received covers potential fees as well
(feeEstimate?.sats ?? 0)
);

// it can go below 0 if the user has an unbalanced channel
Amount maxReceiveAmount = Amount(max(maxAllowedOutboundCapacity.sats - balance.sats, 0));
Expand Down Expand Up @@ -176,12 +193,14 @@ class _CreateInvoiceScreenState extends State<CreateInvoiceScreen> {
: () {
if (_formKey.currentState!.validate()) {
showValidationHint = false;

walletService.createInvoice(amount!).then((invoice) {
if (invoice != null) {
GoRouter.of(context).go(ShareInvoiceScreen.route,
extra: ShareInvoice(
rawInvoice: invoice, invoiceAmount: amount!));
}
rawInvoice: invoice,
invoiceAmount: amount!,
channelOpenFee: feeEstimate)); }
});
} else {
setState(() {
Expand Down
3 changes: 2 additions & 1 deletion mobile/lib/features/wallet/domain/share_invoice.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import '../../../common/domain/model.dart';
class ShareInvoice {
final String rawInvoice;
final Amount invoiceAmount;
Amount? channelOpenFee;

ShareInvoice({required this.rawInvoice, required this.invoiceAmount});
ShareInvoice({required this.rawInvoice, required this.invoiceAmount, this.channelOpenFee});
}
68 changes: 52 additions & 16 deletions mobile/lib/features/wallet/share_invoice_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import 'dart:developer';
import 'package:f_logs/f_logs.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get_10101/common/amount_text.dart';
import 'package:get_10101/common/modal_bottom_sheet_info.dart';
import 'package:get_10101/common/snack_bar.dart';
import 'package:get_10101/common/value_data_row.dart';
import 'package:get_10101/features/wallet/create_invoice_screen.dart';
Expand Down Expand Up @@ -41,6 +43,11 @@ class _ShareInvoiceScreenState extends State<ShareInvoiceScreen> {

const EdgeInsets buttonSpacing = EdgeInsets.symmetric(vertical: 8.0, horizontal: 24.0);

const qrWidth = 200.0;
const qrPadding = 5.0;
const infoButtonRadius = ModalBottomSheetInfo.buttonRadius;
const infoButtonPadding = 5.0;

return Scaffold(
appBar: AppBar(title: const Text("Receive funds")),
body: SafeArea(
Expand All @@ -63,25 +70,54 @@ class _ShareInvoiceScreenState extends State<ShareInvoiceScreen> {
child: QrImageView(
data: widget.invoice.rawInvoice,
version: QrVersions.auto,
size: 200.0,
padding: const EdgeInsets.all(10.0),
padding: const EdgeInsets.all(qrPadding),
),
),
),
Padding(
padding: const EdgeInsets.only(bottom: 10.0),
child: Center(
child: SizedBox(
// Size of the qr image minus padding
width: 190,
child: ValueDataRow(
type: ValueType.amount,
value: widget.invoice.invoiceAmount,
label: 'Amount',
labelTextStyle: const TextStyle(color: Colors.grey),
valueTextStyle: const TextStyle(color: Colors.grey),
))),
),
const SizedBox(height: 10),
Center(
child: SizedBox(
// Size of the qr image minus padding
width: qrWidth - 2 * qrPadding,
child: ValueDataRow(
type: ValueType.amount,
value: widget.invoice.invoiceAmount,
label: 'Amount',
))),
if (widget.invoice.channelOpenFee != null)
Padding(
// Set in by size of info button on the right
padding: const EdgeInsets.only(left: infoButtonRadius * 2),
child: SizedBox(
width: qrWidth - 2 * qrPadding + infoButtonRadius * 2,
child: Row(
children: [
Expanded(
child: ValueDataRow(
type: ValueType.amount,
value: widget.invoice.channelOpenFee,
label: "Fee Estimate")),
ModalBottomSheetInfo(
closeButtonText: "Back to Share Invoice",
infoButtonPadding: const EdgeInsets.all(infoButtonPadding),
child: Column(
children: [
Center(
child: Text("Understanding Fees",
style: Theme.of(context).textTheme.headlineSmall),
),
const SizedBox(height: 10),
Text(
"Upon receiving your first payment the 10101 LSP will open a Lightning channel with you.\n"
"To cover the costs for opening the channel the transaction fee is collected after the channel was opened, meaning that an estimated ${formatSats(widget.invoice.channelOpenFee!)} will be collected from your wallet once the channel was opened.\n"
"The fee estimate is based on a transaction weight with two inputs and the current estimated fee rate."),
],
)),
],
),
),
),
const SizedBox(height: 10)
]),
),
// Faucet button, only available if we are on regtest
Expand Down
8 changes: 8 additions & 0 deletions mobile/native/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use crate::db;
use crate::event;
use crate::event::api::FlutterSubscriber;
use crate::ln_dlc;
use crate::ln_dlc::FUNDING_TX_WEIGHT_ESTIMATE;
use crate::logger;
use crate::orderbook;
use crate::trade::order;
Expand Down Expand Up @@ -341,3 +342,10 @@ pub fn decode_invoice(invoice: String) -> Result<LightningInvoice> {
pub fn get_node_id() -> SyncReturn<String> {
SyncReturn(ln_dlc::get_node_info().pubkey.to_string())
}

pub fn get_channel_open_fee_estimate_sat() -> Result<u64> {
let fee_rate = ln_dlc::get_fee_rate()?;
let estimate = FUNDING_TX_WEIGHT_ESTIMATE as f32 * fee_rate.as_sat_per_vb();

Ok(estimate.ceil() as u64)
}
16 changes: 16 additions & 0 deletions mobile/native/src/ln_dlc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use bdk::bitcoin::secp256k1::SecretKey;
use bdk::bitcoin::Txid;
use bdk::bitcoin::XOnlyPublicKey;
use bdk::BlockTime;
use bdk::FeeRate;
use coordinator_commons::TradeParams;
use itertools::chain;
use itertools::Itertools;
Expand All @@ -31,6 +32,7 @@ use ln_dlc_node::node::rust_dlc_manager::ChannelId;
use ln_dlc_node::node::LnDlcNodeSettings;
use ln_dlc_node::node::NodeInfo;
use ln_dlc_node::seed::Bip39Seed;
use ln_dlc_node::CONFIRMATION_TARGET;
use orderbook_commons::FakeScidResponse;
use orderbook_commons::FEE_INVOICE_DESCRIPTION_PREFIX_TAKER;
use parking_lot::RwLock;
Expand All @@ -57,6 +59,15 @@ const PROCESS_INCOMING_MESSAGES_INTERVAL: Duration = Duration::from_secs(5);
const UPDATE_WALLET_HISTORY_INTERVAL: Duration = Duration::from_secs(5);
const CHECK_OPEN_ORDERS_INTERVAL: Duration = Duration::from_secs(60);

/// The weight estimate of the funding transaction
///
/// This weight estimate assumes two inputs.
/// This value was chosen based on mainnet channel funding transactions with two inputs.
/// Note that we cannot predict this value precisely, because the app cannot predict what UTXOs the
/// coordinator will use for the channel opening transaction. Only once the transaction is know the
/// exact fee will be know.
pub const FUNDING_TX_WEIGHT_ESTIMATE: u64 = 220;

pub async fn refresh_wallet_info() -> Result<()> {
let node = NODE.get();
let wallet = node.inner.wallet();
Expand Down Expand Up @@ -426,6 +437,11 @@ pub fn get_usable_channel_details() -> Result<Vec<ChannelDetails>> {
Ok(channels)
}

pub fn get_fee_rate() -> Result<FeeRate> {
let node = NODE.try_get().context("failed to get ln dlc node")?;
Ok(node.inner.wallet().get_fee_rate(CONFIRMATION_TARGET))
}

pub fn create_invoice(amount_sats: Option<u64>) -> Result<Invoice> {
let runtime = get_or_create_tokio_runtime()?;

Expand Down

0 comments on commit 88c9fca

Please sign in to comment.