diff --git a/CHANGELOG.md b/CHANGELOG.md index bb65a845c..fbb406d22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ ## Synonym Fork Additions +- Added `OnchainPayment::calculate_send_all_fee()` to preview the fee for a drain / send-all + transaction before broadcasting (fee-calculation counterpart of `send_all_to_address`) - Added runtime APIs for dynamic address type management: - `Node::add_address_type_to_monitor()` and `add_address_type_to_monitor_with_mnemonic()` to add an address type to the monitored set - `Node::remove_address_type_from_monitor()` to unload an address type (persisted state retained for re-add) diff --git a/Package.swift b/Package.swift index 5080241f8..56b3ac464 100644 --- a/Package.swift +++ b/Package.swift @@ -3,8 +3,8 @@ import PackageDescription -let tag = "v0.7.0-rc.29" -let checksum = "983475feca0a1c4677fbfb8cbc6acbb5691cb55798e2819c29faf7a71260c672" +let tag = "v0.7.0-rc.30" +let checksum = "61f686901875529cb54850846283ce709e17b2500a1728b51d629ad03298a641" let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip" let package = Package( diff --git a/bindings/kotlin/ldk-node-android/gradle.properties b/bindings/kotlin/ldk-node-android/gradle.properties index 45c080991..ac7f34deb 100644 --- a/bindings/kotlin/ldk-node-android/gradle.properties +++ b/bindings/kotlin/ldk-node-android/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official group=com.synonym -version=0.7.0-rc.29 +version=0.7.0-rc.30 diff --git a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/arm64-v8a/libldk_node.so b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/arm64-v8a/libldk_node.so index 5d1d7704f..4a76e096c 100755 Binary files a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/arm64-v8a/libldk_node.so and b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/arm64-v8a/libldk_node.so differ diff --git a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/armeabi-v7a/libldk_node.so b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/armeabi-v7a/libldk_node.so index de1b913b8..f260c4802 100755 Binary files a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/armeabi-v7a/libldk_node.so and b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/armeabi-v7a/libldk_node.so differ diff --git a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/x86_64/libldk_node.so b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/x86_64/libldk_node.so index 944fedeb0..f1db658a0 100755 Binary files a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/x86_64/libldk_node.so and b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/x86_64/libldk_node.so differ diff --git a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt index a30ccbb0a..7503d693a 100644 --- a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt +++ b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt @@ -1515,6 +1515,8 @@ internal typealias UniffiVTableCallbackInterfaceVssHeaderProviderUniffiByValue = + + @@ -2554,6 +2556,13 @@ internal interface UniffiLib : Library { `urgent`: Byte, uniffiCallStatus: UniffiRustCallStatus, ): Pointer? + fun uniffi_ldk_node_fn_method_onchainpayment_calculate_send_all_fee( + `ptr`: Pointer?, + `address`: RustBufferByValue, + `retainReserves`: Byte, + `feeRate`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): Long fun uniffi_ldk_node_fn_method_onchainpayment_calculate_total_fee( `ptr`: Pointer?, `address`: RustBufferByValue, @@ -3310,6 +3319,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_calculate_cpfp_fee_rate( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_calculate_send_all_fee( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee( ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs( @@ -3897,6 +3908,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_cpfp_fee_rate() != 32879.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_send_all_fee() != 16052.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee() != 57218.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -8050,6 +8064,21 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + @Throws(NodeException::class) + override fun `calculateSendAllFee`(`address`: Address, `retainReserves`: kotlin.Boolean, `feeRate`: FeeRate?): kotlin.ULong { + return FfiConverterULong.lift(callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_calculate_send_all_fee( + it, + FfiConverterTypeAddress.lower(`address`), + FfiConverterBoolean.lower(`retainReserves`), + FfiConverterOptionalTypeFeeRate.lower(`feeRate`), + uniffiRustCallStatus, + ) + } + }) + } + @Throws(NodeException::class) override fun `calculateTotalFee`(`address`: Address, `amountSats`: kotlin.ULong, `feeRate`: FeeRate?, `utxosToSpend`: List?): kotlin.ULong { return FfiConverterULong.lift(callWithPointer { diff --git a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt index 7a97af512..d9fa35199 100644 --- a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt +++ b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt @@ -584,6 +584,9 @@ interface OnchainPaymentInterface { @Throws(NodeException::class) fun `calculateCpfpFeeRate`(`parentTxid`: Txid, `urgent`: kotlin.Boolean): FeeRate + @Throws(NodeException::class) + fun `calculateSendAllFee`(`address`: Address, `retainReserves`: kotlin.Boolean, `feeRate`: FeeRate?): kotlin.ULong + @Throws(NodeException::class) fun `calculateTotalFee`(`address`: Address, `amountSats`: kotlin.ULong, `feeRate`: FeeRate?, `utxosToSpend`: List?): kotlin.ULong diff --git a/bindings/kotlin/ldk-node-jvm/gradle.properties b/bindings/kotlin/ldk-node-jvm/gradle.properties index 0499f1219..eed593aff 100644 --- a/bindings/kotlin/ldk-node-jvm/gradle.properties +++ b/bindings/kotlin/ldk-node-jvm/gradle.properties @@ -1,4 +1,4 @@ org.gradle.jvmargs=-Xmx1536m kotlin.code.style=official group=com.synonym -version=0.7.0-rc.29 +version=0.7.0-rc.30 diff --git a/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt b/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt index 7a97af512..d9fa35199 100644 --- a/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt +++ b/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt @@ -584,6 +584,9 @@ interface OnchainPaymentInterface { @Throws(NodeException::class) fun `calculateCpfpFeeRate`(`parentTxid`: Txid, `urgent`: kotlin.Boolean): FeeRate + @Throws(NodeException::class) + fun `calculateSendAllFee`(`address`: Address, `retainReserves`: kotlin.Boolean, `feeRate`: FeeRate?): kotlin.ULong + @Throws(NodeException::class) fun `calculateTotalFee`(`address`: Address, `amountSats`: kotlin.ULong, `feeRate`: FeeRate?, `utxosToSpend`: List?): kotlin.ULong diff --git a/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.jvm.kt b/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.jvm.kt index 492fe11c0..247691525 100644 --- a/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.jvm.kt +++ b/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.jvm.kt @@ -1513,6 +1513,8 @@ internal typealias UniffiVTableCallbackInterfaceVssHeaderProviderUniffiByValue = + + @@ -2552,6 +2554,13 @@ internal interface UniffiLib : Library { `urgent`: Byte, uniffiCallStatus: UniffiRustCallStatus, ): Pointer? + fun uniffi_ldk_node_fn_method_onchainpayment_calculate_send_all_fee( + `ptr`: Pointer?, + `address`: RustBufferByValue, + `retainReserves`: Byte, + `feeRate`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): Long fun uniffi_ldk_node_fn_method_onchainpayment_calculate_total_fee( `ptr`: Pointer?, `address`: RustBufferByValue, @@ -3308,6 +3317,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_calculate_cpfp_fee_rate( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_calculate_send_all_fee( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee( ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs( @@ -3895,6 +3906,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_cpfp_fee_rate() != 32879.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_send_all_fee() != 16052.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee() != 57218.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -8039,6 +8053,21 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + @Throws(NodeException::class) + override fun `calculateSendAllFee`(`address`: Address, `retainReserves`: kotlin.Boolean, `feeRate`: FeeRate?): kotlin.ULong { + return FfiConverterULong.lift(callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_calculate_send_all_fee( + it, + FfiConverterTypeAddress.lower(`address`), + FfiConverterBoolean.lower(`retainReserves`), + FfiConverterOptionalTypeFeeRate.lower(`feeRate`), + uniffiRustCallStatus, + ) + } + }) + } + @Throws(NodeException::class) override fun `calculateTotalFee`(`address`: Address, `amountSats`: kotlin.ULong, `feeRate`: FeeRate?, `utxosToSpend`: List?): kotlin.ULong { return FfiConverterULong.lift(callWithPointer { diff --git a/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-aarch64/libldk_node.dylib b/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-aarch64/libldk_node.dylib index 7e95abdd4..5f8c21952 100644 Binary files a/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-aarch64/libldk_node.dylib and b/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-aarch64/libldk_node.dylib differ diff --git a/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-x86-64/libldk_node.dylib b/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-x86-64/libldk_node.dylib index 6239c2216..3ce9de905 100644 Binary files a/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-x86-64/libldk_node.dylib and b/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-x86-64/libldk_node.dylib differ diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 04d56013e..f09f76752 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -317,6 +317,8 @@ interface OnchainPayment { FeeRate calculate_cpfp_fee_rate([ByRef]Txid parent_txid, boolean urgent); [Throws=NodeError] u64 calculate_total_fee([ByRef]Address address, u64 amount_sats, FeeRate? fee_rate, sequence? utxos_to_spend); + [Throws=NodeError] + u64 calculate_send_all_fee([ByRef]Address address, boolean retain_reserves, FeeRate? fee_rate); }; enum CoinSelectionAlgorithm { diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index 081f48a95..f64c3c65e 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ldk_node" -version = "0.7.0-rc.29" +version = "0.7.0-rc.30" authors = [ { name="Elias Rohrer", email="dev@tnull.de" }, ] diff --git a/bindings/python/src/ldk_node/ldk_node.py b/bindings/python/src/ldk_node/ldk_node.py index d90d1e574..c3dd18ce8 100644 --- a/bindings/python/src/ldk_node/ldk_node.py +++ b/bindings/python/src/ldk_node/ldk_node.py @@ -797,6 +797,8 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_cpfp_fee_rate() != 32879: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_send_all_fee() != 16052: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee() != 57218: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs() != 19144: @@ -2189,6 +2191,14 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_calculate_cpfp_fee_rate.restype = ctypes.c_void_p +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_calculate_send_all_fee.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.c_int8, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_calculate_send_all_fee.restype = ctypes.c_uint64 _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_calculate_total_fee.argtypes = ( ctypes.c_void_p, _UniffiRustBuffer, @@ -3211,6 +3221,9 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_cpfp_fee_rate.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_cpfp_fee_rate.restype = ctypes.c_uint16 +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_send_all_fee.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_send_all_fee.restype = ctypes.c_uint16 _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee.restype = ctypes.c_uint16 @@ -6563,6 +6576,8 @@ def bump_fee_by_rbf(self, txid: "Txid",fee_rate: "FeeRate"): raise NotImplementedError def calculate_cpfp_fee_rate(self, parent_txid: "Txid",urgent: "bool"): raise NotImplementedError + def calculate_send_all_fee(self, address: "Address",retain_reserves: "bool",fee_rate: "typing.Optional[FeeRate]"): + raise NotImplementedError def calculate_total_fee(self, address: "Address",amount_sats: "int",fee_rate: "typing.Optional[FeeRate]",utxos_to_spend: "typing.Optional[typing.List[SpendableUtxo]]"): raise NotImplementedError def list_spendable_outputs(self, ): @@ -6652,6 +6667,24 @@ def calculate_cpfp_fee_rate(self, parent_txid: "Txid",urgent: "bool") -> "FeeRat + def calculate_send_all_fee(self, address: "Address",retain_reserves: "bool",fee_rate: "typing.Optional[FeeRate]") -> "int": + _UniffiConverterTypeAddress.check_lower(address) + + _UniffiConverterBool.check_lower(retain_reserves) + + _UniffiConverterOptionalTypeFeeRate.check_lower(fee_rate) + + return _UniffiConverterUInt64.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_calculate_send_all_fee,self._uniffi_clone_pointer(), + _UniffiConverterTypeAddress.lower(address), + _UniffiConverterBool.lower(retain_reserves), + _UniffiConverterOptionalTypeFeeRate.lower(fee_rate)) + ) + + + + + def calculate_total_fee(self, address: "Address",amount_sats: "int",fee_rate: "typing.Optional[FeeRate]",utxos_to_spend: "typing.Optional[typing.List[SpendableUtxo]]") -> "int": _UniffiConverterTypeAddress.check_lower(address) diff --git a/bindings/swift/Sources/LDKNode/LDKNode.swift b/bindings/swift/Sources/LDKNode/LDKNode.swift index cf832e0e8..ef5cbbca6 100644 --- a/bindings/swift/Sources/LDKNode/LDKNode.swift +++ b/bindings/swift/Sources/LDKNode/LDKNode.swift @@ -3247,6 +3247,8 @@ public protocol OnchainPaymentProtocol: AnyObject { func calculateCpfpFeeRate(parentTxid: Txid, urgent: Bool) throws -> FeeRate + func calculateSendAllFee(address: Address, retainReserves: Bool, feeRate: FeeRate?) throws -> UInt64 + func calculateTotalFee(address: Address, amountSats: UInt64, feeRate: FeeRate?, utxosToSpend: [SpendableUtxo]?) throws -> UInt64 func listSpendableOutputs() throws -> [SpendableUtxo] @@ -3336,6 +3338,15 @@ open class OnchainPayment: }) } + open func calculateSendAllFee(address: Address, retainReserves: Bool, feeRate: FeeRate?) throws -> UInt64 { + return try FfiConverterUInt64.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_onchainpayment_calculate_send_all_fee(self.uniffiClonePointer(), + FfiConverterTypeAddress.lower(address), + FfiConverterBool.lower(retainReserves), + FfiConverterOptionTypeFeeRate.lower(feeRate), $0) + }) + } + open func calculateTotalFee(address: Address, amountSats: UInt64, feeRate: FeeRate?, utxosToSpend: [SpendableUtxo]?) throws -> UInt64 { return try FfiConverterUInt64.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { uniffi_ldk_node_fn_method_onchainpayment_calculate_total_fee(self.uniffiClonePointer(), @@ -12618,6 +12629,9 @@ private var initializationResult: InitializationResult = { if uniffi_ldk_node_checksum_method_onchainpayment_calculate_cpfp_fee_rate() != 32879 { return InitializationResult.apiChecksumMismatch } + if uniffi_ldk_node_checksum_method_onchainpayment_calculate_send_all_fee() != 16052 { + return InitializationResult.apiChecksumMismatch + } if uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee() != 57218 { return InitializationResult.apiChecksumMismatch } diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index c302705f7..5709f7274 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -171,11 +171,12 @@ impl OnchainPayment { /// The calculation respects any on-chain reserve requirements and validates that sufficient /// funds are available, just like [`send_to_address`]. /// - /// **Special handling for maximum amounts:** If the specified amount would result in - /// insufficient funds due to fees, but is within the spendable balance, this method will - /// automatically calculate the fee for sending all available funds while retaining the - /// anchor channel reserve. This allows users to calculate fees when trying to send - /// their maximum spendable balance. + /// **Note on maximum amounts:** For calculating the fee when sending the entire spendable + /// balance, prefer [`calculate_send_all_fee`] which is purpose-built for that use case. + /// This method includes a best-effort fallback for near-max amounts, but it may not + /// trigger in all cases depending on the underlying wallet error. + /// + /// [`calculate_send_all_fee`]: Self::calculate_send_all_fee /// /// # Arguments /// @@ -247,6 +248,63 @@ impl OnchainPayment { result } + /// Calculates the total fee for sending all available on-chain funds without + /// actually broadcasting. + /// + /// This is the fee-calculation counterpart of [`send_all_to_address`]. Use it to + /// show the user how much a drain / send-all transaction would cost before they + /// confirm. + /// + /// When `retain_reserves` is `true`, the calculation accounts for the on-chain anchor + /// channel reserve (see [`BalanceDetails::total_anchor_channels_reserve_sats`]), sending only + /// the spendable portion. When `false`, the calculation covers draining the entire wallet + /// balance, which may be dangerous if you have open anchor channels whose counterparty you + /// don't trust to spend the anchor output after closure. + /// + /// # Arguments + /// + /// * `address` - The destination Bitcoin address + /// * `retain_reserves` - If `true`, retains the anchor channel reserve; if `false`, drains everything + /// * `fee_rate` - Optional fee rate to use (if `None`, will estimate based on current network conditions) + /// + /// # Returns + /// + /// The total fee in satoshis that would be paid for this transaction. + /// + /// # Errors + /// + /// * [`Error::NotRunning`] - If the node is not running + /// * [`Error::InvalidAddress`] - If the address is invalid + /// * [`Error::InsufficientFunds`] - If there are insufficient funds + /// * [`Error::WalletOperationFailed`] - If fee calculation fails + /// + /// [`send_all_to_address`]: Self::send_all_to_address + /// [`BalanceDetails::total_anchor_channels_reserve_sats`]: crate::BalanceDetails::total_anchor_channels_reserve_sats + pub fn calculate_send_all_fee( + &self, address: &bitcoin::Address, retain_reserves: bool, fee_rate: Option, + ) -> Result { + if !*self.is_running.read().unwrap() { + return Err(Error::NotRunning); + } + + let send_amount = if retain_reserves { + let cur_anchor_reserve_sats = + crate::total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); + OnchainSendAmount::AllRetainingReserve { cur_anchor_reserve_sats } + } else { + OnchainSendAmount::AllDrainingReserve + }; + + let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); + self.wallet.calculate_transaction_fee( + address, + send_amount, + fee_rate_opt, + None, + &self.channel_manager, + ) + } + /// Send an on-chain payment to the given address. /// /// This will respect any on-chain reserve we need to keep, i.e., won't allow to cut into @@ -284,6 +342,8 @@ impl OnchainPayment { /// This is useful if you have closed all channels and want to migrate funds to another /// on-chain wallet. /// + /// To preview the fee before broadcasting, use [`calculate_send_all_fee`]. + /// /// Please note that if `retain_reserves` is set to `false` this will **not** retain any on-chain reserves, which might be potentially /// dangerous if you have open Anchor channels for which you can't trust the counterparty to /// spend the Anchor output after channel closure. If `retain_reserves` is set to `true`, this @@ -293,6 +353,7 @@ impl OnchainPayment { /// If `fee_rate` is set it will be used on the resulting transaction. Otherwise a reasonable /// we'll retrieve an estimate from the configured chain source. /// + /// [`calculate_send_all_fee`]: Self::calculate_send_all_fee /// [`BalanceDetails::spendable_onchain_balance_sats`]: crate::balance::BalanceDetails::spendable_onchain_balance_sats pub fn send_all_to_address( &self, address: &bitcoin::Address, retain_reserves: bool, fee_rate: Option, diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 21ae14d4f..287f01a8a 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -596,6 +596,24 @@ async fn onchain_send_all_retains_reserve() { ..=premine_amount_sat) .contains(&node_b.list_balances().spendable_onchain_balance_sats)); + // With an open anchor channel, retain_reserves=true vs false should produce different fees + // because retain=true excludes the reserve from the send amount (smaller tx output, same + // inputs) while retain=false drains everything. + let fee_retain = node_b + .onchain_payment() + .calculate_send_all_fee(&addr_a, true, None) + .expect("fee with retain should succeed"); + let fee_drain = node_b + .onchain_payment() + .calculate_send_all_fee(&addr_a, false, None) + .expect("fee with drain should succeed"); + assert!(fee_retain > 0); + assert!(fee_drain > 0); + // Drain sends a larger output (includes reserve), so its fee may differ. + // Both must be less than the total balance. + assert!(fee_retain < node_b.list_balances().total_onchain_balance_sats); + assert!(fee_drain < node_b.list_balances().total_onchain_balance_sats); + // Send all over again, this time ensuring the reserve is accounted for let txid = node_b.onchain_payment().send_all_to_address(&addr_a, true, None).unwrap(); @@ -613,6 +631,126 @@ async fn onchain_send_all_retains_reserve() { .contains(&node_a.list_balances().spendable_onchain_balance_sats)); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn calculate_send_all_fee_matches_actual() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + let premine_amount_sat = 1_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a.clone()], + Amount::from_sat(premine_amount_sat), + ) + .await; + node_a.sync_wallets().unwrap(); + + // No channels open: retain_reserves=true should still work (reserve is 0). + let estimated_fee = node_a + .onchain_payment() + .calculate_send_all_fee(&addr_b, true, None) + .expect("fee estimation should succeed"); + assert!(estimated_fee > 0, "fee must be positive"); + assert!(estimated_fee < premine_amount_sat, "fee must be less than balance"); + + // Drain with retain_reserves=false should give the same result when no channels exist. + let estimated_fee_drain = node_a + .onchain_payment() + .calculate_send_all_fee(&addr_b, false, None) + .expect("drain fee estimation should succeed"); + assert_eq!(estimated_fee, estimated_fee_drain); + + // Custom fee rate should produce a different (but still valid) estimate. + let custom_fee_rate = bitcoin::FeeRate::from_sat_per_kwu(500); + let estimated_fee_custom = node_a + .onchain_payment() + .calculate_send_all_fee(&addr_b, true, Some(custom_fee_rate)) + .expect("custom fee rate estimation should succeed"); + assert!(estimated_fee_custom > 0); + + // Actually send and compare the real fee with the estimate (using default fee rate). + let txid = node_a.onchain_payment().send_all_to_address(&addr_b, true, None).unwrap(); + wait_for_tx(&electrsd.client, txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let received = node_b.list_balances().spendable_onchain_balance_sats; + let actual_fee = premine_amount_sat - received; + assert_eq!(estimated_fee, actual_fee, "estimated fee should match the actual fee paid"); + + // After draining, node_a has zero balance — fee estimation should fail. + assert!(node_a.onchain_payment().calculate_send_all_fee(&addr_b, true, None).is_err()); + + // Verify wrong-network address returns an error (testnet address on regtest). + let wrong_net_addr: Address = + "tb1q0d40e5rta4fty63z64gztf8c3v20cvet6v2jdh".parse().expect("parse unchecked"); + let wrong_net_addr = wrong_net_addr.assume_checked(); + assert_eq!( + Err(NodeError::InvalidAddress), + node_b.onchain_payment().calculate_send_all_fee(&wrong_net_addr, true, None) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn calculate_total_fee_estimation() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + let premine_amount_sat = 1_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a.clone()], + Amount::from_sat(premine_amount_sat), + ) + .await; + node_a.sync_wallets().unwrap(); + + let send_amount = 100_000; + let fee = node_a + .onchain_payment() + .calculate_total_fee(&addr_b, send_amount, None, None) + .expect("fee calculation should succeed"); + assert!(fee > 0, "fee must be positive"); + assert!(fee < send_amount, "fee should be less than the send amount"); + + // Send and verify the actual fee matches. + let txid = node_a.onchain_payment().send_to_address(&addr_b, send_amount, None, None).unwrap(); + wait_for_tx(&electrsd.client, txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let node_a_balance = node_a.list_balances().spendable_onchain_balance_sats; + let actual_fee = premine_amount_sat - send_amount - node_a_balance; + assert_eq!(fee, actual_fee, "estimated fee should match the actual fee paid"); + + // When the amount exceeds spendable balance, we should get an error. + let spendable = node_a.list_balances().spendable_onchain_balance_sats; + assert!(node_a + .onchain_payment() + .calculate_total_fee(&addr_b, spendable + 1, None, None) + .is_err()); + + // For max-amount sends, use calculate_send_all_fee instead of calculate_total_fee. + let send_all_fee = node_a + .onchain_payment() + .calculate_send_all_fee(&addr_b, true, None) + .expect("send_all fee should work for max-amount"); + assert!(send_all_fee > 0); + assert!(send_all_fee < spendable); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn onchain_wallet_recovery() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();