diff --git a/Cargo.lock b/Cargo.lock index 061ac6b6..84482783 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,61 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "askama" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.108", +] + +[[package]] +name = "askama_parser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow", +] + +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -140,6 +195,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bdk_chain" version = "0.23.2" @@ -360,6 +424,38 @@ dependencies = [ "serde", ] +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.17", +] + [[package]] name = "cc" version = "1.2.43" @@ -592,6 +688,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "futures" version = "0.3.31" @@ -708,6 +813,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "h2" version = "0.3.27" @@ -1266,7 +1388,7 @@ dependencies = [ "serde", "tokio", "tokio-rustls 0.26.4", - "toml", + "toml 0.8.23", ] [[package]] @@ -1280,7 +1402,7 @@ dependencies = [ "serde", "serde_json", "tokio", - "toml", + "toml 0.8.23", ] [[package]] @@ -1295,7 +1417,9 @@ dependencies = [ "reqwest 0.11.27", "rustls 0.21.12", "rustls-pemfile", + "thiserror 1.0.69", "tokio", + "uniffi", ] [[package]] @@ -1518,6 +1642,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniscript" version = "12.3.5" @@ -1546,6 +1676,16 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1621,6 +1761,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "possiblyrandom" version = "0.2.0" @@ -1734,7 +1880,7 @@ dependencies = [ "rustc-hash", "rustls 0.23.34", "socket2 0.6.1", - "thiserror", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -1755,7 +1901,7 @@ dependencies = [ "rustls 0.23.34", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -2096,6 +2242,26 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "sct" version = "0.7.1" @@ -2127,6 +2293,16 @@ dependencies = [ "cc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde" version = "1.0.228" @@ -2206,6 +2382,12 @@ dependencies = [ "libc", ] +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "slab" version = "0.4.11" @@ -2218,6 +2400,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.5.10" @@ -2244,6 +2432,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -2338,13 +2532,42 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", ] [[package]] @@ -2464,6 +2687,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "0.8.23" @@ -2643,6 +2875,128 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "uniffi" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3291800a6b06569f7d3e15bdb6dc235e0f0c8bd3eb07177f430057feb076415f" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a04b99fa7796eaaa7b87976a0dbdd1178dc1ee702ea00aca2642003aef9b669e" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck 0.5.0", + "indexmap 2.12.0", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml 0.5.11", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98" +dependencies = [ + "anyhow", + "async-compat", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09acd2ce09c777dd65ee97c251d33c8a972afc04873f1e3b21eb3492ade16933" +dependencies = [ + "anyhow", + "indexmap 2.12.0", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "uniffi_macros" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5596f178c4f7aafa1a501c4e0b96236a96bc2ef92bdb453d83e609dad0040152" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.108", + "toml 0.5.11", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beadc1f460eb2e209263c49c4f5b19e9a02e00a3b2b393f78ad10d766346ecff" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd76b3ac8a2d964ca9fce7df21c755afb4c77b054a85ad7a029ad179cc5abb8a" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.12.0", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.29.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4319cf905911d70d5b97ce0f46f101619a22e9a189c8c46d797a9955e9233716" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2817,6 +3171,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + [[package]] name = "which" version = "4.4.2" diff --git a/ldk-server-client/Cargo.toml b/ldk-server-client/Cargo.toml index 9768ac52..375711fe 100644 --- a/ldk-server-client/Cargo.toml +++ b/ldk-server-client/Cargo.toml @@ -3,9 +3,14 @@ name = "ldk-server-client" version = "0.1.0" edition = "2021" +[lib] +crate-type = ["lib", "staticlib", "cdylib"] + [features] default = [] serde = ["ldk-server-grpc/serde"] +uniffi = ["dep:uniffi", "dep:tokio", "dep:thiserror"] +uniffi-cli = ["uniffi", "uniffi/cli"] [dependencies] ldk-server-grpc = { path = "../ldk-server-grpc" } @@ -16,6 +21,14 @@ hyper = { version = "0.14", default-features = false, features = ["client", "htt hyper-rustls = { version = "0.24", default-features = false, features = ["http2", "tls12", "tokio-runtime"] } rustls = "0.21" rustls-pemfile = "1" +uniffi = { version = "0.29.5", features = ["tokio"], optional = true } +tokio = { version = "1", default-features = false, features = ["rt-multi-thread"], optional = true } +thiserror = { version = "1", optional = true } [dev-dependencies] tokio = { version = "1", default-features = false, features = ["macros", "rt"] } + +[[bin]] +name = "uniffi-bindgen" +path = "uniffi-bindgen.rs" +required-features = ["uniffi-cli"] diff --git a/ldk-server-client/src/lib.rs b/ldk-server-client/src/lib.rs index 4959f127..2947692c 100644 --- a/ldk-server-client/src/lib.rs +++ b/ldk-server-client/src/lib.rs @@ -20,3 +20,10 @@ pub mod error; /// Request/Response structs required for interacting with the client. pub use ldk_server_grpc; + +#[cfg(feature = "uniffi")] +#[allow(missing_docs)] +pub mod uniffi_types; + +#[cfg(feature = "uniffi")] +uniffi::setup_scaffolding!(); diff --git a/ldk-server-client/src/uniffi_types.rs b/ldk-server-client/src/uniffi_types.rs new file mode 100644 index 00000000..c7d36f74 --- /dev/null +++ b/ldk-server-client/src/uniffi_types.rs @@ -0,0 +1,1100 @@ +// UniFFI-exposed types and client wrapper for `ldk-server-client`. +// +// The types in this module are hand-written flat analogues of the +// prost-generated protobuf types from `ldk-server-grpc`. They exist because +// prost types are not directly UniFFI-exportable: they use +// `#[derive(::prost::Message)]`, nested `oneof` modules, and `prost::bytes::Bytes`, +// none of which UniFFI can serialize across the FFI boundary. +// +// Conversions (`From`/`Into`) to and from the underlying prost types are +// implemented alongside each wrapper. + +use std::sync::Arc; + +use ldk_server_grpc::api::{ + Bolt11ReceiveRequest, Bolt11ReceiveResponse, Bolt11SendRequest, Bolt12ReceiveRequest, + Bolt12ReceiveResponse, Bolt12SendRequest, CloseChannelRequest, ConnectPeerRequest, + DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, + DisconnectPeerRequest, ForceCloseChannelRequest, GetBalancesRequest, GetBalancesResponse, + GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, ListChannelsRequest, + ListPaymentsRequest, ListPeersRequest, OnchainReceiveRequest, OnchainSendRequest, + OpenChannelRequest, UnifiedSendRequest, UnifiedSendResponse, +}; +use ldk_server_grpc::types::{ + bolt11_invoice_description, offer_amount, payment_kind, BestBlock, Bolt11InvoiceDescription, + Channel, OfferAmount, OutPoint, PageToken, Payment, PaymentDirection as ProstPaymentDirection, + PaymentStatus as ProstPaymentStatus, Peer, +}; + +use crate::client::LdkServerClient; +use crate::error::{LdkServerError, LdkServerErrorCode}; + +// --------------------------------------------------------------------------- +// Error +// --------------------------------------------------------------------------- + +/// Errors surfaced across the UniFFI boundary by `LdkServerClientUni`. +/// +/// Flat variants that map the server-side gRPC error codes plus a catch-all +/// for client-side / unknown errors. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum LdkServerClientError { + #[error("Invalid request: {reason}")] + InvalidRequest { reason: String }, + #[error("Authentication failed: {reason}")] + AuthenticationFailed { reason: String }, + #[error("Lightning error: {reason}")] + LightningError { reason: String }, + #[error("Internal server error: {reason}")] + InternalServerError { reason: String }, + #[error("Internal error: {reason}")] + InternalError { reason: String }, +} + +impl From for LdkServerClientError { + fn from(err: LdkServerError) -> Self { + // NOTE: the field is deliberately named `reason` rather than `message`: UniFFI's + // Kotlin generator emits struct-variant errors as subclasses of `Throwable`, and a + // constructor property called `message` collides with Throwable's own `message` + // property, producing a compile error on the generated code. `reason` sidesteps that. + let reason = err.message; + match err.error_code { + LdkServerErrorCode::InvalidRequestError => Self::InvalidRequest { reason }, + LdkServerErrorCode::AuthError => Self::AuthenticationFailed { reason }, + LdkServerErrorCode::LightningError => Self::LightningError { reason }, + LdkServerErrorCode::InternalServerError => Self::InternalServerError { reason }, + LdkServerErrorCode::InternalError => Self::InternalError { reason }, + } + } +} + +// --------------------------------------------------------------------------- +// Node info / best block +// --------------------------------------------------------------------------- + +/// High-level identity and sync state of the remote LDK Server node. +#[derive(Clone, Debug, uniffi::Record)] +pub struct NodeInfo { + /// Hex-encoded public key of the node. + pub node_id: String, + /// Best block the lightning wallet is currently synced to. + pub current_best_block: Option, + pub latest_lightning_wallet_sync_timestamp: Option, + pub latest_onchain_wallet_sync_timestamp: Option, + pub latest_fee_rate_cache_update_timestamp: Option, + pub latest_rgs_snapshot_timestamp: Option, + pub latest_node_announcement_broadcast_timestamp: Option, + pub listening_addresses: Vec, + pub announcement_addresses: Vec, + pub node_alias: Option, + /// `node_id@address` strings that can be shared with peers. + pub node_uris: Vec, +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct BestBlockInfo { + pub block_hash: String, + pub height: u32, +} + +impl From for BestBlockInfo { + fn from(b: BestBlock) -> Self { + Self { block_hash: b.block_hash, height: b.height } + } +} + +impl From for NodeInfo { + fn from(r: GetNodeInfoResponse) -> Self { + Self { + node_id: r.node_id, + current_best_block: r.current_best_block.map(BestBlockInfo::from), + latest_lightning_wallet_sync_timestamp: r.latest_lightning_wallet_sync_timestamp, + latest_onchain_wallet_sync_timestamp: r.latest_onchain_wallet_sync_timestamp, + latest_fee_rate_cache_update_timestamp: r.latest_fee_rate_cache_update_timestamp, + latest_rgs_snapshot_timestamp: r.latest_rgs_snapshot_timestamp, + latest_node_announcement_broadcast_timestamp: r + .latest_node_announcement_broadcast_timestamp, + listening_addresses: r.listening_addresses, + announcement_addresses: r.announcement_addresses, + node_alias: r.node_alias, + node_uris: r.node_uris, + } + } +} + +// --------------------------------------------------------------------------- +// Balances +// --------------------------------------------------------------------------- + +/// Summary of on-chain and lightning balances. +/// +/// The detailed per-HTLC `lightning_balances` and `pending_balances_from_channel_closures` +/// breakdowns are intentionally omitted here: those variants involve deeply nested `oneof` +/// payloads that require substantially more wrapper scaffolding. The summary `*_sats` fields +/// are what a wallet UI displays; callers who need the breakdown can fall back to the raw +/// Rust client. +#[derive(Clone, Debug, uniffi::Record)] +pub struct BalanceInfo { + pub total_onchain_balance_sats: u64, + pub spendable_onchain_balance_sats: u64, + pub total_anchor_channels_reserve_sats: u64, + pub total_lightning_balance_sats: u64, +} + +impl From for BalanceInfo { + fn from(r: GetBalancesResponse) -> Self { + Self { + total_onchain_balance_sats: r.total_onchain_balance_sats, + spendable_onchain_balance_sats: r.spendable_onchain_balance_sats, + total_anchor_channels_reserve_sats: r.total_anchor_channels_reserve_sats, + total_lightning_balance_sats: r.total_lightning_balance_sats, + } + } +} + +// --------------------------------------------------------------------------- +// Payments +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum PaymentDirection { + Inbound, + Outbound, +} + +impl From for PaymentDirection { + fn from(d: ProstPaymentDirection) -> Self { + match d { + ProstPaymentDirection::Inbound => Self::Inbound, + ProstPaymentDirection::Outbound => Self::Outbound, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum PaymentStatus { + Pending, + Succeeded, + Failed, +} + +impl From for PaymentStatus { + fn from(s: ProstPaymentStatus) -> Self { + match s { + ProstPaymentStatus::Pending => Self::Pending, + ProstPaymentStatus::Succeeded => Self::Succeeded, + ProstPaymentStatus::Failed => Self::Failed, + } + } +} + +/// Flat representation of `PaymentKind`'s `oneof` variants. +/// +/// Secret byte fields (`Bolt11::secret`, etc.) and the nested `ConfirmationStatus` on the +/// on-chain variant are dropped — a wallet UI only needs the outer identifiers (txid, hash, +/// preimage, offer_id). +#[derive(Clone, Debug, uniffi::Enum)] +pub enum PaymentKindInfo { + Onchain { txid: String }, + Bolt11 { hash: String, preimage: Option }, + Bolt11Jit { hash: String, preimage: Option }, + Bolt12Offer { hash: Option, preimage: Option, offer_id: String }, + Bolt12Refund { hash: Option, preimage: Option }, + Spontaneous { hash: String, preimage: Option }, +} + +impl From for PaymentKindInfo { + fn from(k: payment_kind::Kind) -> Self { + match k { + payment_kind::Kind::Onchain(o) => Self::Onchain { txid: o.txid }, + payment_kind::Kind::Bolt11(b) => Self::Bolt11 { hash: b.hash, preimage: b.preimage }, + payment_kind::Kind::Bolt11Jit(b) => { + Self::Bolt11Jit { hash: b.hash, preimage: b.preimage } + }, + payment_kind::Kind::Bolt12Offer(b) => { + Self::Bolt12Offer { hash: b.hash, preimage: b.preimage, offer_id: b.offer_id } + }, + payment_kind::Kind::Bolt12Refund(b) => { + Self::Bolt12Refund { hash: b.hash, preimage: b.preimage } + }, + payment_kind::Kind::Spontaneous(s) => { + Self::Spontaneous { hash: s.hash, preimage: s.preimage } + }, + } + } +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct PaymentInfo { + pub id: String, + pub kind: Option, + pub amount_msat: Option, + pub fee_paid_msat: Option, + pub direction: PaymentDirection, + pub status: PaymentStatus, + pub latest_update_timestamp: u64, +} + +impl From for PaymentInfo { + fn from(p: Payment) -> Self { + // prost 0.11 generates `from_i32(i32) -> Option` for message enums, not + // TryFrom. Unknown values (e.g., protocol version skew) fall back to safe defaults. + let direction = ProstPaymentDirection::from_i32(p.direction) + .unwrap_or(ProstPaymentDirection::Outbound) + .into(); + let status = + ProstPaymentStatus::from_i32(p.status).unwrap_or(ProstPaymentStatus::Pending).into(); + let kind = p.kind.and_then(|k| k.kind).map(PaymentKindInfo::from); + Self { + id: p.id, + kind, + amount_msat: p.amount_msat, + fee_paid_msat: p.fee_paid_msat, + direction, + status, + latest_update_timestamp: p.latest_update_timestamp, + } + } +} + +// --------------------------------------------------------------------------- +// Channels +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, uniffi::Record)] +pub struct OutPointInfo { + pub txid: String, + pub vout: u32, +} + +impl From for OutPointInfo { + fn from(o: OutPoint) -> Self { + Self { txid: o.txid, vout: o.vout } + } +} + +/// Subset of `Channel` fields relevant to a wallet UI. The fuller prost `Channel` has 25+ +/// fields covering config, HTLC limits, and counterparty forwarding info — all omitted here. +#[derive(Clone, Debug, uniffi::Record)] +pub struct ChannelInfo { + pub channel_id: String, + pub counterparty_node_id: String, + pub funding_txo: Option, + pub user_channel_id: String, + pub channel_value_sats: u64, + pub outbound_capacity_msat: u64, + pub inbound_capacity_msat: u64, + pub confirmations_required: Option, + pub confirmations: Option, + pub is_outbound: bool, + pub is_channel_ready: bool, + pub is_usable: bool, + pub is_announced: bool, +} + +impl From for ChannelInfo { + fn from(c: Channel) -> Self { + Self { + channel_id: c.channel_id, + counterparty_node_id: c.counterparty_node_id, + funding_txo: c.funding_txo.map(OutPointInfo::from), + user_channel_id: c.user_channel_id, + channel_value_sats: c.channel_value_sats, + outbound_capacity_msat: c.outbound_capacity_msat, + inbound_capacity_msat: c.inbound_capacity_msat, + confirmations_required: c.confirmations_required, + confirmations: c.confirmations, + is_outbound: c.is_outbound, + is_channel_ready: c.is_channel_ready, + is_usable: c.is_usable, + is_announced: c.is_announced, + } + } +} + +// --------------------------------------------------------------------------- +// Peers +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, uniffi::Record)] +pub struct PeerInfo { + pub node_id: String, + pub address: String, + pub is_persisted: bool, + pub is_connected: bool, +} + +impl From for PeerInfo { + fn from(p: Peer) -> Self { + Self { + node_id: p.node_id, + address: p.address, + is_persisted: p.is_persisted, + is_connected: p.is_connected, + } + } +} + +// --------------------------------------------------------------------------- +// Send / receive results +// --------------------------------------------------------------------------- + +/// Result of `unified_send`. Which variant is produced depends on which of the candidate +/// payment types (offer → invoice → on-chain) the server found first in the supplied URI. +#[derive(Clone, Debug, uniffi::Enum)] +pub enum UnifiedSendResult { + Onchain { txid: String }, + Bolt11 { payment_id: String }, + Bolt12 { payment_id: String }, +} + +impl TryFrom for UnifiedSendResult { + type Error = LdkServerClientError; + + fn try_from(r: UnifiedSendResponse) -> Result { + use ldk_server_grpc::api::unified_send_response::PaymentResult; + match r.payment_result { + Some(PaymentResult::Txid(txid)) => Ok(Self::Onchain { txid }), + Some(PaymentResult::Bolt11PaymentId(payment_id)) => Ok(Self::Bolt11 { payment_id }), + Some(PaymentResult::Bolt12PaymentId(payment_id)) => Ok(Self::Bolt12 { payment_id }), + None => Err(LdkServerClientError::InternalError { + reason: "server returned UnifiedSendResponse with no payment_result".to_string(), + }), + } + } +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct Bolt11ReceiveResult { + pub invoice: String, + pub payment_hash: String, + pub payment_secret: String, +} + +impl From for Bolt11ReceiveResult { + fn from(r: Bolt11ReceiveResponse) -> Self { + Self { invoice: r.invoice, payment_hash: r.payment_hash, payment_secret: r.payment_secret } + } +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct Bolt12ReceiveResult { + pub offer: String, + pub offer_id: String, +} + +impl From for Bolt12ReceiveResult { + fn from(r: Bolt12ReceiveResponse) -> Self { + Self { offer: r.offer, offer_id: r.offer_id } + } +} + +// --------------------------------------------------------------------------- +// Decoded invoice / offer +// --------------------------------------------------------------------------- + +/// Parsed BOLT11 invoice. +/// +/// `route_hints` and the `features` bitmap are dropped — they aren't useful to a mobile +/// wallet UI and require more wrapper types. +#[derive(Clone, Debug, uniffi::Record)] +pub struct DecodedInvoice { + pub destination: String, + pub payment_hash: String, + pub amount_msat: Option, + pub timestamp: u64, + pub expiry: u64, + pub description: Option, + pub description_hash: Option, + pub fallback_address: Option, + pub min_final_cltv_expiry_delta: u64, + pub payment_secret: String, + pub currency: String, + pub payment_metadata: Option, + pub is_expired: bool, +} + +impl From for DecodedInvoice { + fn from(r: DecodeInvoiceResponse) -> Self { + Self { + destination: r.destination, + payment_hash: r.payment_hash, + amount_msat: r.amount_msat, + timestamp: r.timestamp, + expiry: r.expiry, + description: r.description, + description_hash: r.description_hash, + fallback_address: r.fallback_address, + min_final_cltv_expiry_delta: r.min_final_cltv_expiry_delta, + payment_secret: r.payment_secret, + currency: r.currency, + payment_metadata: r.payment_metadata, + is_expired: r.is_expired, + } + } +} + +/// Parsed BOLT12 offer. +/// +/// The prost `OfferAmount` is a `oneof` with a `BitcoinAmountMsats(u64)` and a +/// `CurrencyAmount{iso4217_code, amount}` variant. Wallets overwhelmingly only care about +/// the Bitcoin variant, so we flatten to `amount_msat: Option` and drop currency +/// offers. `features`, `paths`, `metadata`, and `quantity` are also dropped for MVP. +#[derive(Clone, Debug, uniffi::Record)] +pub struct DecodedOffer { + pub offer_id: String, + pub description: Option, + pub issuer: Option, + pub amount_msat: Option, + pub issuer_signing_pubkey: Option, + pub absolute_expiry: Option, + pub chains: Vec, + pub is_expired: bool, +} + +fn extract_bitcoin_amount_msats(amount: Option) -> Option { + match amount?.amount? { + offer_amount::Amount::BitcoinAmountMsats(msats) => Some(msats), + offer_amount::Amount::CurrencyAmount(_) => None, + } +} + +impl From for DecodedOffer { + fn from(r: DecodeOfferResponse) -> Self { + Self { + offer_id: r.offer_id, + description: r.description, + issuer: r.issuer, + amount_msat: extract_bitcoin_amount_msats(r.amount), + issuer_signing_pubkey: r.issuer_signing_pubkey, + absolute_expiry: r.absolute_expiry, + chains: r.chains, + is_expired: r.is_expired, + } + } +} + +// --------------------------------------------------------------------------- +// Pagination / list results +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, uniffi::Record)] +pub struct PageTokenInfo { + pub token: String, + pub index: i64, +} + +impl From for PageTokenInfo { + fn from(t: PageToken) -> Self { + Self { token: t.token, index: t.index } + } +} + +impl From for PageToken { + fn from(t: PageTokenInfo) -> Self { + Self { token: t.token, index: t.index } + } +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct ListPaymentsResult { + pub payments: Vec, + pub next_page_token: Option, +} + +// --------------------------------------------------------------------------- +// Client wrapper +// --------------------------------------------------------------------------- + +/// UniFFI-exported wrapper around [`LdkServerClient`]. +/// +/// `LdkServerClient` itself holds `reqwest::Client` + `hyper::Client`, which are not +/// UniFFI-exportable. This thin wrapper adapts the async API to `Arc` / `suspend fun` +/// semantics that UniFFI generates for Kotlin (and Swift). +#[derive(uniffi::Object)] +pub struct LdkServerClientUni { + inner: LdkServerClient, +} + +#[uniffi::export(async_runtime = "tokio")] +impl LdkServerClientUni { + /// Construct a new client. + /// + /// - `base_url` should omit the scheme (e.g. `"localhost:3000"`). + /// - `api_key` is the hex-encoded 32-byte key that the server generates on first run; + /// it's used for HMAC-SHA256 request authentication. + /// - `server_cert_pem` is the text of the server's TLS certificate; found at + /// `/tls.crt` after the server has started. + #[uniffi::constructor] + pub fn new( + base_url: String, api_key: String, server_cert_pem: String, + ) -> Result, LdkServerClientError> { + let inner = LdkServerClient::new(base_url, api_key, server_cert_pem.as_bytes()) + .map_err(|reason| LdkServerClientError::InvalidRequest { reason })?; + Ok(Arc::new(Self { inner })) + } + + /// Retrieve the node's identity, sync status, and announced addresses. + pub async fn get_node_info(&self) -> Result { + let resp = self.inner.get_node_info(GetNodeInfoRequest {}).await?; + Ok(resp.into()) + } + + /// Retrieve the summary on-chain and Lightning balances. + pub async fn get_balances(&self) -> Result { + let resp = self.inner.get_balances(GetBalancesRequest {}).await?; + Ok(resp.into()) + } + + /// List all known channels. + pub async fn list_channels(&self) -> Result, LdkServerClientError> { + let resp = self.inner.list_channels(ListChannelsRequest {}).await?; + Ok(resp.channels.into_iter().map(ChannelInfo::from).collect()) + } + + /// List all known peers (connected or persisted). + pub async fn list_peers(&self) -> Result, LdkServerClientError> { + let resp = self.inner.list_peers(ListPeersRequest {}).await?; + Ok(resp.peers.into_iter().map(PeerInfo::from).collect()) + } + + /// List payments. Pass the `next_page_token` from a prior result to continue pagination. + pub async fn list_payments( + &self, page_token: Option, + ) -> Result { + let request = ListPaymentsRequest { page_token: page_token.map(PageToken::from) }; + let resp = self.inner.list_payments(request).await?; + Ok(ListPaymentsResult { + payments: resp.payments.into_iter().map(PaymentInfo::from).collect(), + next_page_token: resp.next_page_token.map(PageTokenInfo::from), + }) + } + + /// Fetch the details for a single payment by id. Returns `None` if no payment with that + /// id is known. + pub async fn get_payment_details( + &self, payment_id: String, + ) -> Result, LdkServerClientError> { + let resp = self.inner.get_payment_details(GetPaymentDetailsRequest { payment_id }).await?; + Ok(resp.payment.map(PaymentInfo::from)) + } + + // ---- Receive ------------------------------------------------------- + + /// Generate a new on-chain address to receive into. Each call yields a fresh address. + pub async fn onchain_receive(&self) -> Result { + let resp = self.inner.onchain_receive(OnchainReceiveRequest {}).await?; + Ok(resp.address) + } + + /// Generate a new BOLT11 invoice. `description`, if supplied, is attached as a direct + /// description (the hash variant is intentionally not exposed for the MVP). + pub async fn bolt11_receive( + &self, amount_msat: Option, description: Option, expiry_secs: u32, + ) -> Result { + let description = description.map(|s| Bolt11InvoiceDescription { + kind: Some(bolt11_invoice_description::Kind::Direct(s)), + }); + let request = Bolt11ReceiveRequest { amount_msat, description, expiry_secs }; + let resp = self.inner.bolt11_receive(request).await?; + Ok(resp.into()) + } + + /// Generate a new BOLT12 offer. `description` is required (pass an empty string if you + /// want no description). + pub async fn bolt12_receive( + &self, description: String, amount_msat: Option, expiry_secs: Option, + quantity: Option, + ) -> Result { + let request = Bolt12ReceiveRequest { description, amount_msat, expiry_secs, quantity }; + let resp = self.inner.bolt12_receive(request).await?; + Ok(resp.into()) + } + + // ---- Send ---------------------------------------------------------- + + /// Pay a BIP21 URI or BIP353 Human-Readable Name. Dispatches to on-chain, BOLT11, or + /// BOLT12 on the server side depending on what the URI resolves to. + pub async fn unified_send( + &self, uri: String, amount_msat: Option, + ) -> Result { + let request = UnifiedSendRequest { uri, amount_msat, route_parameters: None }; + let resp = self.inner.unified_send(request).await?; + UnifiedSendResult::try_from(resp) + } + + /// Pay a BOLT11 invoice. `amount_msat` is required for zero-amount invoices and must be + /// `None` for fixed-amount invoices. Returns the server-side payment id. + pub async fn bolt11_send( + &self, invoice: String, amount_msat: Option, + ) -> Result { + let request = Bolt11SendRequest { invoice, amount_msat, route_parameters: None }; + let resp = self.inner.bolt11_send(request).await?; + Ok(resp.payment_id) + } + + /// Pay a BOLT12 offer. Returns the server-side payment id. + pub async fn bolt12_send( + &self, offer: String, amount_msat: Option, quantity: Option, + payer_note: Option, + ) -> Result { + let request = + Bolt12SendRequest { offer, amount_msat, quantity, payer_note, route_parameters: None }; + let resp = self.inner.bolt12_send(request).await?; + Ok(resp.payment_id) + } + + /// Send an on-chain payment. Set `send_all = Some(true)` to sweep the wallet (dangerous + /// if anchor channels are open — see `OnchainSendRequest` docs in the proto file). + /// Returns the broadcast txid. + pub async fn onchain_send( + &self, address: String, amount_sats: Option, send_all: Option, + fee_rate_sat_per_vb: Option, + ) -> Result { + let request = OnchainSendRequest { address, amount_sats, send_all, fee_rate_sat_per_vb }; + let resp = self.inner.onchain_send(request).await?; + Ok(resp.txid) + } + + // ---- Channels ------------------------------------------------------ + + /// Open a new channel with the given peer. Returns the local `user_channel_id`. + pub async fn open_channel( + &self, node_pubkey: String, address: String, channel_amount_sats: u64, + push_to_counterparty_msat: Option, announce_channel: bool, + ) -> Result { + let request = OpenChannelRequest { + node_pubkey, + address, + channel_amount_sats, + push_to_counterparty_msat, + channel_config: None, + announce_channel, + disable_counterparty_reserve: false, + }; + let resp = self.inner.open_channel(request).await?; + Ok(resp.user_channel_id) + } + + /// Cooperatively close a channel. + pub async fn close_channel( + &self, user_channel_id: String, counterparty_node_id: String, + ) -> Result<(), LdkServerClientError> { + let request = CloseChannelRequest { user_channel_id, counterparty_node_id }; + self.inner.close_channel(request).await?; + Ok(()) + } + + /// Force-close a channel. + pub async fn force_close_channel( + &self, user_channel_id: String, counterparty_node_id: String, + force_close_reason: Option, + ) -> Result<(), LdkServerClientError> { + let request = + ForceCloseChannelRequest { user_channel_id, counterparty_node_id, force_close_reason }; + self.inner.force_close_channel(request).await?; + Ok(()) + } + + // ---- Peers --------------------------------------------------------- + + /// Connect to a peer. If `persist = true`, we'll try to reconnect after restarts. + pub async fn connect_peer( + &self, node_pubkey: String, address: String, persist: bool, + ) -> Result<(), LdkServerClientError> { + let request = ConnectPeerRequest { node_pubkey, address, persist }; + self.inner.connect_peer(request).await?; + Ok(()) + } + + /// Disconnect from a peer. + pub async fn disconnect_peer(&self, node_pubkey: String) -> Result<(), LdkServerClientError> { + self.inner.disconnect_peer(DisconnectPeerRequest { node_pubkey }).await?; + Ok(()) + } + + // ---- Decode -------------------------------------------------------- + + /// Parse a BOLT11 invoice without sending a payment. + pub async fn decode_invoice( + &self, invoice: String, + ) -> Result { + let resp = self.inner.decode_invoice(DecodeInvoiceRequest { invoice }).await?; + Ok(resp.into()) + } + + /// Parse a BOLT12 offer without sending a payment. + pub async fn decode_offer(&self, offer: String) -> Result { + let resp = self.inner.decode_offer(DecodeOfferRequest { offer }).await?; + Ok(resp.into()) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use ldk_server_grpc::api::unified_send_response::PaymentResult; + use ldk_server_grpc::api::Bolt11ReceiveResponse; + use ldk_server_grpc::types::offer_amount::Amount as OfferAmountOneof; + use ldk_server_grpc::types::payment_kind::Kind as PaymentKindProst; + use ldk_server_grpc::types::{ + Bolt11, Bolt11Jit, Bolt12Offer, Bolt12Refund, CurrencyAmount, Onchain, PaymentKind, + Spontaneous, + }; + + use super::*; + + #[test] + fn error_code_mapping_covers_all_variants() { + // Each prost error code maps to a distinct wrapper variant, and the Display output + // preserves the original server-side message. We match on the wrapper variant + // directly rather than on the Display prefix so this test doesn't break if we + // retune wording. + let cases: Vec<(LdkServerErrorCode, &str, fn(&LdkServerClientError) -> bool)> = vec![ + (LdkServerErrorCode::InvalidRequestError, "r1", |e| { + matches!(e, LdkServerClientError::InvalidRequest { .. }) + }), + (LdkServerErrorCode::AuthError, "r2", |e| { + matches!(e, LdkServerClientError::AuthenticationFailed { .. }) + }), + (LdkServerErrorCode::LightningError, "r3", |e| { + matches!(e, LdkServerClientError::LightningError { .. }) + }), + (LdkServerErrorCode::InternalServerError, "r4", |e| { + matches!(e, LdkServerClientError::InternalServerError { .. }) + }), + (LdkServerErrorCode::InternalError, "r5", |e| { + matches!(e, LdkServerClientError::InternalError { .. }) + }), + ]; + for (code, msg, is_expected_variant) in cases { + let err: LdkServerClientError = LdkServerError::new(code.clone(), msg).into(); + assert!(is_expected_variant(&err), "wrong variant for code {code:?}: {err:?}"); + assert!( + format!("{err}").contains(msg), + "display output should contain the original message ({msg})", + ); + } + } + + #[test] + fn best_block_and_node_info_roundtrip() { + let resp = GetNodeInfoResponse { + node_id: "0211".to_string(), + current_best_block: Some(BestBlock { block_hash: "abcd".to_string(), height: 42 }), + latest_lightning_wallet_sync_timestamp: Some(100), + latest_onchain_wallet_sync_timestamp: None, + latest_fee_rate_cache_update_timestamp: Some(200), + latest_rgs_snapshot_timestamp: None, + latest_node_announcement_broadcast_timestamp: Some(300), + listening_addresses: vec!["127.0.0.1:9735".to_string()], + announcement_addresses: vec![], + node_alias: Some("my-node".to_string()), + node_uris: vec!["0211@example.com:9735".to_string()], + }; + let info: NodeInfo = resp.into(); + assert_eq!(info.node_id, "0211"); + let best = info.current_best_block.expect("present"); + assert_eq!(best.height, 42); + assert_eq!(best.block_hash, "abcd"); + assert_eq!(info.latest_lightning_wallet_sync_timestamp, Some(100)); + assert_eq!(info.latest_onchain_wallet_sync_timestamp, None); + assert_eq!(info.node_alias.as_deref(), Some("my-node")); + } + + #[test] + fn node_info_handles_missing_best_block() { + let info: NodeInfo = GetNodeInfoResponse::default().into(); + assert!(info.current_best_block.is_none()); + assert!(info.listening_addresses.is_empty()); + assert!(info.node_alias.is_none()); + } + + #[test] + fn balance_info_from_defaults() { + let balances: BalanceInfo = GetBalancesResponse::default().into(); + assert_eq!(balances.total_onchain_balance_sats, 0); + assert_eq!(balances.total_lightning_balance_sats, 0); + } + + #[test] + fn payment_direction_and_status_mapping() { + assert_eq!( + PaymentDirection::from(ProstPaymentDirection::Inbound), + PaymentDirection::Inbound + ); + assert_eq!( + PaymentDirection::from(ProstPaymentDirection::Outbound), + PaymentDirection::Outbound + ); + assert_eq!(PaymentStatus::from(ProstPaymentStatus::Pending), PaymentStatus::Pending); + assert_eq!(PaymentStatus::from(ProstPaymentStatus::Succeeded), PaymentStatus::Succeeded); + assert_eq!(PaymentStatus::from(ProstPaymentStatus::Failed), PaymentStatus::Failed); + } + + #[test] + fn payment_kind_covers_all_oneof_variants() { + let cases: Vec<(PaymentKindProst, &str)> = vec![ + ( + PaymentKindProst::Onchain(Onchain { txid: "deadbeef".to_string(), status: None }), + "Onchain", + ), + ( + PaymentKindProst::Bolt11(Bolt11 { + hash: "h11".to_string(), + preimage: Some("p11".to_string()), + secret: None, + }), + "Bolt11", + ), + ( + PaymentKindProst::Bolt11Jit(Bolt11Jit { + hash: "hjit".to_string(), + preimage: None, + secret: None, + lsp_fee_limits: None, + counterparty_skimmed_fee_msat: None, + }), + "Bolt11Jit", + ), + ( + PaymentKindProst::Bolt12Offer(Bolt12Offer { + hash: None, + preimage: None, + secret: None, + offer_id: "oid".to_string(), + payer_note: None, + quantity: None, + }), + "Bolt12Offer", + ), + ( + PaymentKindProst::Bolt12Refund(Bolt12Refund { + hash: None, + preimage: None, + secret: None, + payer_note: None, + quantity: None, + }), + "Bolt12Refund", + ), + ( + PaymentKindProst::Spontaneous(Spontaneous { + hash: "hsp".to_string(), + preimage: None, + }), + "Spontaneous", + ), + ]; + + for (prost_kind, label) in cases { + let wrapper = PaymentKindInfo::from(prost_kind); + let debug_repr = format!("{wrapper:?}"); + assert!(debug_repr.starts_with(label), "{debug_repr} should start with {label}"); + } + } + + #[test] + fn payment_with_unknown_enum_values_defaults_safely() { + // Protocol version skew: the server returns a direction/status int the client + // doesn't recognize. We should fall back to safe defaults rather than panic. + let payment = Payment { + id: "x".to_string(), + kind: Some(PaymentKind { + kind: Some(PaymentKindProst::Spontaneous(Spontaneous { + hash: "h".to_string(), + preimage: None, + })), + }), + amount_msat: Some(1000), + fee_paid_msat: None, + direction: 999, + status: -1, + latest_update_timestamp: 0, + }; + let info: PaymentInfo = payment.into(); + assert_eq!(info.direction, PaymentDirection::Outbound); + assert_eq!(info.status, PaymentStatus::Pending); + assert!(info.kind.is_some()); + } + + #[test] + fn channel_info_roundtrip() { + let c = Channel { + channel_id: "cid".to_string(), + counterparty_node_id: "cp".to_string(), + funding_txo: Some(OutPoint { txid: "tx".to_string(), vout: 3 }), + user_channel_id: "uc".to_string(), + unspendable_punishment_reserve: None, + channel_value_sats: 1_000_000, + feerate_sat_per_1000_weight: 0, + outbound_capacity_msat: 500_000_000, + inbound_capacity_msat: 500_000_000, + confirmations_required: Some(6), + confirmations: Some(3), + is_outbound: true, + is_channel_ready: false, + is_usable: false, + is_announced: true, + channel_config: None, + next_outbound_htlc_limit_msat: 0, + next_outbound_htlc_minimum_msat: 0, + force_close_spend_delay: None, + counterparty_outbound_htlc_minimum_msat: None, + counterparty_outbound_htlc_maximum_msat: None, + counterparty_unspendable_punishment_reserve: 0, + counterparty_forwarding_info_fee_base_msat: None, + counterparty_forwarding_info_fee_proportional_millionths: None, + counterparty_forwarding_info_cltv_expiry_delta: None, + }; + let info: ChannelInfo = c.into(); + assert_eq!(info.channel_id, "cid"); + assert_eq!(info.funding_txo.as_ref().map(|o| o.vout), Some(3)); + assert_eq!(info.channel_value_sats, 1_000_000); + assert!(info.is_outbound); + assert!(!info.is_usable); + } + + #[test] + fn peer_info_roundtrip() { + let peer = Peer { + node_id: "np".to_string(), + address: "127.0.0.1:9735".to_string(), + is_persisted: true, + is_connected: false, + }; + let info: PeerInfo = peer.into(); + assert_eq!(info.node_id, "np"); + assert!(info.is_persisted); + assert!(!info.is_connected); + } + + #[test] + fn unified_send_result_dispatches_on_oneof() { + let txid_resp = + UnifiedSendResponse { payment_result: Some(PaymentResult::Txid("tx".to_string())) }; + assert!(matches!( + UnifiedSendResult::try_from(txid_resp).unwrap(), + UnifiedSendResult::Onchain { .. } + )); + + let b11_resp = UnifiedSendResponse { + payment_result: Some(PaymentResult::Bolt11PaymentId("p1".to_string())), + }; + assert!(matches!( + UnifiedSendResult::try_from(b11_resp).unwrap(), + UnifiedSendResult::Bolt11 { .. } + )); + + let b12_resp = UnifiedSendResponse { + payment_result: Some(PaymentResult::Bolt12PaymentId("p2".to_string())), + }; + assert!(matches!( + UnifiedSendResult::try_from(b12_resp).unwrap(), + UnifiedSendResult::Bolt12 { .. } + )); + + // Empty payment_result is a protocol violation and should surface as an error rather + // than a mystery default. + let empty = UnifiedSendResponse { payment_result: None }; + assert!(matches!( + UnifiedSendResult::try_from(empty), + Err(LdkServerClientError::InternalError { .. }) + )); + } + + #[test] + fn bolt11_receive_roundtrip() { + let resp = Bolt11ReceiveResponse { + invoice: "lnbc...".to_string(), + payment_hash: "ph".to_string(), + payment_secret: "ps".to_string(), + }; + let result: Bolt11ReceiveResult = resp.into(); + assert_eq!(result.invoice, "lnbc..."); + assert_eq!(result.payment_hash, "ph"); + assert_eq!(result.payment_secret, "ps"); + } + + #[test] + fn decoded_offer_extracts_bitcoin_amount_only() { + let with_btc = DecodeOfferResponse { + offer_id: "oid".to_string(), + description: None, + issuer: None, + amount: Some(OfferAmount { amount: Some(OfferAmountOneof::BitcoinAmountMsats(5_000)) }), + issuer_signing_pubkey: None, + absolute_expiry: None, + quantity: None, + paths: vec![], + features: Default::default(), + chains: vec![], + metadata: None, + is_expired: false, + }; + let d: DecodedOffer = with_btc.into(); + assert_eq!(d.amount_msat, Some(5_000)); + + // Currency-denominated offers flatten to None, not an error — a wallet that can only + // pay in Bitcoin should treat this the same as an amount-less offer. + let with_currency = DecodeOfferResponse { + offer_id: "oid".to_string(), + description: None, + issuer: None, + amount: Some(OfferAmount { + amount: Some(OfferAmountOneof::CurrencyAmount(CurrencyAmount { + iso4217_code: "USD".to_string(), + amount: 42, + })), + }), + issuer_signing_pubkey: None, + absolute_expiry: None, + quantity: None, + paths: vec![], + features: Default::default(), + chains: vec![], + metadata: None, + is_expired: false, + }; + let d: DecodedOffer = with_currency.into(); + assert_eq!(d.amount_msat, None); + } + + #[test] + fn decoded_invoice_roundtrip() { + let resp = DecodeInvoiceResponse { + destination: "d".to_string(), + payment_hash: "ph".to_string(), + amount_msat: Some(1_000), + timestamp: 1, + expiry: 2, + description: Some("coffee".to_string()), + description_hash: None, + fallback_address: None, + min_final_cltv_expiry_delta: 3, + payment_secret: "ps".to_string(), + route_hints: vec![], + features: Default::default(), + currency: "bitcoin".to_string(), + payment_metadata: None, + is_expired: false, + }; + let d: DecodedInvoice = resp.into(); + assert_eq!(d.amount_msat, Some(1_000)); + assert_eq!(d.description.as_deref(), Some("coffee")); + assert_eq!(d.currency, "bitcoin"); + } + + #[test] + fn page_token_roundtrips_both_ways() { + let prost = PageToken { token: "t".to_string(), index: 5 }; + let info: PageTokenInfo = prost.clone().into(); + assert_eq!(info.index, 5); + let back: PageToken = info.into(); + assert_eq!(back.token, "t"); + assert_eq!(back.index, 5); + } +} diff --git a/ldk-server-client/uniffi-android.toml b/ldk-server-client/uniffi-android.toml new file mode 100644 index 00000000..3549610a --- /dev/null +++ b/ldk-server-client/uniffi-android.toml @@ -0,0 +1,5 @@ +[bindings.kotlin] +package_name = "org.lightningdevkit.ldkserver.client" +cdylib_name = "ldk_server_client" +android = true +android_cleaner = true diff --git a/ldk-server-client/uniffi-bindgen.rs b/ldk-server-client/uniffi-bindgen.rs new file mode 100644 index 00000000..24d001fd --- /dev/null +++ b/ldk-server-client/uniffi-bindgen.rs @@ -0,0 +1,11 @@ +// Entry point for the uniffi-bindgen CLI, used to generate foreign-language +// bindings (e.g. Kotlin, Swift) from this crate's UniFFI-exported interface. +// +// Build it with `cargo build --features uniffi-cli --bin uniffi-bindgen` and +// invoke via e.g. +// cargo run --features uniffi-cli --bin uniffi-bindgen -- \ +// generate --library target//release/libldk_server_client.so \ +// --language kotlin --out-dir +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/scripts/uniffi_bindgen_generate_kotlin_android.sh b/scripts/uniffi_bindgen_generate_kotlin_android.sh new file mode 100755 index 00000000..a0d42f1f --- /dev/null +++ b/scripts/uniffi_bindgen_generate_kotlin_android.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Cross-compiles `ldk-server-client` for the three Android ABIs we ship +# (arm64-v8a, armeabi-v7a, x86_64) and generates Kotlin bindings from the +# resulting cdylib using library mode. +# +# Outputs (relative to the workspace root, i.e. ../ldk-server): +# target//release/libldk_server_client.so +# $OUT_DIR/kotlin/uniffi/ldk_server_client/ldk_server_client.kt +# $OUT_DIR/jniLibs//libldk_server_client.so +# +# Prerequisites (see README): +# * Android SDK + NDK r27+ installed, with ANDROID_NDK_ROOT exported. +# * Rust 1.93+ with Android targets (aarch64/armv7/x86_64-linux-android). +# * cargo-ndk installed (`cargo install cargo-ndk`). +# +# Usage: +# OUT_DIR=../ldk-server-remote/android/app/src/main \ +# ./scripts/uniffi_bindgen_generate_kotlin_android.sh +# +# If OUT_DIR is unset, files land in ./target/android-bindings/. + +set -euo pipefail + +# Resolve to the workspace root (this script lives in /scripts). +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)" +WORKSPACE_ROOT="$(dirname "$SCRIPT_DIR")" +cd "$WORKSPACE_ROOT" + +OUT_DIR="${OUT_DIR:-$WORKSPACE_ROOT/target/android-bindings}" +KOTLIN_OUT="$OUT_DIR/kotlin" +JNI_LIBS_OUT="$OUT_DIR/jniLibs" + +if [[ -z "${ANDROID_NDK_ROOT:-}" && -z "${NDK_HOME:-}" ]]; then + cat >&2 <<'EOF' +error: ANDROID_NDK_ROOT (or NDK_HOME) is not set. +Install the NDK via `sdkmanager "ndk;27.0.12077973"` and export + ANDROID_NDK_ROOT=$ANDROID_HOME/ndk/27.0.12077973 +before re-running this script. +EOF + exit 1 +fi + +if ! command -v cargo-ndk > /dev/null 2>&1; then + echo "error: cargo-ndk not found. Install with: cargo install cargo-ndk" >&2 + exit 1 +fi + +# Android targets (ABI-named). Keep these aligned with the ABI folders under `jniLibs/`. +TARGETS=(arm64-v8a armeabi-v7a x86_64) + +echo "[1/3] Cross-compiling ldk-server-client for Android (${TARGETS[*]})..." +cargo ndk \ + --manifest-path "$WORKSPACE_ROOT/ldk-server-client/Cargo.toml" \ + $(printf -- "-t %s " "${TARGETS[@]}") \ + --platform 24 \ + build --release --features uniffi + +# Pick any one of the compiled .so files for library-mode bindings extraction; +# the metadata is platform-agnostic. +LIBRARY_PATH="$WORKSPACE_ROOT/target/x86_64-linux-android/release/libldk_server_client.so" + +if [[ ! -f "$LIBRARY_PATH" ]]; then + echo "error: expected library not found at $LIBRARY_PATH" >&2 + exit 1 +fi + +echo "[2/3] Generating Kotlin bindings..." +mkdir -p "$KOTLIN_OUT" +cargo run --manifest-path "$WORKSPACE_ROOT/ldk-server-client/Cargo.toml" \ + --features uniffi-cli --bin uniffi-bindgen -- \ + generate \ + --library "$LIBRARY_PATH" \ + --language kotlin \ + --config "$WORKSPACE_ROOT/ldk-server-client/uniffi-android.toml" \ + --out-dir "$KOTLIN_OUT" + +echo "[3/3] Copying native libs into $JNI_LIBS_OUT..." +# Map cargo's target triples back to Android ABI directory names. +declare -A TRIPLES_TO_ABI=( + [aarch64-linux-android]=arm64-v8a + [armv7-linux-androideabi]=armeabi-v7a + [x86_64-linux-android]=x86_64 +) +for triple in "${!TRIPLES_TO_ABI[@]}"; do + abi="${TRIPLES_TO_ABI[$triple]}" + src="$WORKSPACE_ROOT/target/$triple/release/libldk_server_client.so" + dst="$JNI_LIBS_OUT/$abi" + mkdir -p "$dst" + cp "$src" "$dst/libldk_server_client.so" +done + +echo +echo "Done. Kotlin: $KOTLIN_OUT" +echo " jniLibs: $JNI_LIBS_OUT"