diff --git a/Cargo.lock b/Cargo.lock index 2350cc829..e2efff5c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -487,6 +487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" dependencies = [ "base58ck", + "base64 0.21.7", "bech32 0.11.1", "bitcoin-internals 0.3.0", "bitcoin-io", @@ -1657,6 +1658,7 @@ version = "0.2.0" dependencies = [ "anyhow", "bip39", + "bitcoin 0.32.8", "gl-client", "hex", "lightning-invoice", diff --git a/libs/gl-sdk-cli/src/node.rs b/libs/gl-sdk-cli/src/node.rs index 6aad93a6f..501feb152 100644 --- a/libs/gl-sdk-cli/src/node.rs +++ b/libs/gl-sdk-cli/src/node.rs @@ -128,7 +128,7 @@ fn onchain_receive(node: &glsdk::Node) -> Result<()> { } fn onchain_send(node: &glsdk::Node, destination: String, amount_or_all: String) -> Result<()> { - let res = node.onchain_send(destination, amount_or_all)?; + let res = node.onchain_send(destination, amount_or_all, None, None)?; output::print_json(&OnchainSendOutput::from(res)); Ok(()) } diff --git a/libs/gl-sdk-napi/src/lib.rs b/libs/gl-sdk-napi/src/lib.rs index 3e0749cff..b7cc69881 100644 --- a/libs/gl-sdk-napi/src/lib.rs +++ b/libs/gl-sdk-napi/src/lib.rs @@ -768,7 +768,7 @@ impl Node { let inner = self.inner.clone(); let response = tokio::task::spawn_blocking(move || { inner - .onchain_send(destination, amount_or_all) + .onchain_send(destination, amount_or_all, None, None) .map_err(|e| Error::from_reason(e.to_string())) }) .await diff --git a/libs/gl-sdk/Cargo.toml b/libs/gl-sdk/Cargo.toml index 03b908c2a..91211ba1e 100644 --- a/libs/gl-sdk/Cargo.toml +++ b/libs/gl-sdk/Cargo.toml @@ -13,6 +13,7 @@ name = "glsdk" [dependencies] anyhow = "1" bip39 = "2.2.0" +bitcoin = { version = "0.32", features = ["base64"] } gl-client = { version = "0.4.0", path = "../gl-client" } hex = "0.4" log = "0.4" diff --git a/libs/gl-sdk/glsdk/glsdk.py b/libs/gl-sdk/glsdk/glsdk.py index a85c957d0..d177d5a55 100644 --- a/libs/gl-sdk/glsdk/glsdk.py +++ b/libs/gl-sdk/glsdk/glsdk.py @@ -461,16 +461,8 @@ def _uniffi_check_contract_api_version(lib): raise InternalError("UniFFI contract version mismatch: try cleaning and rebuilding your project") def _uniffi_check_api_checksums(lib): - if lib.uniffi_glsdk_checksum_func_connect() != 43555: - raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_func_parse_input() != 49187: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_func_recover() != 39257: - raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_func_register() != 39628: - raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_func_register_or_recover() != 65070: - raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_func_resolve_input() != 24844: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_func_set_log_level() != 52328: @@ -513,9 +505,15 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_node_state() != 41833: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_method_node_onchain_balance_state() != 52276: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_method_node_onchain_fee_rates() != 57819: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_onchain_receive() != 46432: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_method_node_onchain_send() != 12590: + if lib.uniffi_glsdk_checksum_method_node_onchain_send() != 63330: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_method_node_prepare_onchain_send() != 37850: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_receive() != 39761: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") @@ -525,6 +523,16 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_stream_node_events() != 5933: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_method_nodebuilder_connect() != 47474: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_method_nodebuilder_recover() != 46087: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_method_nodebuilder_register() != 49580: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_method_nodebuilder_register_or_recover() != 5543: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_method_nodebuilder_with_event_listener() != 56969: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_nodeeventstream_next() != 12635: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_scheduler_recover() != 55514: @@ -545,7 +553,7 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_constructor_developercert_new() != 57793: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_constructor_node_new() != 7003: + if lib.uniffi_glsdk_checksum_constructor_nodebuilder_new() != 34740: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_constructor_scheduler_new() != 15239: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") @@ -555,6 +563,8 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_loglistener_on_log() != 34844: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_method_nodeeventlistener_on_event() != 17790: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") # A ctypes library to expose the extern-C FFI definitions. # This is an implementation detail which will be called internally by the public API. @@ -664,11 +674,19 @@ class _UniffiForeignFutureStructVoid(ctypes.Structure): _UNIFFI_CALLBACK_INTERFACE_LOG_LISTENER_METHOD0 = ctypes.CFUNCTYPE(None,ctypes.c_uint64,_UniffiRustBuffer,ctypes.c_void_p, ctypes.POINTER(_UniffiRustCallStatus), ) +_UNIFFI_CALLBACK_INTERFACE_NODE_EVENT_LISTENER_METHOD0 = ctypes.CFUNCTYPE(None,ctypes.c_uint64,_UniffiRustBuffer,ctypes.c_void_p, + ctypes.POINTER(_UniffiRustCallStatus), +) class _UniffiVTableCallbackInterfaceLogListener(ctypes.Structure): _fields_ = [ ("on_log", _UNIFFI_CALLBACK_INTERFACE_LOG_LISTENER_METHOD0), ("uniffi_free", _UNIFFI_CALLBACK_INTERFACE_FREE), ] +class _UniffiVTableCallbackInterfaceNodeEventListener(ctypes.Structure): + _fields_ = [ + ("on_event", _UNIFFI_CALLBACK_INTERFACE_NODE_EVENT_LISTENER_METHOD0), + ("uniffi_free", _UNIFFI_CALLBACK_INTERFACE_FREE), + ] _UniffiLib.uniffi_glsdk_fn_clone_config.argtypes = ( ctypes.c_void_p, ctypes.POINTER(_UniffiRustCallStatus), @@ -761,11 +779,6 @@ class _UniffiVTableCallbackInterfaceLogListener(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_glsdk_fn_free_node.restype = None -_UniffiLib.uniffi_glsdk_fn_constructor_node_new.argtypes = ( - ctypes.c_void_p, - ctypes.POINTER(_UniffiRustCallStatus), -) -_UniffiLib.uniffi_glsdk_fn_constructor_node_new.restype = ctypes.c_void_p _UniffiLib.uniffi_glsdk_fn_method_node_credentials.argtypes = ( ctypes.c_void_p, ctypes.POINTER(_UniffiRustCallStatus), @@ -847,6 +860,16 @@ class _UniffiVTableCallbackInterfaceLogListener(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_glsdk_fn_method_node_node_state.restype = _UniffiRustBuffer +_UniffiLib.uniffi_glsdk_fn_method_node_onchain_balance_state.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_method_node_onchain_balance_state.restype = _UniffiRustBuffer +_UniffiLib.uniffi_glsdk_fn_method_node_onchain_fee_rates.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_method_node_onchain_fee_rates.restype = _UniffiRustBuffer _UniffiLib.uniffi_glsdk_fn_method_node_onchain_receive.argtypes = ( ctypes.c_void_p, ctypes.POINTER(_UniffiRustCallStatus), @@ -856,9 +879,19 @@ class _UniffiVTableCallbackInterfaceLogListener(ctypes.Structure): ctypes.c_void_p, _UniffiRustBuffer, _UniffiRustBuffer, + _UniffiRustBuffer, + _UniffiRustBuffer, ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_glsdk_fn_method_node_onchain_send.restype = _UniffiRustBuffer +_UniffiLib.uniffi_glsdk_fn_method_node_prepare_onchain_send.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + _UniffiRustBuffer, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_method_node_prepare_onchain_send.restype = _UniffiRustBuffer _UniffiLib.uniffi_glsdk_fn_method_node_receive.argtypes = ( ctypes.c_void_p, _UniffiRustBuffer, @@ -884,6 +917,54 @@ class _UniffiVTableCallbackInterfaceLogListener(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_glsdk_fn_method_node_stream_node_events.restype = ctypes.c_void_p +_UniffiLib.uniffi_glsdk_fn_clone_nodebuilder.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_clone_nodebuilder.restype = ctypes.c_void_p +_UniffiLib.uniffi_glsdk_fn_free_nodebuilder.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_free_nodebuilder.restype = None +_UniffiLib.uniffi_glsdk_fn_constructor_nodebuilder_new.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_constructor_nodebuilder_new.restype = ctypes.c_void_p +_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_connect.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_connect.restype = ctypes.c_void_p +_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_recover.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_recover.restype = ctypes.c_void_p +_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_register.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_register.restype = ctypes.c_void_p +_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_register_or_recover.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_register_or_recover.restype = ctypes.c_void_p +_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_with_event_listener.argtypes = ( + ctypes.c_void_p, + ctypes.c_uint64, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_with_event_listener.restype = ctypes.c_void_p _UniffiLib.uniffi_glsdk_fn_clone_nodeeventstream.argtypes = ( ctypes.c_void_p, ctypes.POINTER(_UniffiRustCallStatus), @@ -973,38 +1054,15 @@ class _UniffiVTableCallbackInterfaceLogListener(ctypes.Structure): ctypes.POINTER(_UniffiVTableCallbackInterfaceLogListener), ) _UniffiLib.uniffi_glsdk_fn_init_callback_vtable_loglistener.restype = None -_UniffiLib.uniffi_glsdk_fn_func_connect.argtypes = ( - _UniffiRustBuffer, - _UniffiRustBuffer, - ctypes.c_void_p, - ctypes.POINTER(_UniffiRustCallStatus), +_UniffiLib.uniffi_glsdk_fn_init_callback_vtable_nodeeventlistener.argtypes = ( + ctypes.POINTER(_UniffiVTableCallbackInterfaceNodeEventListener), ) -_UniffiLib.uniffi_glsdk_fn_func_connect.restype = ctypes.c_void_p +_UniffiLib.uniffi_glsdk_fn_init_callback_vtable_nodeeventlistener.restype = None _UniffiLib.uniffi_glsdk_fn_func_parse_input.argtypes = ( _UniffiRustBuffer, ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_glsdk_fn_func_parse_input.restype = _UniffiRustBuffer -_UniffiLib.uniffi_glsdk_fn_func_recover.argtypes = ( - _UniffiRustBuffer, - ctypes.c_void_p, - ctypes.POINTER(_UniffiRustCallStatus), -) -_UniffiLib.uniffi_glsdk_fn_func_recover.restype = ctypes.c_void_p -_UniffiLib.uniffi_glsdk_fn_func_register.argtypes = ( - _UniffiRustBuffer, - _UniffiRustBuffer, - ctypes.c_void_p, - ctypes.POINTER(_UniffiRustCallStatus), -) -_UniffiLib.uniffi_glsdk_fn_func_register.restype = ctypes.c_void_p -_UniffiLib.uniffi_glsdk_fn_func_register_or_recover.argtypes = ( - _UniffiRustBuffer, - _UniffiRustBuffer, - ctypes.c_void_p, - ctypes.POINTER(_UniffiRustCallStatus), -) -_UniffiLib.uniffi_glsdk_fn_func_register_or_recover.restype = ctypes.c_void_p _UniffiLib.uniffi_glsdk_fn_func_resolve_input.argtypes = ( _UniffiRustBuffer, ) @@ -1288,21 +1346,9 @@ class _UniffiVTableCallbackInterfaceLogListener(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.ffi_glsdk_rust_future_complete_void.restype = None -_UniffiLib.uniffi_glsdk_checksum_func_connect.argtypes = ( -) -_UniffiLib.uniffi_glsdk_checksum_func_connect.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_func_parse_input.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_func_parse_input.restype = ctypes.c_uint16 -_UniffiLib.uniffi_glsdk_checksum_func_recover.argtypes = ( -) -_UniffiLib.uniffi_glsdk_checksum_func_recover.restype = ctypes.c_uint16 -_UniffiLib.uniffi_glsdk_checksum_func_register.argtypes = ( -) -_UniffiLib.uniffi_glsdk_checksum_func_register.restype = ctypes.c_uint16 -_UniffiLib.uniffi_glsdk_checksum_func_register_or_recover.argtypes = ( -) -_UniffiLib.uniffi_glsdk_checksum_func_register_or_recover.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_func_resolve_input.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_func_resolve_input.restype = ctypes.c_uint16 @@ -1366,12 +1412,21 @@ class _UniffiVTableCallbackInterfaceLogListener(ctypes.Structure): _UniffiLib.uniffi_glsdk_checksum_method_node_node_state.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_node_node_state.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_method_node_onchain_balance_state.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_method_node_onchain_balance_state.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_method_node_onchain_fee_rates.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_method_node_onchain_fee_rates.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_method_node_onchain_receive.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_node_onchain_receive.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_method_node_onchain_send.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_node_onchain_send.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_method_node_prepare_onchain_send.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_method_node_prepare_onchain_send.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_method_node_receive.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_node_receive.restype = ctypes.c_uint16 @@ -1384,6 +1439,21 @@ class _UniffiVTableCallbackInterfaceLogListener(ctypes.Structure): _UniffiLib.uniffi_glsdk_checksum_method_node_stream_node_events.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_node_stream_node_events.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_connect.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_connect.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_recover.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_recover.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_register.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_register.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_register_or_recover.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_register_or_recover.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_with_event_listener.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_with_event_listener.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_method_nodeeventstream_next.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_nodeeventstream_next.restype = ctypes.c_uint16 @@ -1414,9 +1484,9 @@ class _UniffiVTableCallbackInterfaceLogListener(ctypes.Structure): _UniffiLib.uniffi_glsdk_checksum_constructor_developercert_new.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_constructor_developercert_new.restype = ctypes.c_uint16 -_UniffiLib.uniffi_glsdk_checksum_constructor_node_new.argtypes = ( +_UniffiLib.uniffi_glsdk_checksum_constructor_nodebuilder_new.argtypes = ( ) -_UniffiLib.uniffi_glsdk_checksum_constructor_node_new.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_constructor_nodebuilder_new.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_constructor_scheduler_new.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_constructor_scheduler_new.restype = ctypes.c_uint16 @@ -1429,6 +1499,9 @@ class _UniffiVTableCallbackInterfaceLogListener(ctypes.Structure): _UniffiLib.uniffi_glsdk_checksum_method_loglistener_on_log.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_loglistener_on_log.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_method_nodeeventlistener_on_event.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_method_nodeeventlistener_on_event.restype = ctypes.c_uint16 _UniffiLib.ffi_glsdk_uniffi_contract_version.argtypes = ( ) _UniffiLib.ffi_glsdk_uniffi_contract_version.restype = ctypes.c_uint32 @@ -1586,6 +1659,8 @@ def write(value, buf): + + class FundChannel: peer_id: "str" """ @@ -3241,6 +3316,92 @@ def write(value, buf): _UniffiConverterUInt64.write(value.spendable_balance_msat, buf) +class OnchainFeeRates: + """ + On-chain fee rates in sats per virtual byte at various + confirmation targets, derived from the connected node's view of + network mempool conditions. Use as the basis for a fee-picker UI. + """ + + next_block_sat_per_vbyte: "int" + """ + Target the next block (~10 min). + """ + + half_hour_sat_per_vbyte: "int" + """ + ~30 minute confirmation target (3 blocks). + """ + + hour_sat_per_vbyte: "int" + """ + ~1 hour confirmation target (6 blocks). + """ + + day_sat_per_vbyte: "int" + """ + ~1 day confirmation target (144 blocks). Suitable for + non-urgent sweeps. + """ + + minimum_relay_sat_per_vbyte: "int" + """ + Network minimum relay fee. Anything below this will be + rejected by mempool policy at broadcast time. Use as the + lower bound of any user-facing fee slider. + """ + + def __init__(self, *, next_block_sat_per_vbyte: "int", half_hour_sat_per_vbyte: "int", hour_sat_per_vbyte: "int", day_sat_per_vbyte: "int", minimum_relay_sat_per_vbyte: "int"): + self.next_block_sat_per_vbyte = next_block_sat_per_vbyte + self.half_hour_sat_per_vbyte = half_hour_sat_per_vbyte + self.hour_sat_per_vbyte = hour_sat_per_vbyte + self.day_sat_per_vbyte = day_sat_per_vbyte + self.minimum_relay_sat_per_vbyte = minimum_relay_sat_per_vbyte + + def __str__(self): + return "OnchainFeeRates(next_block_sat_per_vbyte={}, half_hour_sat_per_vbyte={}, hour_sat_per_vbyte={}, day_sat_per_vbyte={}, minimum_relay_sat_per_vbyte={})".format(self.next_block_sat_per_vbyte, self.half_hour_sat_per_vbyte, self.hour_sat_per_vbyte, self.day_sat_per_vbyte, self.minimum_relay_sat_per_vbyte) + + def __eq__(self, other): + if self.next_block_sat_per_vbyte != other.next_block_sat_per_vbyte: + return False + if self.half_hour_sat_per_vbyte != other.half_hour_sat_per_vbyte: + return False + if self.hour_sat_per_vbyte != other.hour_sat_per_vbyte: + return False + if self.day_sat_per_vbyte != other.day_sat_per_vbyte: + return False + if self.minimum_relay_sat_per_vbyte != other.minimum_relay_sat_per_vbyte: + return False + return True + +class _UniffiConverterTypeOnchainFeeRates(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return OnchainFeeRates( + next_block_sat_per_vbyte=_UniffiConverterUInt64.read(buf), + half_hour_sat_per_vbyte=_UniffiConverterUInt64.read(buf), + hour_sat_per_vbyte=_UniffiConverterUInt64.read(buf), + day_sat_per_vbyte=_UniffiConverterUInt64.read(buf), + minimum_relay_sat_per_vbyte=_UniffiConverterUInt64.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterUInt64.check_lower(value.next_block_sat_per_vbyte) + _UniffiConverterUInt64.check_lower(value.half_hour_sat_per_vbyte) + _UniffiConverterUInt64.check_lower(value.hour_sat_per_vbyte) + _UniffiConverterUInt64.check_lower(value.day_sat_per_vbyte) + _UniffiConverterUInt64.check_lower(value.minimum_relay_sat_per_vbyte) + + @staticmethod + def write(value, buf): + _UniffiConverterUInt64.write(value.next_block_sat_per_vbyte, buf) + _UniffiConverterUInt64.write(value.half_hour_sat_per_vbyte, buf) + _UniffiConverterUInt64.write(value.hour_sat_per_vbyte, buf) + _UniffiConverterUInt64.write(value.day_sat_per_vbyte, buf) + _UniffiConverterUInt64.write(value.minimum_relay_sat_per_vbyte, buf) + + class OnchainReceiveResponse: """ A pair of on-chain addresses for receiving funds. @@ -3348,6 +3509,54 @@ def write(value, buf): _UniffiConverterString.write(value.psbt, buf) +class Outpoint: + """ + A specific on-chain output, identified by its outpoint. + """ + + txid: "str" + """ + Transaction id as lowercase hex (64 chars). + """ + + vout: "int" + """ + Output index within that transaction. + """ + + def __init__(self, *, txid: "str", vout: "int"): + self.txid = txid + self.vout = vout + + def __str__(self): + return "Outpoint(txid={}, vout={})".format(self.txid, self.vout) + + def __eq__(self, other): + if self.txid != other.txid: + return False + if self.vout != other.vout: + return False + return True + +class _UniffiConverterTypeOutpoint(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return Outpoint( + txid=_UniffiConverterString.read(buf), + vout=_UniffiConverterUInt32.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterString.check_lower(value.txid) + _UniffiConverterUInt32.check_lower(value.vout) + + @staticmethod + def write(value, buf): + _UniffiConverterString.write(value.txid, buf) + _UniffiConverterUInt32.write(value.vout, buf) + + class ParsedInvoice: """ Parsed BOLT11 invoice with extracted fields. @@ -3891,6 +4100,102 @@ def write(value, buf): _UniffiConverterSequenceString.write(value.status, buf) +class PreparedOnchainSend: + """ + Preview of an on-chain send: the inputs CLN would select at the + given fee rate, the resulting fee, and the amount the recipient + would receive. Inputs are NOT reserved — the wallet is free to + spend them via other paths until `onchain_send` actually broadcasts. + + Pass `utxos` and `sat_per_vbyte` back to `onchain_send` to broadcast + with identical inputs and fee. + + Amounts are in satoshis: on-chain transactions cannot carry sub-sat + precision, so msat denomination would be misleading here. + """ + + utxos: "typing.List[Outpoint]" + """ + UTXOs that would be spent, in selection order. + """ + + total_input_sat: "int" + """ + Sum of all input UTXO values, in satoshis. + """ + + fee_sat: "int" + """ + Fee that would be paid, in satoshis. + """ + + recipient_sat: "int" + """ + Amount the recipient would receive, in satoshis. + For a sweep ("all") this equals `total_input_sat - fee_sat`. + For a fixed amount this equals the requested amount. + """ + + sat_per_vbyte: "int" + """ + Effective fee rate (sat per virtual byte) the node used to + compute this preview. Equal to the caller's `sat_per_vbyte` if + one was supplied; otherwise the rate the node picked at + "normal" priority. Pass this back to `onchain_send` to + reproduce the previewed fee. + """ + + def __init__(self, *, utxos: "typing.List[Outpoint]", total_input_sat: "int", fee_sat: "int", recipient_sat: "int", sat_per_vbyte: "int"): + self.utxos = utxos + self.total_input_sat = total_input_sat + self.fee_sat = fee_sat + self.recipient_sat = recipient_sat + self.sat_per_vbyte = sat_per_vbyte + + def __str__(self): + return "PreparedOnchainSend(utxos={}, total_input_sat={}, fee_sat={}, recipient_sat={}, sat_per_vbyte={})".format(self.utxos, self.total_input_sat, self.fee_sat, self.recipient_sat, self.sat_per_vbyte) + + def __eq__(self, other): + if self.utxos != other.utxos: + return False + if self.total_input_sat != other.total_input_sat: + return False + if self.fee_sat != other.fee_sat: + return False + if self.recipient_sat != other.recipient_sat: + return False + if self.sat_per_vbyte != other.sat_per_vbyte: + return False + return True + +class _UniffiConverterTypePreparedOnchainSend(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return PreparedOnchainSend( + utxos=_UniffiConverterSequenceTypeOutpoint.read(buf), + total_input_sat=_UniffiConverterUInt64.read(buf), + fee_sat=_UniffiConverterUInt64.read(buf), + recipient_sat=_UniffiConverterUInt64.read(buf), + sat_per_vbyte=_UniffiConverterUInt32.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterSequenceTypeOutpoint.check_lower(value.utxos) + _UniffiConverterUInt64.check_lower(value.total_input_sat) + _UniffiConverterUInt64.check_lower(value.fee_sat) + _UniffiConverterUInt64.check_lower(value.recipient_sat) + _UniffiConverterUInt32.check_lower(value.sat_per_vbyte) + + @staticmethod + def write(value, buf): + _UniffiConverterSequenceTypeOutpoint.write(value.utxos, buf) + _UniffiConverterUInt64.write(value.total_input_sat, buf) + _UniffiConverterUInt64.write(value.fee_sat, buf) + _UniffiConverterUInt64.write(value.recipient_sat, buf) + _UniffiConverterUInt32.write(value.sat_per_vbyte, buf) + + class ReceiveResponse: bolt11: "str" opening_fee_msat: "int" @@ -4866,24 +5171,6 @@ def __eq__(self, other): return False return True - class UNKNOWN: - """ - An unknown event type was received. This can happen if the - server sends a new event type that this client doesn't know about. - """ - - - def __init__(self,): - pass - - def __str__(self): - return "NodeEvent.UNKNOWN()".format() - - def __eq__(self, other): - if not other.is_UNKNOWN(): - return False - return True - # For each variant, we have `is_NAME` and `is_name` methods for easily checking @@ -4892,17 +5179,12 @@ def is_INVOICE_PAID(self) -> bool: return isinstance(self, NodeEvent.INVOICE_PAID) def is_invoice_paid(self) -> bool: return isinstance(self, NodeEvent.INVOICE_PAID) - def is_UNKNOWN(self) -> bool: - return isinstance(self, NodeEvent.UNKNOWN) - def is_unknown(self) -> bool: - return isinstance(self, NodeEvent.UNKNOWN) # Now, a little trick - we make each nested variant class be a subclass of the main # enum class, so that method calls and instance checks etc will work intuitively. # We might be able to do this a little more neatly with a metaclass, but this'll do. NodeEvent.INVOICE_PAID = type("NodeEvent.INVOICE_PAID", (NodeEvent.INVOICE_PAID, NodeEvent,), {}) # type: ignore -NodeEvent.UNKNOWN = type("NodeEvent.UNKNOWN", (NodeEvent.UNKNOWN, NodeEvent,), {}) # type: ignore @@ -4915,9 +5197,6 @@ def read(buf): return NodeEvent.INVOICE_PAID( _UniffiConverterTypeInvoicePaidEvent.read(buf), ) - if variant == 2: - return NodeEvent.UNKNOWN( - ) raise InternalError("Raw enum value doesn't match any cases") @staticmethod @@ -4925,8 +5204,6 @@ def check_lower(value): if value.is_INVOICE_PAID(): _UniffiConverterTypeInvoicePaidEvent.check_lower(value.details) return - if value.is_UNKNOWN(): - return raise ValueError(value) @staticmethod @@ -4934,8 +5211,6 @@ def write(value, buf): if value.is_INVOICE_PAID(): buf.write_i32(1) _UniffiConverterTypeInvoicePaidEvent.write(value.details, buf) - if value.is_UNKNOWN(): - buf.write_i32(2) @@ -4943,25 +5218,276 @@ def write(value, buf): -class OutputStatus(enum.Enum): - UNCONFIRMED = 0 - - CONFIRMED = 1 - - SPENT = 2 - - IMMATURE = 3 - +class OnchainBalanceState: + """ + Classifies the on-chain wallet into discrete cases that a wallet + UI can switch on to render the correct entry-point for the + withdraw flow. Derived purely from `NodeState` — no RPC. + """ + def __init__(self): + raise RuntimeError("OnchainBalanceState cannot be instantiated directly") -class _UniffiConverterTypeOutputStatus(_UniffiConverterRustBuffer): - @staticmethod - def read(buf): - variant = buf.read_i32() - if variant == 1: - return OutputStatus.UNCONFIRMED - if variant == 2: - return OutputStatus.CONFIRMED + # Each enum variant is a nested class of the enum itself. + class UNAVAILABLE: + """ + No funds on-chain in any form (confirmed, unconfirmed, + immature, or pending channel-close payouts are all zero). + Don't render a withdraw entry point. + """ + + + def __init__(self,): + pass + + def __str__(self): + return "OnchainBalanceState.UNAVAILABLE()".format() + + def __eq__(self, other): + if not other.is_UNAVAILABLE(): + return False + return True + + class AVAILABLE: + """ + Funds are spendable now. Render the withdraw entry point + enabled with `withdrawable_sat` as the headline. + """ + + withdrawable_sat: "int" + """ + `onchain_balance_sat - emergency_reserve_sat`. Use as + the displayed amount on the entry point. + """ + + emergency_reserve_sat: "int" + """ + Held back by CLN for anchor-channel safety; cannot be + withdrawn without closing channels first. + """ + + unconfirmed_sat: "int" + """ + Inbound on-chain funds not yet confirmed. Informational + only — not part of `withdrawable_sat`. + """ + + + def __init__(self,withdrawable_sat: "int", emergency_reserve_sat: "int", unconfirmed_sat: "int"): + self.withdrawable_sat = withdrawable_sat + self.emergency_reserve_sat = emergency_reserve_sat + self.unconfirmed_sat = unconfirmed_sat + + def __str__(self): + return "OnchainBalanceState.AVAILABLE(withdrawable_sat={}, emergency_reserve_sat={}, unconfirmed_sat={})".format(self.withdrawable_sat, self.emergency_reserve_sat, self.unconfirmed_sat) + + def __eq__(self, other): + if not other.is_AVAILABLE(): + return False + if self.withdrawable_sat != other.withdrawable_sat: + return False + if self.emergency_reserve_sat != other.emergency_reserve_sat: + return False + if self.unconfirmed_sat != other.unconfirmed_sat: + return False + return True + + class RESERVE_ONLY: + """ + On-chain funds exist but are entirely locked as the + anchor-channel emergency reserve. Render the entry point + disabled with an explainer (e.g. "close channels to free + these funds"). + """ + + reserve_sat: "int" + + def __init__(self,reserve_sat: "int"): + self.reserve_sat = reserve_sat + + def __str__(self): + return "OnchainBalanceState.RESERVE_ONLY(reserve_sat={})".format(self.reserve_sat) + + def __eq__(self, other): + if not other.is_RESERVE_ONLY(): + return False + if self.reserve_sat != other.reserve_sat: + return False + return True + + class PENDING_CONFIRMATION: + """ + Inbound on-chain funds are awaiting confirmation. Render a + "pending" indicator instead of an enabled withdraw button. + """ + + unconfirmed_sat: "int" + + def __init__(self,unconfirmed_sat: "int"): + self.unconfirmed_sat = unconfirmed_sat + + def __str__(self): + return "OnchainBalanceState.PENDING_CONFIRMATION(unconfirmed_sat={})".format(self.unconfirmed_sat) + + def __eq__(self, other): + if not other.is_PENDING_CONFIRMATION(): + return False + if self.unconfirmed_sat != other.unconfirmed_sat: + return False + return True + + class IMMATURE: + """ + Funds exist as CSV-timelocked outputs from a recent channel + close and can't be spent until the relative locktime + expires. Render the entry point disabled with a + "channel closing" explainer. + """ + + immature_sat: "int" + + def __init__(self,immature_sat: "int"): + self.immature_sat = immature_sat + + def __str__(self): + return "OnchainBalanceState.IMMATURE(immature_sat={})".format(self.immature_sat) + + def __eq__(self, other): + if not other.is_IMMATURE(): + return False + if self.immature_sat != other.immature_sat: + return False + return True + + + + # For each variant, we have `is_NAME` and `is_name` methods for easily checking + # whether an instance is that variant. + def is_UNAVAILABLE(self) -> bool: + return isinstance(self, OnchainBalanceState.UNAVAILABLE) + def is_unavailable(self) -> bool: + return isinstance(self, OnchainBalanceState.UNAVAILABLE) + def is_AVAILABLE(self) -> bool: + return isinstance(self, OnchainBalanceState.AVAILABLE) + def is_available(self) -> bool: + return isinstance(self, OnchainBalanceState.AVAILABLE) + def is_RESERVE_ONLY(self) -> bool: + return isinstance(self, OnchainBalanceState.RESERVE_ONLY) + def is_reserve_only(self) -> bool: + return isinstance(self, OnchainBalanceState.RESERVE_ONLY) + def is_PENDING_CONFIRMATION(self) -> bool: + return isinstance(self, OnchainBalanceState.PENDING_CONFIRMATION) + def is_pending_confirmation(self) -> bool: + return isinstance(self, OnchainBalanceState.PENDING_CONFIRMATION) + def is_IMMATURE(self) -> bool: + return isinstance(self, OnchainBalanceState.IMMATURE) + def is_immature(self) -> bool: + return isinstance(self, OnchainBalanceState.IMMATURE) + + +# Now, a little trick - we make each nested variant class be a subclass of the main +# enum class, so that method calls and instance checks etc will work intuitively. +# We might be able to do this a little more neatly with a metaclass, but this'll do. +OnchainBalanceState.UNAVAILABLE = type("OnchainBalanceState.UNAVAILABLE", (OnchainBalanceState.UNAVAILABLE, OnchainBalanceState,), {}) # type: ignore +OnchainBalanceState.AVAILABLE = type("OnchainBalanceState.AVAILABLE", (OnchainBalanceState.AVAILABLE, OnchainBalanceState,), {}) # type: ignore +OnchainBalanceState.RESERVE_ONLY = type("OnchainBalanceState.RESERVE_ONLY", (OnchainBalanceState.RESERVE_ONLY, OnchainBalanceState,), {}) # type: ignore +OnchainBalanceState.PENDING_CONFIRMATION = type("OnchainBalanceState.PENDING_CONFIRMATION", (OnchainBalanceState.PENDING_CONFIRMATION, OnchainBalanceState,), {}) # type: ignore +OnchainBalanceState.IMMATURE = type("OnchainBalanceState.IMMATURE", (OnchainBalanceState.IMMATURE, OnchainBalanceState,), {}) # type: ignore + + + + +class _UniffiConverterTypeOnchainBalanceState(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return OnchainBalanceState.UNAVAILABLE( + ) + if variant == 2: + return OnchainBalanceState.AVAILABLE( + _UniffiConverterUInt64.read(buf), + _UniffiConverterUInt64.read(buf), + _UniffiConverterUInt64.read(buf), + ) + if variant == 3: + return OnchainBalanceState.RESERVE_ONLY( + _UniffiConverterUInt64.read(buf), + ) + if variant == 4: + return OnchainBalanceState.PENDING_CONFIRMATION( + _UniffiConverterUInt64.read(buf), + ) + if variant == 5: + return OnchainBalanceState.IMMATURE( + _UniffiConverterUInt64.read(buf), + ) + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value.is_UNAVAILABLE(): + return + if value.is_AVAILABLE(): + _UniffiConverterUInt64.check_lower(value.withdrawable_sat) + _UniffiConverterUInt64.check_lower(value.emergency_reserve_sat) + _UniffiConverterUInt64.check_lower(value.unconfirmed_sat) + return + if value.is_RESERVE_ONLY(): + _UniffiConverterUInt64.check_lower(value.reserve_sat) + return + if value.is_PENDING_CONFIRMATION(): + _UniffiConverterUInt64.check_lower(value.unconfirmed_sat) + return + if value.is_IMMATURE(): + _UniffiConverterUInt64.check_lower(value.immature_sat) + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value.is_UNAVAILABLE(): + buf.write_i32(1) + if value.is_AVAILABLE(): + buf.write_i32(2) + _UniffiConverterUInt64.write(value.withdrawable_sat, buf) + _UniffiConverterUInt64.write(value.emergency_reserve_sat, buf) + _UniffiConverterUInt64.write(value.unconfirmed_sat, buf) + if value.is_RESERVE_ONLY(): + buf.write_i32(3) + _UniffiConverterUInt64.write(value.reserve_sat, buf) + if value.is_PENDING_CONFIRMATION(): + buf.write_i32(4) + _UniffiConverterUInt64.write(value.unconfirmed_sat, buf) + if value.is_IMMATURE(): + buf.write_i32(5) + _UniffiConverterUInt64.write(value.immature_sat, buf) + + + + + + + +class OutputStatus(enum.Enum): + UNCONFIRMED = 0 + + CONFIRMED = 1 + + SPENT = 2 + + IMMATURE = 3 + + + +class _UniffiConverterTypeOutputStatus(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return OutputStatus.UNCONFIRMED + if variant == 2: + return OutputStatus.CONFIRMED if variant == 3: return OutputStatus.SPENT if variant == 4: @@ -5755,6 +6281,68 @@ def _uniffi_free(uniffi_handle): + +class NodeEventListener(typing.Protocol): + """ + Callback interface for receiving node events. + + `on_event` is invoked from the SDK's internal event-dispatch task. + Implementations should be cheap and non-blocking; to update UI, + dispatch to the main thread from inside the handler. + + Installed via `NodeBuilder::with_event_listener(...)` so events + emitted during node bring-up are captured. The polling-style + `Node::stream_node_events()` API is still available for callers + that prefer to drive events themselves. + """ + + def on_event(self, event: "NodeEvent"): + raise NotImplementedError + + +# Put all the bits inside a class to keep the top-level namespace clean +class _UniffiTraitImplNodeEventListener: + # For each method, generate a callback function to pass to Rust + + @_UNIFFI_CALLBACK_INTERFACE_NODE_EVENT_LISTENER_METHOD0 + def on_event( + uniffi_handle, + event, + uniffi_out_return, + uniffi_call_status_ptr, + ): + uniffi_obj = _UniffiConverterTypeNodeEventListener._handle_map.get(uniffi_handle) + def make_call(): + args = (_UniffiConverterTypeNodeEvent.lift(event), ) + method = uniffi_obj.on_event + return method(*args) + + + write_return_value = lambda v: None + _uniffi_trait_interface_call( + uniffi_call_status_ptr.contents, + make_call, + write_return_value, + ) + + @_UNIFFI_CALLBACK_INTERFACE_FREE + def _uniffi_free(uniffi_handle): + _UniffiConverterTypeNodeEventListener._handle_map.remove(uniffi_handle) + + # Generate the FFI VTable. This has a field for each callback interface method. + _uniffi_vtable = _UniffiVTableCallbackInterfaceNodeEventListener( + on_event, + _uniffi_free + ) + # Send Rust a pointer to the VTable. Note: this means we need to keep the struct alive forever, + # or else bad things will happen when Rust tries to access it. + _UniffiLib.uniffi_glsdk_fn_init_callback_vtable_nodeeventlistener(ctypes.byref(_uniffi_vtable)) + +# The _UniffiConverter which transforms the Callbacks in to Handles to pass to Rust. +_UniffiConverterTypeNodeEventListener = _UniffiCallbackInterfaceFfiConverter() + + + class _UniffiConverterOptionalUInt32(_UniffiConverterRustBuffer): @classmethod def check_lower(cls, value): @@ -6025,6 +6613,33 @@ def read(cls, buf): +class _UniffiConverterOptionalSequenceTypeOutpoint(_UniffiConverterRustBuffer): + @classmethod + def check_lower(cls, value): + if value is not None: + _UniffiConverterSequenceTypeOutpoint.check_lower(value) + + @classmethod + def write(cls, value, buf): + if value is None: + buf.write_u8(0) + return + + buf.write_u8(1) + _UniffiConverterSequenceTypeOutpoint.write(value, buf) + + @classmethod + def read(cls, buf): + flag = buf.read_u8() + if flag == 0: + return None + elif flag == 1: + return _UniffiConverterSequenceTypeOutpoint.read(buf) + else: + raise InternalError("Unexpected flag byte for optional type") + + + class _UniffiConverterOptionalSequenceTypePaymentTypeFilter(_UniffiConverterRustBuffer): @classmethod def check_lower(cls, value): @@ -6152,6 +6767,31 @@ def read(cls, buf): +class _UniffiConverterSequenceTypeOutpoint(_UniffiConverterRustBuffer): + @classmethod + def check_lower(cls, value): + for item in value: + _UniffiConverterTypeOutpoint.check_lower(item) + + @classmethod + def write(cls, value, buf): + items = len(value) + buf.write_i32(items) + for item in value: + _UniffiConverterTypeOutpoint.write(item, buf) + + @classmethod + def read(cls, buf): + count = buf.read_i32() + if count < 0: + raise InternalError("Unexpected negative sequence length") + + return [ + _UniffiConverterTypeOutpoint.read(buf) for i in range(count) + ] + + + class _UniffiConverterSequenceTypePay(_UniffiConverterRustBuffer): @classmethod def check_lower(cls, value): @@ -6780,6 +7420,45 @@ def node_state(self, ): Queries the node live on each call — not cached. """ + raise NotImplementedError + def onchain_balance_state(self, ): + """ + Classify the on-chain wallet for the withdraw entry-point UI. + + Runs three RPCs concurrently: + * `list_funds` — current confirmed/unconfirmed/immature on-chain + balances. + * `list_peer_channels` — pending channel-close payouts that + haven't yet hit the wallet. + * `fund_psbt(satoshi=All, reserve=0, normal feerate)` — a + non-locking probe whose response tells us **exactly** how + much CLN will carve as the anchor-channel emergency reserve + for this specific node, no client-side guessing required. + The carved amount is computed from the response as + `total_inputs − excess − fee`, which is identical to what + CLN would carve on a real broadcast. + + Cheaper to call than `node_state()` and answers a different + question. Wallets typically call it once per render of the + home screen. + + For the *exact* post-fee recipient amount of a withdraw, use + `prepare_onchain_send`; the `withdrawable_sat` returned here + is a pre-fee, reserve-aware figure for the entry-point label. + """ + + raise NotImplementedError + def onchain_fee_rates(self, ): + """ + On-chain fee rates, in sats per virtual byte, at several + confirmation targets. + + Sourced from the connected node's view of the network — no + 3rd-party HTTP calls. Use as the basis for a fee-picker UI; + `minimum_relay_sat_per_vbyte` is the relay floor enforced at + broadcast time and should be the lower bound of any slider. + """ + raise NotImplementedError def onchain_receive(self, ): """ @@ -6791,7 +7470,7 @@ def onchain_receive(self, ): """ raise NotImplementedError - def onchain_send(self, destination: "str",amount_or_all: "str"): + def onchain_send(self, destination: "str",amount_or_all: "str",sat_per_vbyte: "typing.Optional[int]",utxos: "typing.Optional[typing.List[Outpoint]]"): """ Send bitcoin on-chain to a destination address. @@ -6801,11 +7480,54 @@ def onchain_send(self, destination: "str",amount_or_all: "str"): - `"50000"` or `"50000sat"` — 50,000 satoshis - `"50000msat"` — 50,000 millisatoshis - `"all"` — sweep the entire on-chain balance + * `sat_per_vbyte` — Optional fee rate in sats per virtual byte. + Pass `None` to let the node pick. Pass the value from a prior + `prepare_onchain_send` to reproduce the previewed fee. + * `utxos` — Optional pinned input set. Pass the `utxos` returned + by `prepare_onchain_send` (together with the same + `sat_per_vbyte`) to broadcast a transaction with the exact + inputs and fee shown in the preview. Pass `None` to let the + node coin-select. Returns the raw transaction, txid, and PSBT once broadcast. The transaction is broadcast immediately — this is not a dry run. """ + raise NotImplementedError + def prepare_onchain_send(self, destination: "str",amount_or_all: "str",sat_per_vbyte: "typing.Optional[int]"): + """ + Preview an on-chain send without broadcasting or reserving UTXOs. + + Runs CLN's coin selection at the given fee rate and returns the + inputs that would be spent, the fee, and the amount the recipient + would receive. Safe to call repeatedly (e.g. while the user + adjusts a fee slider) — nothing is locked. + + To broadcast with the previewed values, pass the returned + `utxos` and `sat_per_vbyte` back to `onchain_send`. Identical + inputs at the same fee rate yield the same fee. + + **Use this for "Send Max" UIs.** `recipient_sat` is the only + authoritative post-fee amount the destination will receive + for a sweep. `NodeState.onchain_balance_msat` includes the + emergency reserve and the fee — neither of which leaves the + wallet with the recipient. For the entry-point button label + (a pre-fee approximation that updates without an RPC), use + `OnchainBalanceState::Available.withdrawable_sat`. + + # Arguments + * `destination` — A Bitcoin address (bech32, p2sh, or p2tr). + * `amount_or_all` — Amount to send. Accepts: + - `"50000"` or `"50000sat"` — 50,000 satoshis + - `"50000msat"` — 50,000 millisatoshis + - `"all"` — sweep the entire on-chain balance + * `sat_per_vbyte` — Fee rate in sats per virtual byte. Pass + `None` to use the node's "normal" priority feerate; the + effective rate CLN picked is reported back in the result's + `sat_per_vbyte` field, which can be passed to `onchain_send` + to reproduce it. + """ + raise NotImplementedError def receive(self, label: "str",description: "str",amount_msat: "typing.Optional[int]"): """ @@ -6852,11 +7574,9 @@ class Node(): """ _pointer: ctypes.c_void_p - def __init__(self, credentials: "Credentials"): - _UniffiConverterTypeCredentials.check_lower(credentials) - - self._pointer = _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_constructor_node_new, - _UniffiConverterTypeCredentials.lower(credentials)) + + def __init__(self, *args, **kwargs): + raise ValueError("This class has no default constructor") def __del__(self): # In case of partial initialization of instances. @@ -7143,6 +7863,59 @@ def node_state(self, ) -> "NodeState": + def onchain_balance_state(self, ) -> "OnchainBalanceState": + """ + Classify the on-chain wallet for the withdraw entry-point UI. + + Runs three RPCs concurrently: + * `list_funds` — current confirmed/unconfirmed/immature on-chain + balances. + * `list_peer_channels` — pending channel-close payouts that + haven't yet hit the wallet. + * `fund_psbt(satoshi=All, reserve=0, normal feerate)` — a + non-locking probe whose response tells us **exactly** how + much CLN will carve as the anchor-channel emergency reserve + for this specific node, no client-side guessing required. + The carved amount is computed from the response as + `total_inputs − excess − fee`, which is identical to what + CLN would carve on a real broadcast. + + Cheaper to call than `node_state()` and answers a different + question. Wallets typically call it once per render of the + home screen. + + For the *exact* post-fee recipient amount of a withdraw, use + `prepare_onchain_send`; the `withdrawable_sat` returned here + is a pre-fee, reserve-aware figure for the entry-point label. + """ + + return _UniffiConverterTypeOnchainBalanceState.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_node_onchain_balance_state,self._uniffi_clone_pointer(),) + ) + + + + + + def onchain_fee_rates(self, ) -> "OnchainFeeRates": + """ + On-chain fee rates, in sats per virtual byte, at several + confirmation targets. + + Sourced from the connected node's view of the network — no + 3rd-party HTTP calls. Use as the basis for a fee-picker UI; + `minimum_relay_sat_per_vbyte` is the relay floor enforced at + broadcast time and should be the lower bound of any slider. + """ + + return _UniffiConverterTypeOnchainFeeRates.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_node_onchain_fee_rates,self._uniffi_clone_pointer(),) + ) + + + + + def onchain_receive(self, ) -> "OnchainReceiveResponse": """ Generate a fresh on-chain Bitcoin address for receiving funds. @@ -7160,7 +7933,7 @@ def onchain_receive(self, ) -> "OnchainReceiveResponse": - def onchain_send(self, destination: "str",amount_or_all: "str") -> "OnchainSendResponse": + def onchain_send(self, destination: "str",amount_or_all: "str",sat_per_vbyte: "typing.Optional[int]",utxos: "typing.Optional[typing.List[Outpoint]]") -> "OnchainSendResponse": """ Send bitcoin on-chain to a destination address. @@ -7170,6 +7943,14 @@ def onchain_send(self, destination: "str",amount_or_all: "str") -> "OnchainSendR - `"50000"` or `"50000sat"` — 50,000 satoshis - `"50000msat"` — 50,000 millisatoshis - `"all"` — sweep the entire on-chain balance + * `sat_per_vbyte` — Optional fee rate in sats per virtual byte. + Pass `None` to let the node pick. Pass the value from a prior + `prepare_onchain_send` to reproduce the previewed fee. + * `utxos` — Optional pinned input set. Pass the `utxos` returned + by `prepare_onchain_send` (together with the same + `sat_per_vbyte`) to broadcast a transaction with the exact + inputs and fee shown in the preview. Pass `None` to let the + node coin-select. Returns the raw transaction, txid, and PSBT once broadcast. The transaction is broadcast immediately — this is not a dry run. @@ -7179,10 +7960,67 @@ def onchain_send(self, destination: "str",amount_or_all: "str") -> "OnchainSendR _UniffiConverterString.check_lower(amount_or_all) + _UniffiConverterOptionalUInt32.check_lower(sat_per_vbyte) + + _UniffiConverterOptionalSequenceTypeOutpoint.check_lower(utxos) + return _UniffiConverterTypeOnchainSendResponse.lift( _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_node_onchain_send,self._uniffi_clone_pointer(), _UniffiConverterString.lower(destination), - _UniffiConverterString.lower(amount_or_all)) + _UniffiConverterString.lower(amount_or_all), + _UniffiConverterOptionalUInt32.lower(sat_per_vbyte), + _UniffiConverterOptionalSequenceTypeOutpoint.lower(utxos)) + ) + + + + + + def prepare_onchain_send(self, destination: "str",amount_or_all: "str",sat_per_vbyte: "typing.Optional[int]") -> "PreparedOnchainSend": + """ + Preview an on-chain send without broadcasting or reserving UTXOs. + + Runs CLN's coin selection at the given fee rate and returns the + inputs that would be spent, the fee, and the amount the recipient + would receive. Safe to call repeatedly (e.g. while the user + adjusts a fee slider) — nothing is locked. + + To broadcast with the previewed values, pass the returned + `utxos` and `sat_per_vbyte` back to `onchain_send`. Identical + inputs at the same fee rate yield the same fee. + + **Use this for "Send Max" UIs.** `recipient_sat` is the only + authoritative post-fee amount the destination will receive + for a sweep. `NodeState.onchain_balance_msat` includes the + emergency reserve and the fee — neither of which leaves the + wallet with the recipient. For the entry-point button label + (a pre-fee approximation that updates without an RPC), use + `OnchainBalanceState::Available.withdrawable_sat`. + + # Arguments + * `destination` — A Bitcoin address (bech32, p2sh, or p2tr). + * `amount_or_all` — Amount to send. Accepts: + - `"50000"` or `"50000sat"` — 50,000 satoshis + - `"50000msat"` — 50,000 millisatoshis + - `"all"` — sweep the entire on-chain balance + * `sat_per_vbyte` — Fee rate in sats per virtual byte. Pass + `None` to use the node's "normal" priority feerate; the + effective rate CLN picked is reported back in the result's + `sat_per_vbyte` field, which can be passed to `onchain_send` + to reproduce it. + """ + + _UniffiConverterString.check_lower(destination) + + _UniffiConverterString.check_lower(amount_or_all) + + _UniffiConverterOptionalUInt32.check_lower(sat_per_vbyte) + + return _UniffiConverterTypePreparedOnchainSend.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_node_prepare_onchain_send,self._uniffi_clone_pointer(), + _UniffiConverterString.lower(destination), + _UniffiConverterString.lower(amount_or_all), + _UniffiConverterOptionalUInt32.lower(sat_per_vbyte)) ) @@ -7296,6 +8134,262 @@ def read(cls, buf: _UniffiRustBuffer): @classmethod def write(cls, value: NodeProtocol, buf: _UniffiRustBuffer): buf.write_u64(cls.lower(value)) +class NodeBuilderProtocol(typing.Protocol): + """ + Configurable Node construction. See module docs. + + All fields are immutable after construction. Each `with_*` setter + returns a fresh `Arc` that shares ownership of any + previously-installed modifiers via `Arc`. No interior + mutability, no locks — the builder is a value, not a state + machine. + """ + + def connect(self, credentials: "bytes",mnemonic: "typing.Optional[str]"): + """ + Connect to an existing node using saved credentials and return + a connected Node with any configured modifiers applied. + + If `mnemonic` is `Some(...)`, the SDK spawns a signer for the + connected Node. If `None`, the Node is signerless and signing + happens elsewhere (paired device, CLN node's local signer, + hardware signer). + """ + + raise NotImplementedError + def recover(self, mnemonic: "str"): + """ + Recover credentials for an existing node and return a + connected Node with any configured modifiers applied. + + `mnemonic` is required — recovery drives the signer to + authenticate. + """ + + raise NotImplementedError + def register(self, mnemonic: "str",invite_code: "typing.Optional[str]"): + """ + Register a new Greenlight node and return a connected Node + with the SDK signer running and any configured modifiers + applied. + + `mnemonic` is required — registration drives the signer to + sign the registration challenge, so the SDK must hold the + seed for this call. + """ + + raise NotImplementedError + def register_or_recover(self, mnemonic: "str",invite_code: "typing.Optional[str]"): + """ + Try to recover; if the node doesn't exist, register a new one. + + `mnemonic` is required — both recover and register drive the + signer. + """ + + raise NotImplementedError + def with_event_listener(self, listener: "NodeEventListener"): + """ + Install a node event listener. Events fire from the moment the + gRPC stream is established by the build call (`register` / + `recover` / `connect` / …), so attach the listener via the + builder rather than after the fact to capture events from the + very first moment. + + Returns a new builder that shares the rest of the + configuration. Build calls on the returned builder will + install the listener; the original builder is unchanged. + """ + + raise NotImplementedError +# NodeBuilder is a Rust-only trait - it's a wrapper around a Rust implementation. +class NodeBuilder(): + """ + Configurable Node construction. See module docs. + + All fields are immutable after construction. Each `with_*` setter + returns a fresh `Arc` that shares ownership of any + previously-installed modifiers via `Arc`. No interior + mutability, no locks — the builder is a value, not a state + machine. + """ + + _pointer: ctypes.c_void_p + def __init__(self, config: "Config"): + """ + Create a builder for a Node with `config`. No I/O happens + until you call `connect` / `register` / `recover` / + `register_or_recover`. + """ + + _UniffiConverterTypeConfig.check_lower(config) + + self._pointer = _uniffi_rust_call(_UniffiLib.uniffi_glsdk_fn_constructor_nodebuilder_new, + _UniffiConverterTypeConfig.lower(config)) + + def __del__(self): + # In case of partial initialization of instances. + pointer = getattr(self, "_pointer", None) + if pointer is not None: + _uniffi_rust_call(_UniffiLib.uniffi_glsdk_fn_free_nodebuilder, pointer) + + def _uniffi_clone_pointer(self): + return _uniffi_rust_call(_UniffiLib.uniffi_glsdk_fn_clone_nodebuilder, self._pointer) + + # Used by alternative constructors or any methods which return this type. + @classmethod + def _make_instance_(cls, pointer): + # Lightly yucky way to bypass the usual __init__ logic + # and just create a new instance with the required pointer. + inst = cls.__new__(cls) + inst._pointer = pointer + return inst + + + def connect(self, credentials: "bytes",mnemonic: "typing.Optional[str]") -> "Node": + """ + Connect to an existing node using saved credentials and return + a connected Node with any configured modifiers applied. + + If `mnemonic` is `Some(...)`, the SDK spawns a signer for the + connected Node. If `None`, the Node is signerless and signing + happens elsewhere (paired device, CLN node's local signer, + hardware signer). + """ + + _UniffiConverterBytes.check_lower(credentials) + + _UniffiConverterOptionalString.check_lower(mnemonic) + + return _UniffiConverterTypeNode.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_connect,self._uniffi_clone_pointer(), + _UniffiConverterBytes.lower(credentials), + _UniffiConverterOptionalString.lower(mnemonic)) + ) + + + + + + def recover(self, mnemonic: "str") -> "Node": + """ + Recover credentials for an existing node and return a + connected Node with any configured modifiers applied. + + `mnemonic` is required — recovery drives the signer to + authenticate. + """ + + _UniffiConverterString.check_lower(mnemonic) + + return _UniffiConverterTypeNode.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_recover,self._uniffi_clone_pointer(), + _UniffiConverterString.lower(mnemonic)) + ) + + + + + + def register(self, mnemonic: "str",invite_code: "typing.Optional[str]") -> "Node": + """ + Register a new Greenlight node and return a connected Node + with the SDK signer running and any configured modifiers + applied. + + `mnemonic` is required — registration drives the signer to + sign the registration challenge, so the SDK must hold the + seed for this call. + """ + + _UniffiConverterString.check_lower(mnemonic) + + _UniffiConverterOptionalString.check_lower(invite_code) + + return _UniffiConverterTypeNode.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_register,self._uniffi_clone_pointer(), + _UniffiConverterString.lower(mnemonic), + _UniffiConverterOptionalString.lower(invite_code)) + ) + + + + + + def register_or_recover(self, mnemonic: "str",invite_code: "typing.Optional[str]") -> "Node": + """ + Try to recover; if the node doesn't exist, register a new one. + + `mnemonic` is required — both recover and register drive the + signer. + """ + + _UniffiConverterString.check_lower(mnemonic) + + _UniffiConverterOptionalString.check_lower(invite_code) + + return _UniffiConverterTypeNode.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_register_or_recover,self._uniffi_clone_pointer(), + _UniffiConverterString.lower(mnemonic), + _UniffiConverterOptionalString.lower(invite_code)) + ) + + + + + + def with_event_listener(self, listener: "NodeEventListener") -> "NodeBuilder": + """ + Install a node event listener. Events fire from the moment the + gRPC stream is established by the build call (`register` / + `recover` / `connect` / …), so attach the listener via the + builder rather than after the fact to capture events from the + very first moment. + + Returns a new builder that shares the rest of the + configuration. Build calls on the returned builder will + install the listener; the original builder is unchanged. + """ + + _UniffiConverterTypeNodeEventListener.check_lower(listener) + + return _UniffiConverterTypeNodeBuilder.lift( + _uniffi_rust_call(_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_with_event_listener,self._uniffi_clone_pointer(), + _UniffiConverterTypeNodeEventListener.lower(listener)) + ) + + + + + + +class _UniffiConverterTypeNodeBuilder: + + @staticmethod + def lift(value: int): + return NodeBuilder._make_instance_(value) + + @staticmethod + def check_lower(value: NodeBuilder): + if not isinstance(value, NodeBuilder): + raise TypeError("Expected NodeBuilder instance, {} found".format(type(value).__name__)) + + @staticmethod + def lower(value: NodeBuilderProtocol): + if not isinstance(value, NodeBuilder): + raise TypeError("Expected NodeBuilder instance, {} found".format(type(value).__name__)) + return value._uniffi_clone_pointer() + + @classmethod + def read(cls, buf: _UniffiRustBuffer): + ptr = buf.read_u64() + if ptr == 0: + raise InternalError("Raw pointer value was null") + return cls.lift(ptr) + + @classmethod + def write(cls, value: NodeBuilderProtocol, buf: _UniffiRustBuffer): + buf.write_u64(cls.lower(value)) class NodeEventStreamProtocol(typing.Protocol): """ A stream of node events. Call `next()` to receive the next event. @@ -7688,23 +8782,6 @@ async def _uniffi_rust_call_async(rust_future, ffi_poll, ffi_complete, ffi_free, finally: ffi_free(rust_future) -def connect(mnemonic: "str",credentials: "bytes",config: "Config") -> "Node": - """ - Connect to an existing Greenlight node using previously saved credentials. - """ - - _UniffiConverterString.check_lower(mnemonic) - - _UniffiConverterBytes.check_lower(credentials) - - _UniffiConverterTypeConfig.check_lower(config) - - return _UniffiConverterTypeNode.lift(_uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_func_connect, - _UniffiConverterString.lower(mnemonic), - _UniffiConverterBytes.lower(credentials), - _UniffiConverterTypeConfig.lower(config))) - - def parse_input(input: "str") -> "ParsedInput": """ Synchronously classify the input. **No HTTP, no I/O.** @@ -7726,60 +8803,6 @@ def parse_input(input: "str") -> "ParsedInput": return _UniffiConverterTypeParsedInput.lift(_uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_func_parse_input, _UniffiConverterString.lower(input))) - -def recover(mnemonic: "str",config: "Config") -> "Node": - """ - Recover credentials for an existing Greenlight node and return a connected Node. - - The app should call `node.credentials()` to get the credential bytes - and persist them for future `connect()` calls. - """ - - _UniffiConverterString.check_lower(mnemonic) - - _UniffiConverterTypeConfig.check_lower(config) - - return _UniffiConverterTypeNode.lift(_uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_func_recover, - _UniffiConverterString.lower(mnemonic), - _UniffiConverterTypeConfig.lower(config))) - - -def register(mnemonic: "str",invite_code: "typing.Optional[str]",config: "Config") -> "Node": - """ - Register a new Greenlight node and return a connected Node with signer running. - - The app should call `node.credentials()` to get the credential bytes - and persist them for future `connect()` calls. - """ - - _UniffiConverterString.check_lower(mnemonic) - - _UniffiConverterOptionalString.check_lower(invite_code) - - _UniffiConverterTypeConfig.check_lower(config) - - return _UniffiConverterTypeNode.lift(_uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_func_register, - _UniffiConverterString.lower(mnemonic), - _UniffiConverterOptionalString.lower(invite_code), - _UniffiConverterTypeConfig.lower(config))) - - -def register_or_recover(mnemonic: "str",invite_code: "typing.Optional[str]",config: "Config") -> "Node": - """ - Try to recover an existing node; if none exists, register a new one. - """ - - _UniffiConverterString.check_lower(mnemonic) - - _UniffiConverterOptionalString.check_lower(invite_code) - - _UniffiConverterTypeConfig.check_lower(config) - - return _UniffiConverterTypeNode.lift(_uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_func_register_or_recover, - _UniffiConverterString.lower(mnemonic), - _UniffiConverterOptionalString.lower(invite_code), - _UniffiConverterTypeConfig.lower(config))) - async def resolve_input(input: "str") -> "ResolvedInput": """ @@ -7853,6 +8876,7 @@ def set_logger(level: "LogLevel",listener: "LogListener") -> None: "LogLevel", "Network", "NodeEvent", + "OnchainBalanceState", "OutputStatus", "ParsedInput", "PayStatus", @@ -7882,20 +8906,19 @@ def set_logger(level: "LogLevel",listener: "LogListener") -> None: "LnUrlWithdrawSuccessData", "LogEntry", "NodeState", + "OnchainFeeRates", "OnchainReceiveResponse", "OnchainSendResponse", + "Outpoint", "ParsedInvoice", "Pay", "Payment", "Peer", "PeerChannel", + "PreparedOnchainSend", "ReceiveResponse", "SendResponse", - "connect", "parse_input", - "recover", - "register", - "register_or_recover", "resolve_input", "set_log_level", "set_logger", @@ -7904,9 +8927,11 @@ def set_logger(level: "LogLevel",listener: "LogListener") -> None: "DeveloperCert", "Handle", "Node", + "NodeBuilder", "NodeEventStream", "Scheduler", "Signer", "LogListener", + "NodeEventListener", ] diff --git a/libs/gl-sdk/src/lib.rs b/libs/gl-sdk/src/lib.rs index c5a97e050..3f8317305 100644 --- a/libs/gl-sdk/src/lib.rs +++ b/libs/gl-sdk/src/lib.rs @@ -39,12 +39,13 @@ pub use crate::{ config::Config, credentials::{Credentials, DeveloperCert}, node::{ - ChannelState, FundChannel, FundOutput, GetInfoResponse, Invoice, InvoicePaidEvent, - InvoiceStatus, ListFundsResponse, ListIndex, ListInvoicesResponse, + ChannelState, FundChannel, FundOutput, GetInfoResponse, Invoice, + InvoicePaidEvent, InvoiceStatus, ListFundsResponse, ListIndex, ListInvoicesResponse, ListPaymentsRequest, ListPeerChannelsResponse, ListPaysResponse, ListPeersResponse, - Node, NodeEvent, NodeEventListener, NodeEventStream, NodeState, OnchainReceiveResponse, - OnchainSendResponse, OutputStatus, Pay, PayStatus, Payment, PaymentStatus, PaymentType, - PaymentTypeFilter, Peer, PeerChannel, ReceiveResponse, SendResponse, + Node, NodeEvent, NodeEventListener, NodeEventStream, NodeState, OnchainBalanceState, + OnchainFeeRates, OnchainReceiveResponse, OnchainSendResponse, Outpoint, OutputStatus, + Pay, PayStatus, Payment, PaymentStatus, PaymentType, PaymentTypeFilter, Peer, + PeerChannel, PreparedOnchainSend, ReceiveResponse, SendResponse, }, input::{ParsedInput, ParsedInvoice, ResolvedInput}, logging::{LogEntry, LogLevel, LogListener}, diff --git a/libs/gl-sdk/src/node.rs b/libs/gl-sdk/src/node.rs index bdd8637ba..22f6932fc 100644 --- a/libs/gl-sdk/src/node.rs +++ b/libs/gl-sdk/src/node.rs @@ -1,4 +1,5 @@ use crate::{credentials::Credentials, signer::Handle, util::exec, Error}; +use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use gl_client::credentials::NodeIdProvider; use gl_client::lnurl::models::LnUrlHttpClient as _; @@ -178,6 +179,14 @@ impl Node { /// - `"50000"` or `"50000sat"` — 50,000 satoshis /// - `"50000msat"` — 50,000 millisatoshis /// - `"all"` — sweep the entire on-chain balance + /// * `sat_per_vbyte` — Optional fee rate in sats per virtual byte. + /// Pass `None` to let the node pick. Pass the value from a prior + /// `prepare_onchain_send` to reproduce the previewed fee. + /// * `utxos` — Optional pinned input set. Pass the `utxos` returned + /// by `prepare_onchain_send` (together with the same + /// `sat_per_vbyte`) to broadcast a transaction with the exact + /// inputs and fee shown in the preview. Pass `None` to let the + /// node coin-select. /// /// Returns the raw transaction, txid, and PSBT once broadcast. /// The transaction is broadcast immediately — this is not a dry run. @@ -185,45 +194,24 @@ impl Node { &self, destination: String, amount_or_all: String, + sat_per_vbyte: Option, + utxos: Option>, ) -> Result { self.check_connected()?; let mut cln_client = exec(self.get_cln_client())?.clone(); - // Decode what the user intends to do. Either we have `all`, - // or we have an amount that we can parse. - let (num, suffix): (String, String) = amount_or_all.chars().partition(|c| c.is_digit(10)); - - let num = if num.len() > 0 { - num.parse::().unwrap() - } else { - 0 - }; - let satoshi = match (num, suffix.as_ref()) { - (n, "") | (n, "sat") => clnpb::AmountOrAll { - // No value suffix, interpret as satoshis. This is an - // onchain RPC method, hence the sat denomination by - // default. - value: Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount { - msat: n * 1000, - })), - }, - (n, "msat") => clnpb::AmountOrAll { - value: Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount { - msat: n, - })), - }, - (0, "all") => clnpb::AmountOrAll { - value: Some(clnpb::amount_or_all::Value::All(true)), - }, - (_, _) => return Err(Error::Argument("amount_or_all".to_owned(), amount_or_all)), - }; + let satoshi = parse_amount_or_all(&amount_or_all)?; let req = clnpb::WithdrawRequest { - destination: destination, + destination, minconf: None, - feerate: None, + feerate: sat_per_vbyte.map(feerate_perkw_from_sat_per_vbyte), satoshi: Some(satoshi), - utxos: vec![], + utxos: utxos + .unwrap_or_default() + .into_iter() + .map(outpoint_to_pb) + .collect::, _>>()?, }; exec(cln_client.withdraw(req)) @@ -231,6 +219,363 @@ impl Node { .map(|r| r.into_inner().into()) } + /// Preview an on-chain send without broadcasting or reserving UTXOs. + /// + /// Runs CLN's coin selection at the given fee rate and returns the + /// inputs that would be spent, the fee, and the amount the recipient + /// would receive. Safe to call repeatedly (e.g. while the user + /// adjusts a fee slider) — nothing is locked. + /// + /// To broadcast with the previewed values, pass the returned + /// `utxos` and `sat_per_vbyte` back to `onchain_send`. Identical + /// inputs at the same fee rate yield the same fee. + /// + /// **Use this for "Send Max" UIs.** `recipient_sat` is the only + /// authoritative post-fee amount the destination will receive + /// for a sweep. `NodeState.onchain_balance_msat` includes the + /// emergency reserve and the fee — neither of which leaves the + /// wallet with the recipient. For the entry-point button label + /// (a pre-fee approximation that updates without an RPC), use + /// `OnchainBalanceState::Available.withdrawable_sat`. + /// + /// # Arguments + /// * `destination` — A Bitcoin address (bech32, p2sh, or p2tr). + /// * `amount_or_all` — Amount to send. Accepts: + /// - `"50000"` or `"50000sat"` — 50,000 satoshis + /// - `"50000msat"` — 50,000 millisatoshis + /// - `"all"` — sweep the entire on-chain balance + /// * `sat_per_vbyte` — Fee rate in sats per virtual byte. Pass + /// `None` to use the node's "normal" priority feerate; the + /// effective rate CLN picked is reported back in the result's + /// `sat_per_vbyte` field, which can be passed to `onchain_send` + /// to reproduce it. + pub fn prepare_onchain_send( + &self, + destination: String, + amount_or_all: String, + sat_per_vbyte: Option, + ) -> Result { + self.check_connected()?; + let cln_client = exec(self.get_cln_client())?.clone(); + + let satoshi = parse_amount_or_all(&amount_or_all)?; + let is_sweep = matches!(satoshi.value, Some(clnpb::amount_or_all::Value::All(true))); + + // `startweight` must cover everything CLN does NOT add itself + // during fundpsbt: the base tx overhead and the destination + // output. CLN accumulates per-input spend weights and the + // change output weight on top of this. See + // lightning/plugins/spender/multiwithdraw.c:339 for the + // canonical formula CLN uses for its own withdraw plugin. + let startweight = BASE_TX_CORE_WEIGHT + output_weight_for_address(&destination); + + let feerate = match sat_per_vbyte { + Some(rate) => feerate_perkw_from_sat_per_vbyte(rate), + None => clnpb::Feerate { + style: Some(clnpb::feerate::Style::Normal(true)), + }, + }; + + let req = clnpb::FundpsbtRequest { + satoshi: Some(satoshi), + feerate: Some(feerate), + startweight, + // `reserve = 0` is the whole point: CLN runs coin selection + // and returns the would-be inputs but does not lock them. + reserve: Some(0), + minconf: None, + locktime: None, + min_witness_weight: None, + // For non-sweep sends any leftover after the requested + // amount + fee becomes change. For sweeps there is no + // requested amount so the leftover is the recipient amount + // and CLN reports it via `excess_msat`. + excess_as_change: Some(!is_sweep), + nonwrapped: None, + opening_anchor_channel: None, + }; + + // Run fund_psbt and feerates concurrently. The latter is used + // only to validate the requested rate against the network's + // relay floor — without this check, a too-low `sat_per_vbyte` + // produces a confusing post-broadcast `min relay fee not met` + // failure instead of a clean pre-confirmation error. + let (fund_res, feerates_res) = exec(async { + let mut c_fund = cln_client.clone(); + let mut c_rates = cln_client.clone(); + tokio::join!( + c_fund.fund_psbt(req), + c_rates.feerates(clnpb::FeeratesRequest { + style: clnpb::feerates_request::FeeratesStyle::Perkw as i32, + }), + ) + }); + + // Reject below-relay rates up front when the caller specified + // one. If `feerates` itself failed, skip the check — a stale + // bitcoind connection shouldn't block a prepare. + if let (Some(rate), Ok(rates)) = (sat_per_vbyte, feerates_res.as_ref()) + && let Some(perkw) = rates.get_ref().perkw.as_ref() + { + let min_sat_per_vbyte = + sat_per_vbyte_from_perkw(perkw.min_acceptable).max(1); + if (rate as u64) < min_sat_per_vbyte { + return Err(Error::Argument( + "sat_per_vbyte".to_owned(), + format!( + "{} sat/vbyte is below the network minimum of {} sat/vbyte", + rate, min_sat_per_vbyte + ), + )); + } + } + + let res = fund_res + .map_err(|e| Error::Rpc(e.to_string()))? + .into_inner(); + + // CLN only emits the `reservations` array when `reserve > 0` + // (see lightning/wallet/reservation.c:421 — `if (reserve)`). + // We deliberately pass `reserve=0` to avoid locking UTXOs, so + // we extract the chosen inputs from the returned PSBT instead. + let psbt = bitcoin::Psbt::from_str(&res.psbt) + .map_err(|e| Error::Rpc(format!("invalid psbt from fund_psbt: {}", e)))?; + let utxos: Vec = psbt + .unsigned_tx + .input + .iter() + .map(|tx_in| Outpoint { + txid: tx_in.previous_output.txid.to_string(), + vout: tx_in.previous_output.vout, + }) + .collect(); + + // BIP-141: feerate_per_kw is sats per 1000 weight units, so + // fee_sat = weight_wu × feerate_per_kw / 1000. The proto-level + // `estimated_final_weight` already includes the destination + // output we declared via `startweight`, plus any change output. + let fee_sat: u64 = + (res.estimated_final_weight as u64 * res.feerate_per_kw as u64) / 1000; + + // Sum input values directly from the PSBT. Each PSBT input + // carries its prevout amount in `witness_utxo` (segwit) or + // `non_witness_utxo` (legacy). This is the one source of truth + // and works for sweeps that include an emergency-reserve + // change output (anchor-channel wallets) — see + // lightning/wallet/reservation.c:443 `change_for_emergency`, + // which carves out `emergency_sat` even from `satoshi=All`. + let mut total_input_sat: u64 = 0; + for (i, input) in psbt.inputs.iter().enumerate() { + let value = if let Some(ref txout) = input.witness_utxo { + txout.value + } else if let Some(ref tx) = input.non_witness_utxo { + let vout = psbt.unsigned_tx.input[i].previous_output.vout as usize; + tx.output + .get(vout) + .map(|o| o.value) + .ok_or_else(|| { + Error::Rpc("psbt non_witness_utxo missing vout".to_owned()) + })? + } else { + return Err(Error::Rpc(format!( + "psbt input {} has no witness_utxo or non_witness_utxo", + i + ))); + }; + total_input_sat = total_input_sat.saturating_add(value.to_sat()); + } + + let recipient_sat: u64 = if is_sweep { + // For `satoshi=All` CLN reports the post-fee, post-emergency + // leftover via `excess_msat`; that's what the recipient + // receives. Any difference between `total_input_sat` and + // `recipient_sat + fee_sat` is the emergency-reserve change + // CLN keeps in the wallet for anchor channels. + res.excess_msat.as_ref().map(|a| a.msat).unwrap_or(0) / 1000 + } else { + match parse_amount_or_all(&amount_or_all)?.value { + Some(clnpb::amount_or_all::Value::Amount(a)) => a.msat / 1000, + _ => 0, + } + }; + + // Round up so passing this back to `onchain_send` produces a + // feerate at least as high as the previewed one; that way the + // broadcast fee is never below what the user agreed to. + let effective_sat_per_vbyte: u32 = + (res.feerate_per_kw as u64).div_ceil(250) as u32; + + Ok(PreparedOnchainSend { + utxos, + total_input_sat, + fee_sat, + recipient_sat, + sat_per_vbyte: effective_sat_per_vbyte, + }) + } + + /// Classify the on-chain wallet for the withdraw entry-point UI. + /// + /// Runs three RPCs concurrently: + /// * `list_funds` — current confirmed/unconfirmed/immature on-chain + /// balances. + /// * `list_peer_channels` — pending channel-close payouts that + /// haven't yet hit the wallet. + /// * `fund_psbt(satoshi=All, reserve=0, normal feerate)` — a + /// non-locking probe whose response tells us **exactly** how + /// much CLN will carve as the anchor-channel emergency reserve + /// for this specific node, no client-side guessing required. + /// The carved amount is computed from the response as + /// `total_inputs − excess − fee`, which is identical to what + /// CLN would carve on a real broadcast. + /// + /// Cheaper to call than `node_state()` and answers a different + /// question. Wallets typically call it once per render of the + /// home screen. + /// + /// For the *exact* post-fee recipient amount of a withdraw, use + /// `prepare_onchain_send`; the `withdrawable_sat` returned here + /// is a pre-fee, reserve-aware figure for the entry-point label. + pub fn onchain_balance_state(&self) -> Result { + self.check_connected()?; + let cln_client = exec(self.get_cln_client())?.clone(); + + // Run the three RPCs concurrently. The probe is allowed to + // fail (e.g. empty wallet, insufficient funds for any spend); + // we treat that as "no reserve applicable" and let the rest + // of the classification proceed. + let (funds_res, channels_res, probe_res) = exec(async { + let mut c_funds = cln_client.clone(); + let mut c_channels = cln_client.clone(); + let mut c_probe = cln_client.clone(); + let probe_req = clnpb::FundpsbtRequest { + satoshi: Some(clnpb::AmountOrAll { + value: Some(clnpb::amount_or_all::Value::All(true)), + }), + feerate: Some(clnpb::Feerate { + style: Some(clnpb::feerate::Style::Normal(true)), + }), + // Assume a P2WPKH destination for the probe — we don't + // have a real address here. The output type only + // affects fee estimation by a handful of weight units; + // it does not affect the carved emergency reserve. + startweight: BASE_TX_CORE_WEIGHT + 124, + reserve: Some(0), + minconf: None, + locktime: None, + min_witness_weight: None, + excess_as_change: Some(false), + nonwrapped: None, + opening_anchor_channel: None, + }; + tokio::join!( + c_funds.list_funds(clnpb::ListfundsRequest { spent: None }), + c_channels.list_peer_channels(clnpb::ListpeerchannelsRequest { id: None }), + c_probe.fund_psbt(probe_req), + ) + }); + + let funds: ListFundsResponse = funds_res + .map_err(|e| Error::Rpc(e.to_string()))? + .into_inner() + .into(); + let channels: ListPeerChannelsResponse = channels_res + .map_err(|e| Error::Rpc(e.to_string()))? + .into_inner() + .into(); + + let mut confirmed_sat: u64 = 0; + let mut unconfirmed_sat: u64 = 0; + let mut immature_sat: u64 = 0; + for output in &funds.outputs { + if output.reserved { + continue; + } + let value_sat = output.amount_msat / 1000; + match output.status { + OutputStatus::Confirmed => confirmed_sat += value_sat, + OutputStatus::Unconfirmed => unconfirmed_sat += value_sat, + OutputStatus::Immature => immature_sat += value_sat, + OutputStatus::Spent => {} + } + } + + let mut pending_close_sat: u64 = 0; + for ch in &channels.channels { + if channel_payout_still_pending(ch) { + pending_close_sat += ch.to_us_msat.unwrap_or(0) / 1000; + } + } + + // Derive the actual emergency reserve from the probe. CLN's + // `change_for_emergency` runs server-side in the same + // `fund_psbt` call we just made; the difference between the + // input total and (recipient + fee) is exactly what would be + // carved as change on a real sweep. + let reserve_sat = match probe_res { + Ok(resp) => { + let resp = resp.into_inner(); + // CLN-managed UTXOs are always segwit, so each PSBT + // input carries `witness_utxo` with the prevout + // amount. Sum to get total input value. + let total_input_sat = bitcoin::Psbt::from_str(&resp.psbt) + .ok() + .map(|p| { + p.inputs + .iter() + .filter_map(|i| { + i.witness_utxo.as_ref().map(|t| t.value.to_sat()) + }) + .sum::() + }) + .unwrap_or(0); + let excess_sat = resp + .excess_msat + .as_ref() + .map(|a| a.msat / 1000) + .unwrap_or(0); + let fee_sat = (resp.estimated_final_weight as u64 + * resp.feerate_per_kw as u64) + / 1000; + total_input_sat + .saturating_sub(excess_sat) + .saturating_sub(fee_sat) + } + // `fund_psbt` errors are expected on empty wallets or when + // every UTXO is dust-uneconomic at the chosen feerate; + // treat as "no reserve applicable." + Err(_) => 0, + }; + + Ok(classify_onchain_balance( + confirmed_sat, + reserve_sat, + unconfirmed_sat, + immature_sat, + pending_close_sat, + )) + } + + /// On-chain fee rates, in sats per virtual byte, at several + /// confirmation targets. + /// + /// Sourced from the connected node's view of the network — no + /// 3rd-party HTTP calls. Use as the basis for a fee-picker UI; + /// `minimum_relay_sat_per_vbyte` is the relay floor enforced at + /// broadcast time and should be the lower bound of any slider. + pub fn onchain_fee_rates(&self) -> Result { + self.check_connected()?; + let mut cln_client = exec(self.get_cln_client())?.clone(); + + let req = clnpb::FeeratesRequest { + style: clnpb::feerates_request::FeeratesStyle::Perkw as i32, + }; + let res = exec(cln_client.feerates(req)) + .map_err(|e| Error::Rpc(e.to_string()))? + .into_inner(); + Ok(compute_fee_rates(res.perkw.as_ref())) + } + /// Generate a fresh on-chain Bitcoin address for receiving funds. /// /// Returns both a bech32 (SegWit v0) and a p2tr (Taproot) address. @@ -376,6 +721,7 @@ impl Node { connected_channel_peer_set.insert(ch.peer_id.clone()); } } + let connected_channel_peers: Vec = connected_channel_peer_set.into_iter().collect(); @@ -1013,6 +1359,207 @@ impl Node { } } +/// A specific on-chain output, identified by its outpoint. +#[derive(Clone, uniffi::Record)] +pub struct Outpoint { + /// Transaction id as lowercase hex (64 chars). + pub txid: String, + /// Output index within that transaction. + pub vout: u32, +} + +/// On-chain fee rates in sats per virtual byte at various +/// confirmation targets, derived from the connected node's view of +/// network mempool conditions. Use as the basis for a fee-picker UI. +#[derive(Clone, uniffi::Record)] +pub struct OnchainFeeRates { + /// Target the next block (~10 min). + pub next_block_sat_per_vbyte: u64, + /// ~30 minute confirmation target (3 blocks). + pub half_hour_sat_per_vbyte: u64, + /// ~1 hour confirmation target (6 blocks). + pub hour_sat_per_vbyte: u64, + /// ~1 day confirmation target (144 blocks). Suitable for + /// non-urgent sweeps. + pub day_sat_per_vbyte: u64, + /// Network minimum relay fee. Anything below this will be + /// rejected by mempool policy at broadcast time. Use as the + /// lower bound of any user-facing fee slider. + pub minimum_relay_sat_per_vbyte: u64, +} + +/// Convert sat/kw to sat/vbyte, rounding up so we never undershoot +/// the relay floor when the caller submits the value back. +fn sat_per_vbyte_from_perkw(perkw: u32) -> u64 { + (perkw as u64).div_ceil(250) +} + +/// Pick the smallest-blockcount estimate that is `≥ target_blocks`. +/// If none exists (the longest estimate is shorter than `target_blocks`), +/// fall back to the longest estimate available. Returns the rate in +/// sat per kilo-weight (perkw). +fn pick_perkw_for_target( + estimates: &[clnpb::FeeratesPerkwEstimates], + target_blocks: u32, +) -> Option { + let above = estimates + .iter() + .filter(|e| e.blockcount >= target_blocks) + .min_by_key(|e| e.blockcount) + .map(|e| e.feerate); + above.or_else(|| { + estimates + .iter() + .max_by_key(|e| e.blockcount) + .map(|e| e.feerate) + }) +} + +/// Map CLN's perkw feerates into an `OnchainFeeRates` (sat/vbyte) +/// for the standard 5-bucket fee-picker UI. Rounds up at the +/// boundary so values produced here are never below the network's +/// relay floor when the caller submits them. +fn compute_fee_rates(perkw: Option<&clnpb::FeeratesPerkw>) -> OnchainFeeRates { + // Universal fallback when the node hasn't reported feerates yet + // (e.g. just after startup, no connection to bitcoind). + const FALLBACK_SAT_PER_VBYTE: u64 = 1; + + let Some(p) = perkw else { + return OnchainFeeRates { + next_block_sat_per_vbyte: FALLBACK_SAT_PER_VBYTE, + half_hour_sat_per_vbyte: FALLBACK_SAT_PER_VBYTE, + hour_sat_per_vbyte: FALLBACK_SAT_PER_VBYTE, + day_sat_per_vbyte: FALLBACK_SAT_PER_VBYTE, + minimum_relay_sat_per_vbyte: FALLBACK_SAT_PER_VBYTE, + }; + }; + + let minimum_relay_sat_per_vbyte = + sat_per_vbyte_from_perkw(p.min_acceptable).max(FALLBACK_SAT_PER_VBYTE); + + let bucket = |target_blocks: u32| -> u64 { + pick_perkw_for_target(&p.estimates, target_blocks) + .map(sat_per_vbyte_from_perkw) + .unwrap_or(minimum_relay_sat_per_vbyte) + .max(minimum_relay_sat_per_vbyte) + }; + + OnchainFeeRates { + next_block_sat_per_vbyte: bucket(1), + half_hour_sat_per_vbyte: bucket(3), + hour_sat_per_vbyte: bucket(6), + day_sat_per_vbyte: bucket(144), + minimum_relay_sat_per_vbyte, + } +} + +/// Classifies the on-chain wallet into discrete cases that a wallet +/// UI can switch on to render the correct entry-point for the +/// withdraw flow. Derived purely from `NodeState` — no RPC. +#[derive(Clone, uniffi::Enum)] +pub enum OnchainBalanceState { + /// No funds on-chain in any form (confirmed, unconfirmed, + /// immature, or pending channel-close payouts are all zero). + /// Don't render a withdraw entry point. + Unavailable, + + /// Funds are spendable now. Render the withdraw entry point + /// enabled with `withdrawable_sat` as the headline. + Available { + /// `onchain_balance_sat - emergency_reserve_sat`. Use as + /// the displayed amount on the entry point. + withdrawable_sat: u64, + /// Held back by CLN for anchor-channel safety; cannot be + /// withdrawn without closing channels first. + emergency_reserve_sat: u64, + /// Inbound on-chain funds not yet confirmed. Informational + /// only — not part of `withdrawable_sat`. + unconfirmed_sat: u64, + }, + + /// On-chain funds exist but are entirely locked as the + /// anchor-channel emergency reserve. Render the entry point + /// disabled with an explainer (e.g. "close channels to free + /// these funds"). + ReserveOnly { reserve_sat: u64 }, + + /// Inbound on-chain funds are awaiting confirmation. Render a + /// "pending" indicator instead of an enabled withdraw button. + PendingConfirmation { unconfirmed_sat: u64 }, + + /// Funds exist as CSV-timelocked outputs from a recent channel + /// close and can't be spent until the relative locktime + /// expires. Render the entry point disabled with a + /// "channel closing" explainer. + Immature { immature_sat: u64 }, +} + +/// Pure variant classifier. Given the five sat-denominated balance +/// figures, decide which `OnchainBalanceState` variant applies. The +/// public method `Node::onchain_balance_state` gathers the figures +/// from CLN and calls this. +fn classify_onchain_balance( + confirmed_sat: u64, + reserve_sat: u64, + unconfirmed_sat: u64, + immature_sat: u64, + pending_close_sat: u64, +) -> OnchainBalanceState { + let withdrawable_sat = confirmed_sat.saturating_sub(reserve_sat); + + if confirmed_sat == 0 + && unconfirmed_sat == 0 + && immature_sat == 0 + && pending_close_sat == 0 + { + return OnchainBalanceState::Unavailable; + } + if withdrawable_sat > ONCHAIN_DUST_THRESHOLD_SAT { + return OnchainBalanceState::Available { + withdrawable_sat, + emergency_reserve_sat: reserve_sat, + unconfirmed_sat, + }; + } + if confirmed_sat > 0 && reserve_sat > 0 { + return OnchainBalanceState::ReserveOnly { reserve_sat }; + } + if unconfirmed_sat > 0 { + return OnchainBalanceState::PendingConfirmation { unconfirmed_sat }; + } + OnchainBalanceState::Immature { immature_sat } +} + +/// Preview of an on-chain send: the inputs CLN would select at the +/// given fee rate, the resulting fee, and the amount the recipient +/// would receive. Inputs are NOT reserved — the wallet is free to +/// spend them via other paths until `onchain_send` actually broadcasts. +/// +/// Pass `utxos` and `sat_per_vbyte` back to `onchain_send` to broadcast +/// with identical inputs and fee. +/// +/// Amounts are in satoshis: on-chain transactions cannot carry sub-sat +/// precision, so msat denomination would be misleading here. +#[derive(uniffi::Record)] +pub struct PreparedOnchainSend { + /// UTXOs that would be spent, in selection order. + pub utxos: Vec, + /// Sum of all input UTXO values, in satoshis. + pub total_input_sat: u64, + /// Fee that would be paid, in satoshis. + pub fee_sat: u64, + /// Amount the recipient would receive, in satoshis. + /// For a sweep ("all") this equals `total_input_sat - fee_sat`. + /// For a fixed amount this equals the requested amount. + pub recipient_sat: u64, + /// Effective fee rate (sat per virtual byte) the node used to + /// compute this preview. Equal to the caller's `sat_per_vbyte` if + /// one was supplied; otherwise the rate the node picked at + /// "normal" priority. Pass this back to `onchain_send` to + /// reproduce the previewed fee. + pub sat_per_vbyte: u32, +} + /// Result of an on-chain send. The transaction has already been broadcast. #[derive(uniffi::Record)] pub struct OnchainSendResponse { @@ -1024,6 +1571,98 @@ pub struct OnchainSendResponse { pub psbt: String, } +/// Parse an `amount_or_all` argument into the protobuf `AmountOrAll`. +/// Accepts `"all"`, `""`, `"sat"`, or `"msat"`. +fn parse_amount_or_all(amount_or_all: &str) -> Result { + let (num, suffix): (String, String) = + amount_or_all.chars().partition(|c| c.is_ascii_digit()); + + let num = if num.is_empty() { + 0 + } else { + num.parse::() + .map_err(|_| Error::Argument("amount_or_all".to_owned(), amount_or_all.to_owned()))? + }; + + match (num, suffix.as_str()) { + (n, "") | (n, "sat") => Ok(clnpb::AmountOrAll { + value: Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount { + msat: n * 1000, + })), + }), + (n, "msat") => Ok(clnpb::AmountOrAll { + value: Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount { msat: n })), + }), + (0, "all") => Ok(clnpb::AmountOrAll { + value: Some(clnpb::amount_or_all::Value::All(true)), + }), + _ => Err(Error::Argument( + "amount_or_all".to_owned(), + amount_or_all.to_owned(), + )), + } +} + +/// Build a CLN `Feerate` from a sat/vbyte value. CLN measures rates +/// in sat per 1000 weight units, and 1 vbyte = 4 weight units, so +/// `sat/kw = sat/vbyte × 250`. +fn feerate_perkw_from_sat_per_vbyte(sat_per_vbyte: u32) -> clnpb::Feerate { + clnpb::Feerate { + style: Some(clnpb::feerate::Style::Perkw(sat_per_vbyte * 250)), + } +} + +/// Convert a public `Outpoint` (hex txid) into the protobuf form +/// (raw txid bytes) used by CLN's `WithdrawRequest`. +fn outpoint_to_pb(o: Outpoint) -> Result { + let txid = hex::decode(&o.txid) + .map_err(|_| Error::Argument("utxos.txid".to_owned(), o.txid.clone()))?; + Ok(clnpb::Outpoint { + txid, + outnum: o.vout, + }) +} + +/// Base transaction overhead in BIP-141 weight units, for a typical +/// segwit transaction with 1–252 inputs and 1–252 outputs: +/// `(version=4 + input_count_varint=1 + output_count_varint=1 + +/// locktime=4) × 4 + segwit_marker_flag=2 = 42 wu`. This is the +/// `bitcoin_tx_core_weight(1, 1)` value from CLN +/// (`lightning/bitcoin/tx.c:849`). At 253+ inputs/outputs the varints +/// grow to 3 bytes (8 wu more), which is rare enough to ignore. +const BASE_TX_CORE_WEIGHT: u32 = 42; + +/// Conservative dust gate for the on-chain entry-point: highest dust +/// threshold across common output types at Bitcoin Core's default +/// `DUST_RELAY_TX_FEE = 3000` (sat/kvB). P2PKH is 546 sat, P2WPKH +/// 294 sat, P2TR/P2WSH 330 sat. Using 546 means `Available` implies +/// the user can plausibly send to any common address type. +/// Source: Bitcoin Core `policy/policy.cpp::GetDustThreshold` and +/// `bitcoin::Script::minimal_non_dust` at the default relay fee. +const ONCHAIN_DUST_THRESHOLD_SAT: u64 = 546; + +/// Serialized weight (BIP-141 weight units) of a single output paying +/// to the given address. Used (with `BASE_TX_CORE_WEIGHT`) as +/// `startweight` for `FundPsbt`, which only accounts for inputs and +/// change on top of what the caller declares. +/// +/// Output bytes = 8 (value) + varint(script_len) + script_pubkey. +/// All standard scripts are < 253 bytes so the varint is 1 byte. The +/// total is then × 4 since outputs are non-witness data. +/// +/// Falls back to 172 wu for unparseable inputs so the fee is over- +/// rather than under-estimated. +fn output_weight_for_address(addr: &str) -> u32 { + match bitcoin::Address::from_str(addr) { + Ok(a) => { + let spk_len = a.assume_checked().script_pubkey().len(); + let varint_len = if spk_len < 0xfd { 1 } else { 3 }; + ((8 + varint_len + spk_len) * 4) as u32 + } + Err(_) => 172, + } +} + impl From for OnchainSendResponse { fn from(other: clnpb::WithdrawResponse) -> Self { Self { @@ -1977,3 +2616,287 @@ fn node_event_from_pb(other: glpb::NodeEvent) -> Option { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_amount_or_all_handles_all_variants() { + let all = parse_amount_or_all("all").unwrap(); + assert!(matches!(all.value, Some(clnpb::amount_or_all::Value::All(true)))); + + let plain = parse_amount_or_all("50000").unwrap(); + assert!(matches!( + plain.value, + Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount { msat: 50_000_000 })) + )); + + let sat = parse_amount_or_all("50000sat").unwrap(); + assert!(matches!( + sat.value, + Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount { msat: 50_000_000 })) + )); + + let msat = parse_amount_or_all("50000msat").unwrap(); + assert!(matches!( + msat.value, + Some(clnpb::amount_or_all::Value::Amount(clnpb::Amount { msat: 50_000 })) + )); + + assert!(parse_amount_or_all("notanumber").is_err()); + assert!(parse_amount_or_all("50000btc").is_err()); + } + + #[test] + fn classify_onchain_balance_unavailable_when_empty() { + assert!(matches!( + classify_onchain_balance(0, 0, 0, 0, 0), + OnchainBalanceState::Unavailable + )); + } + + #[test] + fn classify_onchain_balance_available_with_room_above_dust() { + // confirmed=100k, reserve=25k, unconfirmed=5k → Available + match classify_onchain_balance(100_000, 25_000, 5_000, 0, 0) { + OnchainBalanceState::Available { + withdrawable_sat, + emergency_reserve_sat, + unconfirmed_sat, + } => { + assert_eq!(withdrawable_sat, 75_000); + assert_eq!(emergency_reserve_sat, 25_000); + assert_eq!(unconfirmed_sat, 5_000); + } + other => panic!("expected Available, got {:?}", std::mem::discriminant(&other)), + } + } + + #[test] + fn classify_onchain_balance_reserve_only_when_balance_equals_reserve() { + // 25k confirmed, 25k reserve → withdrawable = 0 + match classify_onchain_balance(25_000, 25_000, 0, 0, 0) { + OnchainBalanceState::ReserveOnly { reserve_sat } => { + assert_eq!(reserve_sat, 25_000); + } + other => panic!( + "expected ReserveOnly, got {:?}", + std::mem::discriminant(&other) + ), + } + } + + #[test] + fn classify_onchain_balance_pending_when_only_unconfirmed() { + match classify_onchain_balance(0, 0, 50_000, 0, 0) { + OnchainBalanceState::PendingConfirmation { unconfirmed_sat } => { + assert_eq!(unconfirmed_sat, 50_000); + } + other => panic!( + "expected PendingConfirmation, got {:?}", + std::mem::discriminant(&other) + ), + } + } + + #[test] + fn classify_onchain_balance_immature_when_only_immature() { + match classify_onchain_balance(0, 0, 0, 100_000, 0) { + OnchainBalanceState::Immature { immature_sat } => { + assert_eq!(immature_sat, 100_000); + } + other => panic!("expected Immature, got {:?}", std::mem::discriminant(&other)), + } + } + + #[test] + fn classify_onchain_balance_real_wallet_small_onchain_with_active_channels() { + // Captured from a live mainnet wallet: 2 active channels, + // ~1,228 sat confirmed on-chain, 25,000 sat reserve carved + // (anchor-channel default). Withdrawable = 0 → ReserveOnly. + match classify_onchain_balance(1_228, 25_000, 0, 0, 0) { + OnchainBalanceState::ReserveOnly { reserve_sat } => { + assert_eq!(reserve_sat, 25_000); + } + other => panic!( + "expected ReserveOnly, got {:?}", + std::mem::discriminant(&other) + ), + } + } + + #[test] + fn classify_onchain_balance_real_wallet_onchain_just_above_reserve() { + // Same wallet after a top-up: 28,228 sat confirmed, 25k + // reserve → withdrawable = 3,228, well above the dust gate + // → Available. + match classify_onchain_balance(28_228, 25_000, 0, 0, 0) { + OnchainBalanceState::Available { + withdrawable_sat, + emergency_reserve_sat, + unconfirmed_sat, + } => { + assert_eq!(withdrawable_sat, 3_228); + assert_eq!(emergency_reserve_sat, 25_000); + assert_eq!(unconfirmed_sat, 0); + } + other => panic!( + "expected Available, got {:?}", + std::mem::discriminant(&other) + ), + } + } + + #[test] + fn classify_onchain_balance_dust_only_above_reserve_is_not_available() { + // Withdrawable would be 100 sat — below the 546 dust gate. + // Falls through Available; lands on ReserveOnly. + match classify_onchain_balance(25_100, 25_000, 0, 0, 0) { + OnchainBalanceState::ReserveOnly { reserve_sat } => { + assert_eq!(reserve_sat, 25_000); + } + other => panic!( + "expected ReserveOnly, got {:?}", + std::mem::discriminant(&other) + ), + } + } + + #[test] + fn classify_onchain_balance_real_user_no_anchor_no_reserve() { + // The user-reported case: 28,228 sat on-chain, channels open + // but NOT anchor type, so probe returns 0 reserve. The + // entry-point should show Available with the full balance. + match classify_onchain_balance(28_228, 0, 0, 0, 0) { + OnchainBalanceState::Available { + withdrawable_sat, + emergency_reserve_sat, + unconfirmed_sat, + } => { + assert_eq!(withdrawable_sat, 28_228); + assert_eq!(emergency_reserve_sat, 0); + assert_eq!(unconfirmed_sat, 0); + } + other => panic!( + "expected Available, got {:?}", + std::mem::discriminant(&other) + ), + } + } + + fn perkw_with(estimates: Vec<(u32, u32)>, min_acceptable: u32) -> clnpb::FeeratesPerkw { + clnpb::FeeratesPerkw { + min_acceptable, + max_acceptable: 0, + opening: None, + mutual_close: None, + unilateral_close: None, + unilateral_anchor_close: None, + delayed_to_us: None, + htlc_resolution: None, + penalty: None, + estimates: estimates + .into_iter() + .map(|(blockcount, feerate)| clnpb::FeeratesPerkwEstimates { + blockcount, + feerate, + smoothed_feerate: feerate, + }) + .collect(), + floor: None, + } + } + + #[test] + fn fee_rates_maps_perkw_to_buckets() { + // Typical CLN response with estimates at 2/6/12/144 blocks. + // perkw values: 1 sat/vbyte = 250, 5 sat/vbyte = 1250. + let perkw = perkw_with( + vec![(2, 5000), (6, 2000), (12, 1500), (144, 500)], + 253, // min_acceptable just over 1 sat/vbyte + ); + let r = compute_fee_rates(Some(&perkw)); + // 5000 perkw = 20 sat/vbyte (next-block target picks blockcount=2) + assert_eq!(r.next_block_sat_per_vbyte, 20); + // half_hour target is 3 blocks; smallest estimate ≥3 is 6 → 2000 perkw = 8 sat/vbyte + assert_eq!(r.half_hour_sat_per_vbyte, 8); + // hour target is 6 blocks → 2000 perkw = 8 sat/vbyte + assert_eq!(r.hour_sat_per_vbyte, 8); + // day target is 144 blocks → 500 perkw = 2 sat/vbyte + assert_eq!(r.day_sat_per_vbyte, 2); + // min_acceptable 253 perkw rounds up to 2 sat/vbyte + assert_eq!(r.minimum_relay_sat_per_vbyte, 2); + } + + #[test] + fn fee_rates_fall_back_to_minimum_when_no_estimates() { + let perkw = perkw_with(vec![], 750); // min ~3 sat/vbyte + let r = compute_fee_rates(Some(&perkw)); + assert_eq!(r.minimum_relay_sat_per_vbyte, 3); + // Empty estimates → all buckets fall back to minimum + assert_eq!(r.next_block_sat_per_vbyte, 3); + assert_eq!(r.half_hour_sat_per_vbyte, 3); + assert_eq!(r.hour_sat_per_vbyte, 3); + assert_eq!(r.day_sat_per_vbyte, 3); + } + + #[test] + fn fee_rates_no_perkw_at_all_returns_safe_floor() { + let r = compute_fee_rates(None); + assert_eq!(r.minimum_relay_sat_per_vbyte, 1); + assert_eq!(r.next_block_sat_per_vbyte, 1); + assert_eq!(r.day_sat_per_vbyte, 1); + } + + #[test] + fn fee_rates_buckets_never_below_minimum() { + // 144-block estimate below min_acceptable — bucket must be + // clamped to minimum so we never recommend below network relay. + let perkw = perkw_with( + vec![(2, 1500), (144, 250)], // 144→1 sat/vbyte, but min=1000 perkw = 4 sat/vbyte + 1000, + ); + let r = compute_fee_rates(Some(&perkw)); + assert_eq!(r.minimum_relay_sat_per_vbyte, 4); + // Even though the 144-block estimate is 1 sat/vbyte, we clamp up. + assert_eq!(r.day_sat_per_vbyte, 4); + } + + #[test] + fn fee_rates_target_above_all_estimates_uses_largest() { + // Only short-target estimates; day(144) should fall back to + // the longest estimate available. + let perkw = perkw_with(vec![(2, 5000), (6, 2500)], 250); + let r = compute_fee_rates(Some(&perkw)); + // Longest available estimate is 6 blocks → 2500 perkw = 10 sat/vbyte + assert_eq!(r.day_sat_per_vbyte, 10); + } + + #[test] + fn output_weight_for_address_per_script_type() { + // P2WPKH — script_pubkey is 22 bytes, output = (8+1+22)*4 = 124 + // BIP-173 test vector. + assert_eq!( + output_weight_for_address("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"), + 124 + ); + + // P2TR — script_pubkey is 34 bytes, output = (8+1+34)*4 = 172 + // BIP-341 test vector. + assert_eq!( + output_weight_for_address( + "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0" + ), + 172 + ); + + // P2SH — script_pubkey is 23 bytes, output = (8+1+23)*4 = 128 + assert_eq!(output_weight_for_address("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"), 128); + + // P2PKH — script_pubkey is 25 bytes, output = (8+1+25)*4 = 136 + assert_eq!(output_weight_for_address("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"), 136); + + // Garbage falls back to the conservative 172 wu. + assert_eq!(output_weight_for_address("not-an-address"), 172); + } +}