diff --git a/.env.example b/.env.example index 3fcc59610..65cd3fb1c 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,11 @@ FORCE_ISOCODE= # Breez SDK API Key (for Lightning Network features) # Get your API key at: https://sdk-doc-liquid.breez.technology/guide/getting_started.html#api-key -BREEZ_API_KEY= \ No newline at end of file +# +# One issued Breez key currently covers both the Liquid and Spark SDKs, +# so `BREEZ_API_KEY` is read by both backends by default. To split them +# later (e.g. if Breez issues separate keys), set `BREEZ_SPARK_API_KEY` +# explicitly — it takes precedence over `BREEZ_API_KEY` for the Spark +# backend. See `coincube-gui/src/app/breez_spark/config.rs`. +BREEZ_API_KEY= +BREEZ_SPARK_API_KEY= \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index beb72f505..20168cdaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1600,6 +1600,7 @@ dependencies = [ "chacha20poly1305", "chrono", "coincube-core", + "coincube-spark-protocol", "coincube-ui", "coincubed", "dirs", @@ -1640,6 +1641,14 @@ dependencies = [ "zip", ] +[[package]] +name = "coincube-spark-protocol" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "coincube-ui" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index d3a3967db..e028fe4d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,28 @@ [workspace] resolver = "2" -members = ["fuzz", "coincube-core", "coincubed", "coincube-gui", "coincube-ui"] -default-members = ["coincube-core", "coincubed", "coincube-gui", "coincube-ui"] +members = [ + "fuzz", + "coincube-core", + "coincubed", + "coincube-gui", + "coincube-ui", + "coincube-spark-protocol", +] +# coincube-spark-bridge is deliberately NOT a member of this workspace. +# It links breez-sdk-spark 0.13.1, whose `tokio_with_wasm ^0.8.7` and +# `rusqlite ^0.32.1 (links = "sqlite3")` dep requirements are unresolvable +# together with breez-sdk-liquid 0.12.2. Making the bridge a standalone +# workspace (with its own Cargo.lock) gives it an isolated dep graph and +# lets it coexist with the Liquid-linked gui at runtime via stdin/stdout +# IPC. See coincube-spark-bridge/Cargo.toml for its own [workspace] block. +exclude = ["coincube-spark-bridge"] +default-members = [ + "coincube-core", + "coincubed", + "coincube-gui", + "coincube-ui", + "coincube-spark-protocol", +] # Patch to resolve dependency conflicts with Breez SDK [patch.crates-io] diff --git a/coincube-gui/Cargo.toml b/coincube-gui/Cargo.toml index 56f20a02e..5573ba2c7 100644 --- a/coincube-gui/Cargo.toml +++ b/coincube-gui/Cargo.toml @@ -21,6 +21,7 @@ async-trait = "0.1" async-hwi = "0.0.29" breez-sdk-liquid = { git = "https://github.com/breez/breez-sdk-liquid", tag = "0.12.2" } coincube-core = { path = "../coincube-core" } +coincube-spark-protocol = { path = "../coincube-spark-protocol" } coincubed = { path = "../coincubed", default-features = false, features = [ "nonblocking_shutdown", ] } @@ -47,7 +48,16 @@ iced_runtime = "0.14.0" # Used to verify RFC-compliance of an email email_address = "0.2.7" -tokio = { version = "1", features = ["signal"] } +tokio = { version = "1", features = [ + "signal", + "process", + "io-util", + "sync", + "time", + "macros", + "rt", + "rt-multi-thread", +] } async-fd-lock = "0.2.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/coincube-gui/src/app/breez/assets.rs b/coincube-gui/src/app/breez_liquid/assets.rs similarity index 98% rename from coincube-gui/src/app/breez/assets.rs rename to coincube-gui/src/app/breez_liquid/assets.rs index d6be6b4ba..213847c05 100644 --- a/coincube-gui/src/app/breez/assets.rs +++ b/coincube-gui/src/app/breez_liquid/assets.rs @@ -110,7 +110,7 @@ pub fn asset_kind_for_id(asset_id: &str, network: Network) -> Option /// decimal places. /// /// ``` -/// use coincube_gui::app::breez::assets::format_asset_amount; +/// use coincube_gui::app::breez_liquid::assets::format_asset_amount; /// assert_eq!(format_asset_amount(100_000_000, 8), "1.00000000"); /// assert_eq!(format_asset_amount(50_000_000, 8), "0.50000000"); /// ``` @@ -157,7 +157,7 @@ pub fn format_usdt_display(amount: u64) -> String { /// `scale`. Returns `None` for malformed or empty inputs. Zero values return `Some(0)`. /// /// ``` -/// use coincube_gui::app::breez::assets::parse_asset_to_minor_units; +/// use coincube_gui::app::breez_liquid::assets::parse_asset_to_minor_units; /// assert_eq!(parse_asset_to_minor_units("1.50", 8), Some(150_000_000)); /// assert_eq!(parse_asset_to_minor_units("1e2", 8), None); // scientific notation rejected /// assert_eq!(parse_asset_to_minor_units("0.0", 8), Some(0)); // zero yields Some(0) diff --git a/coincube-gui/src/app/breez/client.rs b/coincube-gui/src/app/breez_liquid/client.rs similarity index 100% rename from coincube-gui/src/app/breez/client.rs rename to coincube-gui/src/app/breez_liquid/client.rs diff --git a/coincube-gui/src/app/breez/config.rs b/coincube-gui/src/app/breez_liquid/config.rs similarity index 100% rename from coincube-gui/src/app/breez/config.rs rename to coincube-gui/src/app/breez_liquid/config.rs diff --git a/coincube-gui/src/app/breez/mod.rs b/coincube-gui/src/app/breez_liquid/mod.rs similarity index 100% rename from coincube-gui/src/app/breez/mod.rs rename to coincube-gui/src/app/breez_liquid/mod.rs diff --git a/coincube-gui/src/app/breez_spark/assets.rs b/coincube-gui/src/app/breez_spark/assets.rs new file mode 100644 index 000000000..cc8110162 --- /dev/null +++ b/coincube-gui/src/app/breez_spark/assets.rs @@ -0,0 +1,43 @@ +//! Placeholder Spark asset registry. +//! +//! Phase 2 scope: Bitcoin + Lightning only. Spark SDK 0.13.1 also ships +//! Spark-native tokens (BTKN) and a Stable Balance feature (USDB under +//! the hood), but those are invisible to the user in our Phase 6 plan — +//! Stable Balance surfaces as a Settings toggle, not a user-visible asset, +//! and BTKN has no shipping use case for us yet. +//! +//! This module exists so future phases have a stable place to extend the +//! asset list. The `list_assets()` accessor returns an owned `Vec` so +//! callers don't have to care whether the list is static or derived from +//! backend state (it'll need to pull from the bridge once per-cube asset +//! discovery is wired). + +/// A Spark-side asset that the UI can display in a picker / balance row. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub enum SparkAsset { + /// Native Spark Bitcoin balance. + Bitcoin, + /// Lightning payments (BOLT11 / BOLT12 / LNURL). Rendered as a + /// separate asset even though it's routed over Bitcoin under the + /// hood, matching how the UI already treats Liquid's Lightning + /// method. + Lightning, +} + +impl SparkAsset { + /// Short label for display in pickers (not localized). + pub fn label(self) -> &'static str { + match self { + Self::Bitcoin => "BTC", + Self::Lightning => "Lightning", + } + } +} + +/// Return the full asset list the Spark panels should render today. +/// +/// Phase 6 will add a Stable Balance toggle in Settings (not an asset +/// entry) and later phases may add more Spark-native assets here. +pub fn list_assets() -> Vec { + vec![SparkAsset::Bitcoin, SparkAsset::Lightning] +} diff --git a/coincube-gui/src/app/breez_spark/client.rs b/coincube-gui/src/app/breez_spark/client.rs new file mode 100644 index 000000000..c16b14728 --- /dev/null +++ b/coincube-gui/src/app/breez_spark/client.rs @@ -0,0 +1,862 @@ +//! Gui-side client for the `coincube-spark-bridge` subprocess. +//! +//! Architecture +//! ------------ +//! +//! The Breez Spark SDK lives in a sibling binary because its dep graph +//! (rusqlite / libsqlite3-sys / tokio_with_wasm) can't be unified with +//! breez-sdk-liquid's. See `coincube-spark-bridge/Cargo.toml` for the +//! companion crate. +//! +//! [`SparkClient`] owns a [`tokio::process::Child`] and three background +//! tokio tasks: +//! +//! - **writer**: pulls [`Request`] frames from an mpsc channel and +//! writes them as JSON lines to the child's stdin. +//! - **reader**: reads JSON lines from the child's stdout, parses +//! [`Frame`]s, and routes [`Response`]s through a shared pending map +//! (`id -> oneshot::Sender`). [`Event`] frames go to a future event +//! channel (not wired in Phase 3 — just logged for now). +//! - **stderr pump**: logs each stderr line from the bridge at warn level. +//! +//! A request goes like: allocate id, insert `oneshot::Sender` into pending +//! map, send `Request` over the writer channel, await the oneshot. The +//! reader task resolves oneshots by id as responses come back, so +//! concurrent requests don't block each other. +//! +//! Lifecycle +//! --------- +//! +//! [`SparkClient::connect`] spawns the bridge, performs the +//! [`Method::Init`] handshake, and returns the client on success. If the +//! bridge exits before responding, or returns an error, the call fails +//! and the child is cleaned up. On drop the client sends a best-effort +//! [`Method::Shutdown`] (non-blocking fire-and-forget) and kills the +//! child if it didn't exit on its own — `kill_on_drop(true)` on the +//! `Command` ensures the OS reaps it even if the graceful path fails. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use coincube_spark_protocol::{ + ClaimDepositOk, ClaimDepositParams, ErrorKind, ErrorPayload, Event, Frame, GetInfoOk, + GetInfoParams, GetUserSettingsOk, InitParams, ListPaymentsOk, ListPaymentsParams, + ListUnclaimedDepositsOk, Method, OkPayload, ParseInputOk, ParseInputParams, + PrepareLnurlPayParams, PrepareSendOk, PrepareSendParams, ReceiveBolt11Params, + ReceiveOnchainParams, ReceivePaymentOk, Request, Response, ResponseResult, SendPaymentOk, + SendPaymentParams, SetStableBalanceParams, +}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::{broadcast, mpsc, oneshot, Mutex}; +use tracing::{debug, error, warn}; + +use super::config::SparkConfig; + +/// Shared pending-request table. Each entry maps an outstanding request +/// id to the oneshot sender that the caller is awaiting. +type PendingMap = Arc>>>; + +/// Handle to a running `coincube-spark-bridge` subprocess. +/// +/// Clone-safe: the underlying state is `Arc`-shared, so multiple panels +/// can call methods concurrently. Dropping the last clone triggers a +/// best-effort graceful shutdown of the child process. +#[derive(Clone)] +pub struct SparkClient { + inner: Arc, +} + +struct SparkClientInner { + next_id: AtomicU64, + request_tx: mpsc::UnboundedSender, + pending: PendingMap, + /// Broadcast channel into which the reader task pushes every + /// [`Event`] frame received from the bridge. Panels subscribe via + /// [`SparkClient::subscribe_events`] — each subscriber gets its + /// own independent receiver, so the same event can fan out to + /// Receive + Transactions + any future listener without stepping + /// on each other's cursor. + /// + /// Buffer size is 64 (plenty for a wallet's event rate). If a + /// subscriber lags far enough, `broadcast::Receiver::recv` returns + /// `Err(RecvError::Lagged)` — the iced subscription stream below + /// logs and resumes so a slow panel can't wedge the channel. + event_tx: broadcast::Sender, + child: Mutex>, + /// True once `shutdown()` was called or the client was dropped — + /// further requests short-circuit with [`SparkClientError::BridgeUnavailable`]. + closed: std::sync::atomic::AtomicBool, +} + +impl std::fmt::Debug for SparkClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SparkClient").finish_non_exhaustive() + } +} + +impl SparkClient { + /// Spawn the bridge subprocess, hand it a mnemonic + config, and + /// return the connected client. + /// + /// `mnemonic` is passed into the bridge over stdin and then dropped + /// by the caller (see [`super::mod::load_spark_client`] for the + /// zeroizing wrapper). The bridge keeps it in memory for the + /// session lifetime. + pub async fn connect(config: SparkConfig, mnemonic: &str) -> Result { + if config.api_key.is_empty() { + return Err(SparkClientError::Config( + "Spark SDK API key is empty — set BREEZ_SPARK_API_KEY or BREEZ_API_KEY at build time" + .to_string(), + )); + } + + let bridge_path = resolve_bridge_path()?; + debug!("spawning Spark bridge at {:?}", bridge_path); + + let mut child = Command::new(&bridge_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|e| { + SparkClientError::BridgeUnavailable(format!( + "failed to spawn {}: {}", + bridge_path.display(), + e + )) + })?; + + let stdin = child.stdin.take().ok_or_else(|| { + SparkClientError::BridgeUnavailable("bridge stdin was not piped".to_string()) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + SparkClientError::BridgeUnavailable("bridge stdout was not piped".to_string()) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + SparkClientError::BridgeUnavailable("bridge stderr was not piped".to_string()) + })?; + + let pending: PendingMap = Arc::new(Mutex::new(HashMap::new())); + let (request_tx, request_rx) = mpsc::unbounded_channel::(); + // Buffer 64 events — at the bridge's event rate (one per SDK + // sync tick + one per payment state change) that's several + // minutes of headroom even if a subscriber is paused. + let (event_tx, _) = broadcast::channel::(64); + + spawn_writer_task(stdin, request_rx); + spawn_reader_task(stdout, Arc::clone(&pending), event_tx.clone()); + spawn_stderr_task(stderr); + + let inner = Arc::new(SparkClientInner { + next_id: AtomicU64::new(1), + request_tx, + pending, + event_tx, + child: Mutex::new(Some(child)), + closed: std::sync::atomic::AtomicBool::new(false), + }); + let client = Self { inner }; + + // Perform the init handshake. If this fails, drop the client so + // the child process is killed via `kill_on_drop`. + let init_params = InitParams { + api_key: config.api_key, + network: config.network, + mnemonic: mnemonic.to_string(), + mnemonic_passphrase: None, + storage_dir: config + .storage_dir + .to_str() + .ok_or_else(|| { + SparkClientError::Config( + "Spark storage_dir contains non-UTF-8 bytes".to_string(), + ) + })? + .to_string(), + }; + + match client + .request(Method::Init(init_params)) + .await? + { + OkPayload::Init {} => Ok(client), + other => Err(SparkClientError::Protocol(format!( + "init returned unexpected payload: {:?}", + other + ))), + } + } + + /// Fetch wallet info (balance, identity pubkey). + pub async fn get_info(&self) -> Result { + match self + .request(Method::GetInfo(GetInfoParams { + ensure_synced: Some(true), + })) + .await? + { + OkPayload::GetInfo(info) => Ok(info), + other => Err(SparkClientError::Protocol(format!( + "get_info returned unexpected payload: {:?}", + other + ))), + } + } + + /// List recent payments. + pub async fn list_payments( + &self, + limit: Option, + ) -> Result { + match self + .request(Method::ListPayments(ListPaymentsParams { + limit, + offset: Some(0), + })) + .await? + { + OkPayload::ListPayments(list) => Ok(list), + other => Err(SparkClientError::Protocol(format!( + "list_payments returned unexpected payload: {:?}", + other + ))), + } + } + + /// Phase 4e: classify a user-supplied destination string. + /// + /// Calls `BreezSdk::parse` on the bridge side and returns a + /// high-level [`ParseInputOk`] tag the gui can branch on. The Send + /// panel uses this before `prepare_send` to route LNURL / + /// Lightning-address inputs to [`Self::prepare_lnurl_pay`]. + pub async fn parse_input(&self, input: String) -> Result { + match self + .request(Method::ParseInput(ParseInputParams { input })) + .await? + { + OkPayload::ParseInput(ok) => Ok(ok), + other => Err(SparkClientError::Protocol(format!( + "parse_input returned unexpected payload: {:?}", + other + ))), + } + } + + /// Phase 4e: prepare an LNURL-pay / Lightning-address send. + /// + /// Companion to [`Self::prepare_send`] for the LNURL code path. + /// Returns the same [`PrepareSendOk`] shape so the gui's state + /// machine doesn't need a parallel send branch — the bridge + /// remembers which pending map the handle belongs to and dispatches + /// to `sdk.lnurl_pay` vs `sdk.send_payment` transparently when the + /// gui calls [`Self::send_payment`] with the handle. + /// + /// `amount_sat` is required (LNURL servers always specify a + /// min/max range). `comment` is forwarded if the server allows + /// comments. + pub async fn prepare_lnurl_pay( + &self, + input: String, + amount_sat: u64, + comment: Option, + ) -> Result { + match self + .request(Method::PrepareLnurlPay(PrepareLnurlPayParams { + input, + amount_sat, + comment, + })) + .await? + { + OkPayload::PrepareSend(ok) => Ok(ok), + other => Err(SparkClientError::Protocol(format!( + "prepare_lnurl_pay returned unexpected payload: {:?}", + other + ))), + } + } + + /// Phase 4c: parse a destination + compute a send preview. + /// + /// `input` accepts BOLT11 invoices, BIP21 URIs, and on-chain Bitcoin + /// addresses. LNURL / Lightning Address destinations should go + /// through [`Self::parse_input`] + [`Self::prepare_lnurl_pay`] + /// instead — `prepare_send` rejects them at the SDK level. + /// + /// `amount_sat` is required for amountless invoices and on-chain + /// sends; ignored otherwise. Returns a [`PrepareSendOk`] whose + /// `handle` must be echoed back to [`Self::send_payment`] to + /// execute the send. The bridge holds the full SDK prepare + /// response under that key — the handle is single-use. + pub async fn prepare_send( + &self, + input: String, + amount_sat: Option, + ) -> Result { + match self + .request(Method::PrepareSend(PrepareSendParams { input, amount_sat })) + .await? + { + OkPayload::PrepareSend(prepare) => Ok(prepare), + other => Err(SparkClientError::Protocol(format!( + "prepare_send returned unexpected payload: {:?}", + other + ))), + } + } + + /// Phase 4c: execute a previously-prepared send. + /// + /// `prepare_handle` must come from a prior [`Self::prepare_send`] + /// response. It is consumed by the bridge on success or failure — + /// calling twice with the same handle returns a + /// [`SparkClientError::BridgeError`] with + /// [`ErrorKind::BadRequest`]. + pub async fn send_payment( + &self, + prepare_handle: String, + ) -> Result { + match self + .request(Method::SendPayment(SendPaymentParams { prepare_handle })) + .await? + { + OkPayload::SendPayment(sent) => Ok(sent), + other => Err(SparkClientError::Protocol(format!( + "send_payment returned unexpected payload: {:?}", + other + ))), + } + } + + /// Phase 4c: generate a BOLT11 invoice. + /// + /// `amount_sat = None` produces an amountless invoice. `description` + /// is shown to the payer's wallet. `expiry_secs = None` defers to + /// the SDK default (typically 24h). + pub async fn receive_bolt11( + &self, + amount_sat: Option, + description: String, + expiry_secs: Option, + ) -> Result { + match self + .request(Method::ReceiveBolt11(ReceiveBolt11Params { + amount_sat, + description, + expiry_secs, + })) + .await? + { + OkPayload::ReceivePayment(resp) => Ok(resp), + other => Err(SparkClientError::Protocol(format!( + "receive_bolt11 returned unexpected payload: {:?}", + other + ))), + } + } + + /// Phase 4c: generate an on-chain Bitcoin deposit address. + /// + /// Note: Spark's on-chain receive model requires a separate + /// `claim_deposit` call once the incoming tx has confirmed — + /// that's Phase 4d work. Phase 4c just returns the address and + /// trusts the user / background sync to complete the claim + /// eventually. + pub async fn receive_onchain( + &self, + new_address: Option, + ) -> Result { + match self + .request(Method::ReceiveOnchain(ReceiveOnchainParams { new_address })) + .await? + { + OkPayload::ReceivePayment(resp) => Ok(resp), + other => Err(SparkClientError::Protocol(format!( + "receive_onchain returned unexpected payload: {:?}", + other + ))), + } + } + + /// Phase 4f: list on-chain deposits the SDK has noticed but not + /// yet claimed into the Spark wallet. Drives the "Pending + /// deposits" card in the Receive panel. + pub async fn list_unclaimed_deposits( + &self, + ) -> Result { + match self.request(Method::ListUnclaimedDeposits).await? { + OkPayload::ListUnclaimedDeposits(resp) => Ok(resp), + other => Err(SparkClientError::Protocol(format!( + "list_unclaimed_deposits returned unexpected payload: {:?}", + other + ))), + } + } + + /// Phase 4f: claim a specific (txid, vout) deposit into the Spark + /// wallet. Returns the resulting payment id + claimed amount. + /// Fails with [`SparkClientError::BridgeError`] / [`ErrorKind::Sdk`] + /// when the deposit isn't mature yet — the gui should gate the + /// Claim button on the deposit's `is_mature` field to avoid + /// firing pre-mature claims. + pub async fn claim_deposit( + &self, + txid: String, + vout: u32, + ) -> Result { + match self + .request(Method::ClaimDeposit(ClaimDepositParams { txid, vout })) + .await? + { + OkPayload::ClaimDeposit(resp) => Ok(resp), + other => Err(SparkClientError::Protocol(format!( + "claim_deposit returned unexpected payload: {:?}", + other + ))), + } + } + + /// Phase 6: read the SDK's `UserSettings` (Stable Balance on/off, + /// private mode). Boolean-flattened on the bridge side so the gui + /// never sees the USDB token label. + pub async fn get_user_settings(&self) -> Result { + match self.request(Method::GetUserSettings).await? { + OkPayload::GetUserSettings(resp) => Ok(resp), + other => Err(SparkClientError::Protocol(format!( + "get_user_settings returned unexpected payload: {:?}", + other + ))), + } + } + + /// Phase 6: activate (enabled=true) or deactivate (false) the + /// Stable Balance feature. The bridge translates this into + /// `update_user_settings(stable_balance_active_label = ...)`. + pub async fn set_stable_balance(&self, enabled: bool) -> Result<(), SparkClientError> { + match self + .request(Method::SetStableBalance(SetStableBalanceParams { enabled })) + .await? + { + OkPayload::SetStableBalance {} => Ok(()), + other => Err(SparkClientError::Protocol(format!( + "set_stable_balance returned unexpected payload: {:?}", + other + ))), + } + } + + /// Subscribe to bridge [`Event`] frames. Each call returns a fresh + /// `broadcast::Receiver` — each subscriber gets its own independent + /// cursor over the buffered events. The [`iced::Subscription`] + /// helper below wraps this into an iced subscription stream. + pub fn subscribe_events(&self) -> broadcast::Receiver { + self.inner.event_tx.subscribe() + } + + /// Build an iced [`Subscription`](iced::Subscription) over the + /// bridge's event stream. Fires a [`SparkClientEvent`] every time + /// the bridge forwards an SDK event, and silently resumes when a + /// subscriber lags. + /// + /// The state parameter hashes on a per-client identity (the + /// `event_tx` pointer) so swapping out the SparkClient on a + /// reconnect produces a fresh subscription instead of re-binding + /// to the old channel. + pub fn event_subscription(&self) -> iced::Subscription { + iced::Subscription::run_with( + SparkEventSubscriptionState { + client: self.clone(), + }, + make_spark_event_stream, + ) + } + + /// Gracefully shut down the bridge subprocess. After this returns + /// the client is no longer usable. + pub async fn shutdown(&self) -> Result<(), SparkClientError> { + if self + .inner + .closed + .swap(true, Ordering::SeqCst) + { + return Ok(()); + } + + // Best-effort: send Shutdown and wait up to 5s for the child + // to exit, otherwise kill it. + let shutdown_result = tokio::time::timeout( + Duration::from_secs(5), + self.request(Method::Shutdown), + ) + .await; + match shutdown_result { + Ok(Ok(_)) => {} + Ok(Err(e)) => warn!("Spark bridge shutdown RPC failed: {}", e), + Err(_) => warn!("Spark bridge shutdown RPC timed out"), + } + + let mut guard = self.inner.child.lock().await; + if let Some(mut child) = guard.take() { + match tokio::time::timeout(Duration::from_secs(2), child.wait()).await { + Ok(Ok(status)) => debug!("Spark bridge exited with status {}", status), + Ok(Err(e)) => warn!("failed to wait() for Spark bridge: {}", e), + Err(_) => { + warn!("Spark bridge did not exit within 2s, killing"); + let _ = child.kill().await; + } + } + } + Ok(()) + } + + /// Send a request and await its response. + /// + /// Wires up an oneshot channel in the pending map keyed by a fresh + /// monotonic id, pushes the [`Request`] through the writer channel, + /// and awaits the oneshot. Any error response is translated into + /// [`SparkClientError::BridgeError`]. + async fn request(&self, method: Method) -> Result { + if self + .inner + .closed + .load(Ordering::SeqCst) + { + return Err(SparkClientError::BridgeUnavailable( + "Spark client has been shut down".to_string(), + )); + } + + let id = self.inner.next_id.fetch_add(1, Ordering::SeqCst); + let (tx, rx) = oneshot::channel::(); + self.inner.pending.lock().await.insert(id, tx); + + let request = Request { id, method }; + if self.inner.request_tx.send(request).is_err() { + // Writer task exited — bridge is dead. + self.inner.pending.lock().await.remove(&id); + return Err(SparkClientError::BridgeUnavailable( + "Spark bridge writer task exited".to_string(), + )); + } + + // 30s is plenty for connect + info + list; longer timeouts can + // be plumbed per-method later if we add heavy RPCs. + let response = match tokio::time::timeout(Duration::from_secs(30), rx).await { + Ok(Ok(resp)) => resp, + Ok(Err(_)) => { + self.inner.pending.lock().await.remove(&id); + return Err(SparkClientError::BridgeUnavailable( + "Spark bridge reader closed the response channel".to_string(), + )); + } + Err(_) => { + self.inner.pending.lock().await.remove(&id); + return Err(SparkClientError::BridgeUnavailable(format!( + "Spark bridge did not respond within 30s (id={})", + id + ))); + } + }; + + match response.result { + ResponseResult::Ok(payload) => Ok(payload), + ResponseResult::Err(ErrorPayload { kind, message }) => { + Err(SparkClientError::BridgeError { kind, message }) + } + } + } +} + +impl Drop for SparkClient { + fn drop(&mut self) { + // Only the last `Arc` clone triggers real shutdown — checking + // strong_count here is cheap and avoids spamming shutdowns when + // panels clone the handle around. + if Arc::strong_count(&self.inner) > 1 { + return; + } + if self.inner.closed.swap(true, Ordering::SeqCst) { + return; + } + // Fire-and-forget: the writer task will drain the shutdown + // request if it's still alive, and `kill_on_drop(true)` on the + // Command ensures the child dies if the graceful path fails. + let _ = self + .inner + .request_tx + .send(Request { + id: u64::MAX, + method: Method::Shutdown, + }); + } +} + +// --------------------------------------------------------------------------- +// Bridge binary discovery +// --------------------------------------------------------------------------- + +/// Locate the `coincube-spark-bridge` executable. +/// +/// Precedence: +/// 1. `COINCUBE_SPARK_BRIDGE_PATH` env var (absolute path override). +/// 2. Sibling of the current executable, for packaged builds. +/// 3. Workspace `target/debug` / `target/release`, for `cargo run`. +fn resolve_bridge_path() -> Result { + if let Ok(override_path) = std::env::var("COINCUBE_SPARK_BRIDGE_PATH") { + let p = PathBuf::from(override_path); + if p.exists() { + return Ok(p); + } + return Err(SparkClientError::BridgeUnavailable(format!( + "COINCUBE_SPARK_BRIDGE_PATH={} does not exist", + p.display() + ))); + } + + let exe_name = if cfg!(windows) { + "coincube-spark-bridge.exe" + } else { + "coincube-spark-bridge" + }; + + if let Ok(current_exe) = std::env::current_exe() { + if let Some(dir) = current_exe.parent() { + let sibling = dir.join(exe_name); + if sibling.exists() { + return Ok(sibling); + } + } + } + + // Dev fallback: look relative to the workspace so `cargo run` works + // out of the box without copying the bridge binary. + let workspace_root = env!("CARGO_MANIFEST_DIR"); + for profile in ["debug", "release"] { + let candidate = PathBuf::from(workspace_root) + .join("..") + .join("coincube-spark-bridge") + .join("target") + .join(profile) + .join(exe_name); + if candidate.exists() { + return Ok(candidate); + } + } + + Err(SparkClientError::BridgeUnavailable(format!( + "could not locate {} — set COINCUBE_SPARK_BRIDGE_PATH or run `cargo build \ + --manifest-path coincube-spark-bridge/Cargo.toml` first", + exe_name + ))) +} + +// --------------------------------------------------------------------------- +// Background tasks +// --------------------------------------------------------------------------- + +fn spawn_writer_task( + mut stdin: tokio::process::ChildStdin, + mut request_rx: mpsc::UnboundedReceiver, +) { + tokio::spawn(async move { + while let Some(request) = request_rx.recv().await { + let frame = Frame::Request(request); + let line = match serde_json::to_string(&frame) { + Ok(s) => s, + Err(e) => { + error!("failed to serialize Spark bridge request: {}", e); + continue; + } + }; + if stdin.write_all(line.as_bytes()).await.is_err() + || stdin.write_all(b"\n").await.is_err() + || stdin.flush().await.is_err() + { + warn!("Spark bridge writer: stdin closed, draining remaining requests"); + break; + } + } + // Channel closed or stdin broken — task exits; rx is dropped + // implicitly, unblocking any pending recv() in the client. + }); +} + +fn spawn_reader_task( + stdout: tokio::process::ChildStdout, + pending: PendingMap, + event_tx: broadcast::Sender, +) { + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + loop { + match lines.next_line().await { + Ok(Some(line)) => { + if line.trim().is_empty() { + continue; + } + let frame: Frame = match serde_json::from_str(&line) { + Ok(f) => f, + Err(e) => { + warn!("Spark bridge emitted unparseable line: {} ({})", line, e); + continue; + } + }; + match frame { + Frame::Response(resp) => { + if let Some(sender) = pending.lock().await.remove(&resp.id) { + let _ = sender.send(resp); + } else { + warn!( + "Spark bridge response for unknown id {} — dropping", + resp.id + ); + } + } + Frame::Event(event) => { + // `broadcast::Sender::send` returns Err only + // when there are zero active receivers. + // That's fine in Phase 4d — if no panel has + // subscribed yet, the event is dropped + // on the floor. Log at debug so smoke + // tests can observe the flow. + debug!("Spark bridge event: {:?}", event); + let _ = event_tx.send(event); + } + Frame::Request(_) => { + warn!("Spark bridge sent a Request frame — ignoring"); + } + } + } + Ok(None) => { + debug!("Spark bridge stdout closed"); + break; + } + Err(e) => { + warn!("Spark bridge stdout read error: {}", e); + break; + } + } + } + }); +} + +fn spawn_stderr_task(stderr: tokio::process::ChildStderr) { + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + warn!(target: "spark_bridge", "{}", line); + } + }); +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub enum SparkClientError { + /// Missing / unparseable config (API key, storage dir). + Config(String), + /// Bridge subprocess couldn't be started or died unexpectedly. + BridgeUnavailable(String), + /// Bridge returned an error response for a request. + BridgeError { + kind: ErrorKind, + message: String, + }, + /// JSON-RPC framing error (malformed response, unexpected payload). + Protocol(String), +} + +impl std::fmt::Display for SparkClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Config(msg) => write!(f, "Spark config error: {}", msg), + Self::BridgeUnavailable(msg) => { + write!(f, "Spark bridge subprocess unavailable: {}", msg) + } + Self::BridgeError { kind, message } => { + write!(f, "Spark bridge returned {:?}: {}", kind, message) + } + Self::Protocol(msg) => write!(f, "Spark protocol error: {}", msg), + } + } +} + +impl std::error::Error for SparkClientError {} + +// --------------------------------------------------------------------------- +// Iced subscription for bridge events +// --------------------------------------------------------------------------- + +/// Domain wrapper around [`Event`] so the app-level [`crate::app::Message`] +/// doesn't need to depend on the protocol crate directly. +/// +/// Phase 4d just forwards the protocol variant as-is (zero translation +/// cost). Phase 4e / 5 can promote this to a typed enum if panels +/// start branching on event-specific data. +#[derive(Debug, Clone)] +pub struct SparkClientEvent(pub Event); + +/// Subscription identity — hashes on the broadcast sender's pointer +/// so a fresh `SparkClient` (e.g. after the user re-unlocks a cube) +/// produces a brand-new subscription instead of reusing the old one. +struct SparkEventSubscriptionState { + client: SparkClient, +} + +impl std::hash::Hash for SparkEventSubscriptionState { + fn hash(&self, state: &mut H) { + let ptr = Arc::as_ptr(&self.client.inner) as usize; + ptr.hash(state); + } +} + +/// Build the iced [`Stream`](iced::futures::Stream) that drains the +/// broadcast channel into iced's runtime. Uses `iced::stream::channel` +/// with a 100-slot buffer mirroring the Liquid subscription pattern. +fn make_spark_event_stream( + state: &SparkEventSubscriptionState, +) -> impl iced::futures::Stream { + let client = state.client.clone(); + iced::stream::channel( + 100, + move |mut output: iced::futures::channel::mpsc::Sender| async move { + let mut receiver = client.subscribe_events(); + loop { + match receiver.recv().await { + Ok(event) => { + use iced::futures::SinkExt; + if output.send(SparkClientEvent(event)).await.is_err() { + // iced runtime dropped the sink — time to + // stop pumping events for this subscription. + break; + } + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + warn!( + "Spark event subscription lagged by {} events, resuming", + skipped + ); + continue; + } + Err(broadcast::error::RecvError::Closed) => { + // Sender dropped — the SparkClient is gone. + // Park the task forever so iced keeps the + // Subscription id alive until the parent + // re-instantiates the state. + std::future::pending::<()>().await; + break; + } + } + } + std::future::pending::<()>().await; + }, + ) +} diff --git a/coincube-gui/src/app/breez_spark/config.rs b/coincube-gui/src/app/breez_spark/config.rs new file mode 100644 index 000000000..9195354fb --- /dev/null +++ b/coincube-gui/src/app/breez_spark/config.rs @@ -0,0 +1,84 @@ +//! Spark SDK configuration — read at **compile time** from env vars. +//! +//! Mirrors the pattern used by [`crate::app::breez_liquid::config`]: +//! the API key is baked into the binary via `env!(...)` so the packaged +//! app doesn't need a runtime `.env` to connect. Override the value by +//! setting `BREEZ_SPARK_API_KEY` (or `BREEZ_API_KEY`, which is the +//! fallback — the same issued Breez key currently covers both the Liquid +//! and Spark SDKs) before running `cargo build`. + +use coincube_spark_protocol::Network as ProtocolNetwork; +use coincube_core::miniscript::bitcoin; + +/// Resolved Spark configuration for a cube. +/// +/// The gui builds one of these at cube load time from the compile-time +/// API key and the cube's network / storage dir, then hands it to +/// [`crate::app::breez_spark::SparkClient::connect`] to drive the bridge +/// subprocess handshake. +#[derive(Debug, Clone)] +pub struct SparkConfig { + pub api_key: String, + pub network: ProtocolNetwork, + pub storage_dir: std::path::PathBuf, +} + +impl SparkConfig { + /// Build a Spark config for the given bitcoin network, pulling the + /// API key from the compile-time environment. + /// + /// Returns `Err` for networks Spark doesn't support (testnet/signet) + /// so callers can fall back to a disconnected placeholder, matching + /// the behavior of the Liquid loader for unsupported networks. + pub fn for_network( + network: bitcoin::Network, + storage_dir: std::path::PathBuf, + ) -> Result { + let api_key = api_key_from_env(); + let protocol_network = match network { + bitcoin::Network::Bitcoin => ProtocolNetwork::Mainnet, + bitcoin::Network::Regtest => ProtocolNetwork::Regtest, + other => return Err(SparkConfigError::UnsupportedNetwork(other)), + }; + + Ok(Self { + api_key, + network: protocol_network, + storage_dir, + }) + } +} + +/// The compile-time API key value — prefers `BREEZ_SPARK_API_KEY` over +/// the shared `BREEZ_API_KEY`. +/// +/// This is a free function rather than a `const` so a future refactor can +/// swap it to runtime lookup if we ever decide to un-bake the key from +/// the binary. +fn api_key_from_env() -> String { + // `option_env!` is a compile-time lookup, so these unwraps happen at + // build time — no runtime branch cost. If neither is set the baked + // string will be empty, which `SparkClient::connect` will reject + // with a clear error at handshake time. + option_env!("BREEZ_SPARK_API_KEY") + .or(option_env!("BREEZ_API_KEY")) + .unwrap_or("") + .to_string() +} + +#[derive(Debug, Clone)] +pub enum SparkConfigError { + UnsupportedNetwork(bitcoin::Network), +} + +impl std::fmt::Display for SparkConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnsupportedNetwork(n) => { + write!(f, "Spark wallet is not supported on {} network", n) + } + } + } +} + +impl std::error::Error for SparkConfigError {} diff --git a/coincube-gui/src/app/breez_spark/mod.rs b/coincube-gui/src/app/breez_spark/mod.rs new file mode 100644 index 000000000..484a5b63f --- /dev/null +++ b/coincube-gui/src/app/breez_spark/mod.rs @@ -0,0 +1,136 @@ +//! Spark wallet backend — gui-side surface. +//! +//! The actual [`breez-sdk-spark`] integration runs in a sibling binary +//! (`coincube-spark-bridge`) because the Liquid and Spark SDKs can't be +//! linked into the same process (incompatible `rusqlite` / `libsqlite3-sys` +//! dep graphs). See [`coincube-spark-bridge/Cargo.toml`] for the +//! accompanying standalone workspace. +//! +//! Public API: +//! - [`config::SparkConfig`]: reads `BREEZ_SPARK_API_KEY` (or falls back +//! to `BREEZ_API_KEY`) from env at build time. +//! - [`assets::SparkAsset`]: placeholder asset registry — BTC and +//! Lightning only for now. +//! - [`client::SparkClient`]: cloneable subprocess handle, async +//! methods for `get_info` / `list_payments` / `shutdown`. +//! - [`load_spark_client`]: convenience loader that reads the cube's +//! Spark [`HotSigner`] from disk, unlocks it with the user PIN, +//! builds a [`SparkConfig`], and hands the mnemonic to the bridge. + +pub mod assets; +pub mod client; +pub mod config; + +pub use assets::SparkAsset; +pub use client::{SparkClient, SparkClientError, SparkClientEvent}; +pub use config::{SparkConfig, SparkConfigError}; + +use std::path::Path; +use std::sync::Arc; + +use coincube_core::miniscript::bitcoin::{bip32::Fingerprint, Network}; +use coincube_core::signer::HotSigner; +use zeroize::Zeroizing; + +/// Load the Spark backend from the cube's datadir + fingerprint + PIN. +/// +/// Mirrors [`crate::app::breez_liquid::load_breez_client`]. The Spark +/// SDK needs the raw mnemonic on `connect`, so this function: +/// 1. loads the [`HotSigner`] from disk by fingerprint; +/// 2. decrypts its mnemonic with the supplied PIN; +/// 3. passes the mnemonic to the bridge subprocess via stdin; +/// 4. drops the mnemonic string (zeroized) as soon as the bridge +/// confirms init success. +/// +/// Returns [`SparkLoadError::NetworkNotSupported`] for non-mainnet/regtest +/// networks so the caller can skip Spark setup without hard-failing. +pub async fn load_spark_client( + datadir: &Path, + network: Network, + spark_signer_fingerprint: Fingerprint, + password: &str, +) -> Result, SparkLoadError> { + // Only mainnet and regtest are supported by the Spark SDK — testnet, + // testnet4 and signet are skipped here the same way as Liquid. + match network { + Network::Bitcoin | Network::Regtest => {} + _ => return Err(SparkLoadError::NetworkNotSupported(network)), + } + + // Load the specific signer by fingerprint, decrypting the mnemonic + // with the PIN. + let signer = HotSigner::from_datadir_by_fingerprint( + datadir, + network, + spark_signer_fingerprint, + Some(password), + ) + .map_err(|e| match e { + coincube_core::signer::SignerError::MnemonicStorage(io_err) + if io_err.kind() == std::io::ErrorKind::NotFound => + { + SparkLoadError::SignerNotFound(spark_signer_fingerprint) + } + _ => SparkLoadError::SignerError(e.to_string()), + })?; + + // Extract the mnemonic as a Zeroizing so the buffer is + // scrubbed after the bridge has accepted it. + let mnemonic: Zeroizing = Zeroizing::new(signer.mnemonic_str()); + + // Build the Spark storage dir under the cube's Spark subfolder so + // it doesn't collide with Liquid's sqlite file. + let storage_dir = datadir.join("spark"); + if let Err(e) = std::fs::create_dir_all(&storage_dir) { + return Err(SparkLoadError::Config(format!( + "failed to create Spark storage dir {}: {}", + storage_dir.display(), + e + ))); + } + + let config = SparkConfig::for_network(network, storage_dir) + .map_err(|e| SparkLoadError::Config(e.to_string()))?; + + let client = SparkClient::connect(config, mnemonic.as_str()) + .await + .map_err(SparkLoadError::from)?; + + // `mnemonic` Zeroizing is dropped here; the bridge has its + // own copy for the session lifetime. + Ok(Arc::new(client)) +} + +/// Error type for [`load_spark_client`]. +#[derive(Debug, Clone)] +pub enum SparkLoadError { + NetworkNotSupported(Network), + SignerNotFound(Fingerprint), + SignerError(String), + Config(String), + Client(SparkClientError), +} + +impl std::fmt::Display for SparkLoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NetworkNotSupported(n) => { + write!(f, "Spark wallet is not supported on {} network", n) + } + Self::SignerNotFound(fp) => { + write!(f, "Spark wallet signer not found for fingerprint: {}", fp) + } + Self::SignerError(msg) => write!(f, "Spark signer error: {}", msg), + Self::Config(msg) => write!(f, "Spark config error: {}", msg), + Self::Client(err) => write!(f, "Spark client error: {}", err), + } + } +} + +impl std::error::Error for SparkLoadError {} + +impl From for SparkLoadError { + fn from(value: SparkClientError) -> Self { + Self::Client(value) + } +} diff --git a/coincube-gui/src/app/cache.rs b/coincube-gui/src/app/cache.rs index 3568d8537..dd08f0c86 100644 --- a/coincube-gui/src/app/cache.rs +++ b/coincube-gui/src/app/cache.rs @@ -37,6 +37,8 @@ pub struct Cache { pub bitcoin_unit: BitcoinDisplayUnit, /// UI state: whether the Vault submenu is expanded pub vault_expanded: bool, + /// UI state: whether the Spark submenu is expanded + pub spark_expanded: bool, /// UI state: whether the Liquid submenu is expanded pub liquid_expanded: bool, /// UI state: whether the Marketplace submenu is expanded @@ -62,6 +64,17 @@ pub struct Cache { pub show_direction_badges: bool, /// Cached Lightning Address for display in the sidebar across all panels pub lightning_address: Option, + /// Id of the current Cube — needed by Spark Settings so the + /// `update_settings_file` closure can find the right cube when + /// persisting the `default_lightning_backend` picker change. + pub cube_id: String, + /// Current preference for which backend fulfills incoming + /// Lightning Address invoices. Mirrored from + /// `CubeSettings::default_lightning_backend` so panels can read + /// it without going through the disk layer; the authoritative + /// copy lives on `App::cube_settings` and is re-read on + /// `Message::SettingsSaved`. + pub default_lightning_backend: crate::app::wallets::WalletKind, } /// only used for tests. @@ -78,6 +91,7 @@ impl std::default::Default for Cache { fiat_price: None, bitcoin_unit: BitcoinDisplayUnit::default(), vault_expanded: true, + spark_expanded: false, liquid_expanded: false, marketplace_expanded: false, marketplace_p2p_expanded: false, @@ -90,6 +104,8 @@ impl std::default::Default for Cache { btc_usd_price: None, show_direction_badges: true, lightning_address: None, + cube_id: String::new(), + default_lightning_backend: crate::app::wallets::WalletKind::default(), } } } diff --git a/coincube-gui/src/app/menu.rs b/coincube-gui/src/app/menu.rs index 33d776537..8781924f9 100644 --- a/coincube-gui/src/app/menu.rs +++ b/coincube-gui/src/app/menu.rs @@ -3,6 +3,10 @@ use coincube_core::miniscript::bitcoin::{OutPoint, Txid}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Menu { Home, + /// Spark wallet — default for everyday Lightning UX (Phase 5 flips + /// the Lightning Address routing default here). Listed above Liquid + /// in the sidebar because it's the default wallet post-Phase 5. + Spark(SparkSubMenu), Liquid(LiquidSubMenu), Vault(VaultSubMenu), Marketplace(MarketplaceSubMenu), @@ -46,6 +50,21 @@ pub enum LiquidSubMenu { Settings(Option), } +/// Spark wallet sub-panels. +/// +/// Mirrors [`LiquidSubMenu`] on purpose — the Phase 4 plan is to copy +/// the Liquid panels into `state/spark/` and `view/spark/` and strip +/// the Liquid-only flows, so keeping the enum shape identical lets that +/// work land without menu churn. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SparkSubMenu { + Overview, + Send, + Receive, + Transactions(Option), + Settings(Option), +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum VaultSubMenu { Overview, diff --git a/coincube-gui/src/app/message.rs b/coincube-gui/src/app/message.rs index c17961be6..21173d4e1 100644 --- a/coincube-gui/src/app/message.rs +++ b/coincube-gui/src/app/message.rs @@ -10,7 +10,7 @@ use coincubed::config::Config as DaemonConfig; use crate::{ app::{ - breez::BreezError, + breez_liquid::BreezError, cache::{DaemonCache, FiatPrice}, error::Error, view, @@ -75,11 +75,17 @@ pub enum Message { BroadcastModal(Result, Error>), RbfModal(Box, bool, Result, Error>), Export(ImportExportMessage), - PaymentsLoaded(Result, BreezError>), - RefundablesLoaded(Result, BreezError>), + PaymentsLoaded(Result, BreezError>), + RefundablesLoaded(Result, BreezError>), RefundCompleted(Result), BreezInfo(Result), BreezEvent(breez_sdk_liquid::prelude::SdkEvent), + /// Forwarded from the [`coincube-spark-bridge`] subprocess via + /// `SparkBackend::event_subscription`. Wrapped in the + /// [`crate::app::breez_spark::SparkClientEvent`] newtype so the + /// app-level message doesn't depend on `coincube_spark_protocol` + /// directly. + SparkEvent(crate::app::breez_spark::SparkClientEvent), SettingsSaved, SettingsSaveFailed(Error), /// Store the Bitcoind handle produced by configure_and_start_internal_bitcoind so diff --git a/coincube-gui/src/app/mod.rs b/coincube-gui/src/app/mod.rs index d93ab21af..d6a8ac6ad 100644 --- a/coincube-gui/src/app/mod.rs +++ b/coincube-gui/src/app/mod.rs @@ -1,5 +1,7 @@ -pub mod breez; +pub mod breez_liquid; +pub mod breez_spark; pub mod cache; +pub mod wallets; pub mod config; pub mod error; pub mod menu; @@ -38,8 +40,9 @@ use wallet::{sync_status, SyncStatus}; use crate::{ app::{ - breez::BreezClient, + breez_liquid::BreezClient, cache::{Cache, DaemonCache}, + wallets::LiquidBackend, error::Error, menu::{MarketplaceSubMenu, Menu}, message::FiatMessage, @@ -60,6 +63,7 @@ use self::state::vault::settings::SettingsState as VaultSettingsState; struct Panels { current: Menu, vault_expanded: bool, + spark_expanded: bool, liquid_expanded: bool, marketplace_expanded: bool, marketplace_p2p_expanded: bool, @@ -71,6 +75,23 @@ struct Panels { liquid_receive: LiquidReceive, liquid_transactions: LiquidTransactions, liquid_settings: LiquidSettings, + /// Spark wallet Overview — Phase 3 placeholder. Always present so + /// `current()` / `current_mut()` have a target; internally the + /// panel checks whether the [`SparkBackend`] is wired and shows an + /// "unavailable" stub when it isn't. + spark_overview: state::SparkOverview, + /// Phase 4c ships real Send and Receive panels backed by the + /// bridge's new write-path RPCs (`prepare_send_payment`, + /// `send_payment`, `receive_payment`). LNURL-pay, Lightning Address + /// management, and the on-chain `claim_deposit` lifecycle are the + /// Phase 4d follow-ups. + spark_send: state::SparkSend, + spark_receive: state::SparkReceive, + /// Phase 4b ships real Transactions + Settings panels — they use + /// `list_payments` / `get_info` which the bridge already exposes, so + /// they ship ahead of the write-path flows. + spark_transactions: state::SparkTransactions, + spark_settings: state::SparkSettings, global_settings: GeneralSettingsState, // Vault-only panels - None when no vault exists vault_overview: Option, @@ -106,8 +127,10 @@ impl Panels { }) } + #[allow(clippy::too_many_arguments)] fn new_without_vault( breez_client: Arc, + spark_backend: Option>, wallet: Option>, datadir: &CoincubeDirectory, network: bitcoin::Network, @@ -119,36 +142,43 @@ impl Panels { // The UI layer prevents navigation to vault panels when has_vault=false let default_fiat_currency = Self::default_fiat_currency(datadir, network, &cube_id); + let liquid_backend = Arc::new(LiquidBackend::new(breez_client.clone())); Self { current: Menu::Home, vault_expanded: false, + spark_expanded: false, liquid_expanded: false, marketplace_expanded: false, marketplace_p2p_expanded: false, connect_expanded: false, - // Liquid panels always available (use BreezClient, not Vault wallet) + // Liquid panels always available (use LiquidBackend, not Vault wallet) global_home: if let Some(w) = &wallet { GlobalHome::new( w.clone(), - breez_client.clone(), + liquid_backend.clone(), datadir.clone(), network, cube_id.clone(), ) } else { GlobalHome::new_without_wallet( - breez_client.clone(), + liquid_backend.clone(), datadir.clone(), network, cube_id.clone(), ) }, - liquid_overview: LiquidOverview::new(breez_client.clone()), - liquid_send: LiquidSend::new(breez_client.clone()), - liquid_receive: LiquidReceive::new(breez_client.clone()), - liquid_transactions: LiquidTransactions::new(breez_client.clone()), - liquid_settings: LiquidSettings::new(breez_client.clone()), + liquid_overview: LiquidOverview::new(liquid_backend.clone()), + liquid_send: LiquidSend::new(liquid_backend.clone()), + liquid_receive: LiquidReceive::new(liquid_backend.clone()), + liquid_transactions: LiquidTransactions::new(liquid_backend.clone()), + liquid_settings: LiquidSettings::new(liquid_backend.clone()), + spark_overview: state::SparkOverview::new(spark_backend.clone()), + spark_send: state::SparkSend::new(spark_backend.clone()), + spark_receive: state::SparkReceive::new(spark_backend.clone()), + spark_transactions: state::SparkTransactions::new(spark_backend.clone()), + spark_settings: state::SparkSettings::new(spark_backend.clone()), global_settings: { let network_dir = datadir.network_directory(network); let settings_file = settings::Settings::from_file(&network_dir).ok(); @@ -199,6 +229,7 @@ impl Panels { #[allow(clippy::too_many_arguments)] fn new( breez_client: Arc, + spark_backend: Option>, cache: &Cache, wallet: Arc, data_dir: CoincubeDirectory, @@ -219,17 +250,19 @@ impl Panels { .unwrap_or(true); let default_fiat_currency = Self::default_fiat_currency(&data_dir, cache.network, &cube_id); + let liquid_backend = Arc::new(LiquidBackend::new(breez_client.clone())); Self { current: Menu::Home, vault_expanded: false, + spark_expanded: false, liquid_expanded: false, marketplace_expanded: false, marketplace_p2p_expanded: false, connect_expanded: false, global_home: GlobalHome::new( wallet.clone(), - breez_client.clone(), + liquid_backend.clone(), data_dir.clone(), cache.network, cube_id.clone(), @@ -247,11 +280,16 @@ impl Panels { cache.blockheight(), show_rescan_warning, )), - liquid_overview: LiquidOverview::new(breez_client.clone()), - liquid_send: LiquidSend::new(breez_client.clone()), - liquid_receive: LiquidReceive::new(breez_client.clone()), - liquid_transactions: LiquidTransactions::new(breez_client.clone()), - liquid_settings: LiquidSettings::new(breez_client.clone()), + liquid_overview: LiquidOverview::new(liquid_backend.clone()), + liquid_send: LiquidSend::new(liquid_backend.clone()), + liquid_receive: LiquidReceive::new(liquid_backend.clone()), + liquid_transactions: LiquidTransactions::new(liquid_backend.clone()), + liquid_settings: LiquidSettings::new(liquid_backend.clone()), + spark_overview: state::SparkOverview::new(spark_backend.clone()), + spark_send: state::SparkSend::new(spark_backend.clone()), + spark_receive: state::SparkReceive::new(spark_backend.clone()), + spark_transactions: state::SparkTransactions::new(spark_backend.clone()), + spark_settings: state::SparkSettings::new(spark_backend.clone()), global_settings: { let network_dir = data_dir.network_directory(cache.network); let settings_file = settings::Settings::from_file(&network_dir).ok(); @@ -437,6 +475,24 @@ impl Panels { crate::app::menu::LiquidSubMenu::Transactions(_) => Some(&self.liquid_transactions), crate::app::menu::LiquidSubMenu::Settings(_) => Some(&self.liquid_settings), }, + // Phase 4c ships all five real Spark panels. Send/Receive + // use the bridge write-path RPCs added in this phase; + // Overview/Transactions/Settings are unchanged from 4b. + Menu::Spark(submenu) => match submenu { + crate::app::menu::SparkSubMenu::Overview => { + Some(&self.spark_overview as &dyn State) + } + crate::app::menu::SparkSubMenu::Send => Some(&self.spark_send as &dyn State), + crate::app::menu::SparkSubMenu::Receive => { + Some(&self.spark_receive as &dyn State) + } + crate::app::menu::SparkSubMenu::Transactions(_) => { + Some(&self.spark_transactions as &dyn State) + } + crate::app::menu::SparkSubMenu::Settings(_) => { + Some(&self.spark_settings as &dyn State) + } + }, Menu::Vault(submenu) => match submenu { crate::app::menu::VaultSubMenu::Overview => { self.vault_overview.as_ref().map(|v| v as &dyn State) @@ -486,6 +542,23 @@ impl Panels { } crate::app::menu::LiquidSubMenu::Settings(_) => Some(&mut self.liquid_settings), }, + Menu::Spark(submenu) => match submenu { + crate::app::menu::SparkSubMenu::Overview => { + Some(&mut self.spark_overview as &mut dyn State) + } + crate::app::menu::SparkSubMenu::Send => { + Some(&mut self.spark_send as &mut dyn State) + } + crate::app::menu::SparkSubMenu::Receive => { + Some(&mut self.spark_receive as &mut dyn State) + } + crate::app::menu::SparkSubMenu::Transactions(_) => { + Some(&mut self.spark_transactions as &mut dyn State) + } + crate::app::menu::SparkSubMenu::Settings(_) => { + Some(&mut self.spark_settings as &mut dyn State) + } + }, Menu::Vault(submenu) => match submenu { crate::app::menu::VaultSubMenu::Overview => { self.vault_overview.as_mut().map(|v| v as &mut dyn State) @@ -556,6 +629,14 @@ pub struct App { cache: Cache, wallet: Option>, breez_client: Arc, + /// Wallet registry — owns the concrete wallet backends and exposes + /// routing hooks. Holds a [`LiquidBackend`] and an optional + /// [`SparkBackend`] (present when the cube has a Spark signer and + /// the bridge subprocess came up). Phase 5 reads + /// [`WalletRegistry::spark`] from the LNURL subscription hand-off + /// so incoming Lightning Address invoices route through Spark + /// when the cube's `default_lightning_backend` prefers it. + wallet_registry: crate::app::wallets::WalletRegistry, daemon: Option>, internal_bitcoind: Option, cube_settings: settings::CubeSettings, @@ -653,6 +734,7 @@ impl App { cache: Cache, wallet: Arc, breez_client: Arc, + spark_backend: Option>, config: Config, daemon: Arc, data_dir: CoincubeDirectory, @@ -661,9 +743,15 @@ impl App { cube_settings: settings::CubeSettings, ) -> (App, Task) { let config_arc = Arc::new(config); + let liquid_backend = Arc::new(LiquidBackend::new(breez_client.clone())); + let wallet_registry = crate::app::wallets::WalletRegistry::with_spark( + liquid_backend.clone(), + spark_backend.clone(), + ); let mut panels = Panels::new( breez_client.clone(), + spark_backend.clone(), &cache, wallet.clone(), data_dir.clone(), @@ -697,6 +785,7 @@ impl App { daemon: Some(daemon), wallet: Some(wallet), breez_client, + wallet_registry, internal_bitcoind, cube_settings, config: config_arc, @@ -722,12 +811,18 @@ impl App { pub fn new_without_wallet( breez_client: Arc, + spark_backend: Option>, config: Config, datadir: CoincubeDirectory, network: coincube_core::miniscript::bitcoin::Network, cube_settings: settings::CubeSettings, ) -> (App, Task) { let config_arc = Arc::new(config); + let liquid_backend = Arc::new(LiquidBackend::new(breez_client.clone())); + let wallet_registry = crate::app::wallets::WalletRegistry::with_spark( + liquid_backend.clone(), + spark_backend.clone(), + ); // Load bitcoin_unit from cube settings if available let bitcoin_unit = { let network_dir = datadir.network_directory(network); @@ -747,11 +842,14 @@ impl App { has_vault: false, bitcoin_unit, cube_name: cube_settings.name.clone(), + cube_id: cube_settings.id.clone(), + default_lightning_backend: cube_settings.default_lightning_backend, ..Default::default() }; let mut panels = Panels::new_without_vault( breez_client.clone(), + spark_backend.clone(), None, &datadir, network, @@ -771,6 +869,7 @@ impl App { daemon: None, wallet: None, breez_client, + wallet_registry, internal_bitcoind: None, cube_settings, config: config_arc, @@ -1040,6 +1139,18 @@ impl App { // Always subscribe to Breez events (handles fee acceptance globally) subscriptions.push(self.breez_client.subscription().map(Message::BreezEvent)); + // Subscribe to Spark bridge events when a Spark backend is + // active. The backend is optional (cubes without a Spark signer + // run with `wallet_registry.spark() == None`), so we only wire + // the subscription when there's actually a bridge to listen to. + // The subscription identity is keyed on the `Arc` + // pointer inside the backend, so reconnecting produces a fresh + // subscription instead of stale wiring. + if let Some(spark_backend) = self.wallet_registry.spark() { + subscriptions + .push(spark_backend.event_subscription().map(Message::SparkEvent)); + } + // Only create tick subscription if we have a vault (daemon exists) if self.daemon.is_some() { subscriptions.push( @@ -1086,6 +1197,8 @@ impl App { token.to_string(), self.lnurl_sse_retries, self.breez_client.clone(), + self.wallet_registry.spark().cloned(), + self.cube_settings.default_lightning_backend, ) .map(Message::Lnurl), ); @@ -1409,6 +1522,9 @@ impl App { { self.cache.bitcoin_unit = cube.unit_setting.display_unit; self.cube_settings.fiat_price = cube.fiat_price.clone(); + self.cube_settings.default_lightning_backend = + cube.default_lightning_backend; + self.cache.default_lightning_backend = cube.default_lightning_backend; // Clear cached fiat display price if disabled. // Note: btc_usd_price is NOT cleared — it's needed for @@ -1635,6 +1751,25 @@ impl App { self.cache.vault_expanded = self.panels.vault_expanded; if self.panels.vault_expanded { + self.panels.spark_expanded = false; + self.cache.spark_expanded = false; + self.panels.liquid_expanded = false; + self.cache.liquid_expanded = false; + self.panels.connect_expanded = false; + self.cache.connect_expanded = false; + self.panels.marketplace_expanded = false; + self.cache.marketplace_expanded = false; + self.panels.marketplace_p2p_expanded = false; + self.cache.marketplace_p2p_expanded = false; + } + } + Message::View(view::Message::ToggleSpark) => { + self.panels.spark_expanded = !self.panels.spark_expanded; + self.cache.spark_expanded = self.panels.spark_expanded; + + if self.panels.spark_expanded { + self.panels.vault_expanded = false; + self.cache.vault_expanded = false; self.panels.liquid_expanded = false; self.cache.liquid_expanded = false; self.panels.connect_expanded = false; @@ -1652,6 +1787,8 @@ impl App { if self.panels.liquid_expanded { self.panels.vault_expanded = false; self.cache.vault_expanded = false; + self.panels.spark_expanded = false; + self.cache.spark_expanded = false; self.panels.connect_expanded = false; self.cache.connect_expanded = false; self.panels.marketplace_expanded = false; @@ -1667,6 +1804,8 @@ impl App { if self.panels.marketplace_expanded { self.panels.vault_expanded = false; self.cache.vault_expanded = false; + self.panels.spark_expanded = false; + self.cache.spark_expanded = false; self.panels.liquid_expanded = false; self.cache.liquid_expanded = false; self.panels.connect_expanded = false; @@ -1687,6 +1826,8 @@ impl App { if self.panels.connect_expanded { self.panels.vault_expanded = false; self.cache.vault_expanded = false; + self.panels.spark_expanded = false; + self.cache.spark_expanded = false; self.panels.liquid_expanded = false; self.cache.liquid_expanded = false; self.panels.marketplace_expanded = false; @@ -1755,6 +1896,65 @@ impl App { .update(self.daemon.clone(), &self.cache, msg); } + Message::SparkEvent(client_event) => { + use coincube_spark_protocol::Event as SparkEvent; + let crate::app::breez_spark::SparkClientEvent(event) = client_event; + log::info!("App received Spark event: {:?}", event); + + let mut tasks: Vec> = Vec::new(); + + // Refresh Spark Overview on every event — balance + // moves on any payment state change, and `Synced` + // ticks are the SDK's "you're up to date, re-read + // state" signal. Deposits being claimed counts as a + // balance change too. + tasks.push(self.panels.spark_overview.reload(None, None)); + + // Payment-related events reload the Transactions list + // so newly surfaced rows appear without the user + // manually navigating / pressing refresh. `Synced` + // and `DepositsChanged` alone don't imply new + // payment-list rows. + if matches!( + event, + SparkEvent::PaymentSucceeded { .. } + | SparkEvent::PaymentPending { .. } + | SparkEvent::PaymentFailed { .. } + ) { + tasks.push(self.panels.spark_transactions.reload(None, None)); + } + + match event { + SparkEvent::PaymentSucceeded { + amount_sat, bolt11, .. + } => { + // Phase 4f: forward the BOLT11 field so the + // Receive panel can correlate against its + // currently displayed invoice. + tasks.push(Task::done(Message::View( + view::Message::SparkReceive( + view::SparkReceiveMessage::PaymentReceived { + amount_sat, + bolt11, + }, + ), + ))); + } + SparkEvent::DepositsChanged => { + // Phase 4f: refresh the Receive panel's + // pending deposits card. The panel handles + // the actual `list_unclaimed_deposits` RPC + // dispatch. + tasks.push(Task::done(Message::View(view::Message::SparkReceive( + view::SparkReceiveMessage::DepositsChanged, + )))); + } + _ => {} + } + + return Task::batch(tasks); + } + Message::BreezEvent(event) => { use breez_sdk_liquid::prelude::{PaymentDetails, PaymentType, SdkEvent}; log::info!("App received Breez Event: {:?}", event); diff --git a/coincube-gui/src/app/settings/mod.rs b/coincube-gui/src/app/settings/mod.rs index 4a5ceddb0..8ec5fd87b 100644 --- a/coincube-gui/src/app/settings/mod.rs +++ b/coincube-gui/src/app/settings/mod.rs @@ -153,8 +153,34 @@ pub struct CubeSettings { /// Optional security PIN (stored as Argon2id hash with salt in PHC format) #[serde(skip_serializing_if = "Option::is_none")] pub security_pin_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub liquid_wallet_signer_fingerprint: Option, + /// Fingerprint of the [`HotSigner`] whose mnemonic drives both the + /// Liquid and Spark wallets for this cube. Both Breez SDKs derive + /// their wallet keys at different BIP-32 paths from the same seed, + /// so reusing one HotSigner is safe and keeps the "one mnemonic, + /// one PIN, one backup" UX intact. When `None`, neither Breez + /// wallet is configured and the `WalletRegistry` loads with + /// Liquid unavailable and `spark = None`. + /// + /// Serde `alias` lets us transparently deserialize cubes written + /// before the pre-Phase-8 rename (when this field was named + /// `liquid_wallet_signer_fingerprint`). The alias can be removed + /// once every in-the-wild cube has been re-saved at least once + /// under the new name. + /// + /// [`HotSigner`]: coincube_core::signer::HotSigner + /// [`WalletRegistry`]: crate::app::wallets::WalletRegistry + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "liquid_wallet_signer_fingerprint" + )] + pub breez_wallet_signer_fingerprint: Option, + /// Which backend should fulfill incoming Lightning Address invoices + /// for this cube. Starts as `Liquid` (backwards-compatible default + /// for existing cubes); Phase 5 flips the default to `Spark` and + /// adds a per-cube override in Settings. + #[serde(default)] + pub default_lightning_backend: crate::app::wallets::WalletKind, /// Bitcoin display unit preference for this cube #[serde(default)] pub unit_setting: unit::UnitSetting, @@ -185,7 +211,8 @@ impl CubeSettings { created_at: chrono::Utc::now().timestamp(), vault_wallet_id: None, security_pin_hash: None, - liquid_wallet_signer_fingerprint: None, + breez_wallet_signer_fingerprint: None, + default_lightning_backend: crate::app::wallets::WalletKind::default(), backed_up: false, mfa_done: false, remote_synced: false, @@ -204,8 +231,8 @@ impl CubeSettings { self } - pub fn with_liquid_signer(mut self, fingerprint: Fingerprint) -> Self { - self.liquid_wallet_signer_fingerprint = Some(fingerprint); + pub fn with_breez_signer(mut self, fingerprint: Fingerprint) -> Self { + self.breez_wallet_signer_fingerprint = Some(fingerprint); self } diff --git a/coincube-gui/src/app/state/connect/cube.rs b/coincube-gui/src/app/state/connect/cube.rs index 6a32adfc9..81869fdf3 100644 --- a/coincube-gui/src/app/state/connect/cube.rs +++ b/coincube-gui/src/app/state/connect/cube.rs @@ -5,7 +5,7 @@ use iced::task::Handle as TaskHandle; use crate::{ app::{ - breez::BreezClient, + breez_liquid::BreezClient, message::Message, view::{self, ConnectCubeMessage}, }, diff --git a/coincube-gui/src/app/state/connect/mod.rs b/coincube-gui/src/app/state/connect/mod.rs index 8aac3011a..68b18036a 100644 --- a/coincube-gui/src/app/state/connect/mod.rs +++ b/coincube-gui/src/app/state/connect/mod.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use crate::{ app::{ - breez::BreezClient, + breez_liquid::BreezClient, cache::Cache, menu::Menu, message::Message, diff --git a/coincube-gui/src/app/state/global_home.rs b/coincube-gui/src/app/state/global_home.rs index 424bb3411..c229966c6 100644 --- a/coincube-gui/src/app/state/global_home.rs +++ b/coincube-gui/src/app/state/global_home.rs @@ -7,8 +7,9 @@ use breez_sdk_liquid::model::{ PayOnchainRequest, PaymentDetails, PaymentType, PreparePayOnchainRequest, PreparePayOnchainResponse, }; -use breez_sdk_liquid::prelude::PaymentState; use coincube_core::miniscript::bitcoin::{bip32::ChildNumber, Address, Amount}; + +use crate::app::wallets::{DomainPaymentDetails, DomainPaymentStatus}; use coincube_ui::component::amount::BitcoinDisplayUnit; use coincube_ui::component::form; use coincube_ui::widget::*; @@ -17,7 +18,7 @@ use std::time::Duration; use super::vault::psbt::SignModal; use super::{Cache, Menu, State}; -use crate::app::breez::BreezClient; +use crate::app::wallets::LiquidBackend; use crate::app::state::vault::label::LabelsEdited; use crate::app::state::vault::receive::ShowQrCodeModal; use crate::app::view::global_home::{ @@ -67,7 +68,7 @@ impl Labelled for ReceiveAddressInfo { #[derive(Debug)] pub struct GlobalHome { - breez_client: Arc, + breez_client: Arc, liquid_balance: Amount, usdt_balance: u64, usdt_balance_error: bool, @@ -103,7 +104,7 @@ pub struct GlobalHome { impl GlobalHome { pub fn new( wallet: Arc, - breez_client: Arc, + breez_client: Arc, datadir_path: CoincubeDirectory, network: coincube_core::miniscript::bitcoin::Network, cube_id: String, @@ -144,7 +145,7 @@ impl GlobalHome { } pub fn new_without_wallet( - breez_client: Arc, + breez_client: Arc, datadir_path: CoincubeDirectory, network: coincube_core::miniscript::bitcoin::Network, cube_id: String, @@ -1106,7 +1107,7 @@ impl GlobalHome { } fn load_pending_sends(&self) -> Task { - use crate::app::breez::assets::{asset_kind_for_id, AssetKind, USDT_PRECISION}; + use crate::app::breez_liquid::assets::{asset_kind_for_id, AssetKind}; let breez_client = self.breez_client.clone(); let network = self.network; Task::perform( @@ -1118,15 +1119,12 @@ impl GlobalHome { let mut liquid_receive_sats: u64 = 0; let mut usdt_receive_sats: u64 = 0; for payment in &payments { - if !matches!(payment.status, PaymentState::Pending) { + if !matches!(payment.status, DomainPaymentStatus::Pending) { continue; } - let is_send = matches!( - payment.payment_type, - breez_sdk_liquid::prelude::PaymentType::Send - ); + let is_send = !payment.is_incoming(); match &payment.details { - PaymentDetails::Liquid { + DomainPaymentDetails::LiquidAsset { asset_id, asset_info, .. @@ -1135,11 +1133,7 @@ impl GlobalHome { { let minor = asset_info .as_ref() - .map(|ai| { - (ai.amount * 10_f64.powi(USDT_PRECISION as i32)) - .round() - as u64 - }) + .map(|ai| ai.amount_minor) .unwrap_or(payment.amount_sat); if is_send { usdt_send_sats = usdt_send_sats.saturating_add(minor); @@ -1188,7 +1182,7 @@ impl GlobalHome { } fn load_usdt_balance(&self) -> Task { - use crate::app::breez::assets::{asset_kind_for_id, AssetKind}; + use crate::app::breez_liquid::assets::{asset_kind_for_id, AssetKind}; let breez_client = self.breez_client.clone(); let network = self.network; Task::perform( @@ -1288,15 +1282,19 @@ impl GlobalHome { if let Some(payment) = payments.and_then(|ps| { ps.into_iter().find(|payment| { - matches!(payment.payment_type, PaymentType::Send) - && matches!( - &payment.details, - PaymentDetails::Bitcoin { swap_id, .. } if swap_id == &pending.swap_id - ) + if payment.is_incoming() { + return false; + } + match &payment.details { + DomainPaymentDetails::OnChainBitcoin { + swap_id: Some(id), .. + } => id == &pending.swap_id, + _ => false, + } }) }) { stage = match payment.status { - PaymentState::Complete => { + DomainPaymentStatus::Complete => { let cube_id_for_clear = cube_id.clone(); let _ = settings::update_settings_file(&network_dir, move |mut current| { if let Some(cube) = current @@ -1311,20 +1309,20 @@ impl GlobalHome { .await; return None; } - PaymentState::Pending | PaymentState::WaitingFeeAcceptance => { + DomainPaymentStatus::Pending | DomainPaymentStatus::WaitingFeeAcceptance => { match payment.details { - PaymentDetails::Bitcoin { + DomainPaymentDetails::OnChainBitcoin { claim_tx_id: Some(_), .. } => IncomingTransferStage::SendingToVault, _ => IncomingTransferStage::SwappingLbtcToBtc, } } - PaymentState::Created => IncomingTransferStage::TransferInitiated, - PaymentState::Failed - | PaymentState::TimedOut - | PaymentState::Refundable - | PaymentState::RefundPending => { + DomainPaymentStatus::Created => IncomingTransferStage::TransferInitiated, + DomainPaymentStatus::Failed + | DomainPaymentStatus::TimedOut + | DomainPaymentStatus::Refundable + | DomainPaymentStatus::RefundPending => { let cube_id_for_clear = cube_id.clone(); let _ = settings::update_settings_file(&network_dir, move |mut current| { if let Some(cube) = current diff --git a/coincube-gui/src/app/state/liquid/overview.rs b/coincube-gui/src/app/state/liquid/overview.rs index fa0586b10..686a42280 100644 --- a/coincube-gui/src/app/state/liquid/overview.rs +++ b/coincube-gui/src/app/state/liquid/overview.rs @@ -1,33 +1,32 @@ use std::convert::TryInto; use std::sync::Arc; -use breez_sdk_liquid::model::PaymentDetails; -use breez_sdk_liquid::prelude::Payment; use coincube_core::miniscript::bitcoin::Amount; use coincube_ui::widget::*; use iced::Task; -use crate::app::breez::assets::usdt_asset_id; +use crate::app::breez_liquid::assets::usdt_asset_id; use crate::app::menu::{LiquidSubMenu, Menu}; use crate::app::state::{redirect, State}; -use crate::app::{breez::BreezClient, cache::Cache}; +use crate::app::wallets::{DomainPayment, DomainPaymentDetails, LiquidBackend}; +use crate::app::cache::Cache; use crate::app::{message::Message, view, wallet::Wallet}; use crate::daemon::Daemon; use crate::utils::format_time_ago; /// LiquidOverview pub struct LiquidOverview { - breez_client: Arc, + breez_client: Arc, btc_balance: Amount, usdt_balance: u64, recent_transaction: Vec, - recent_payments: Vec, - selected_payment: Option, + recent_payments: Vec, + selected_payment: Option, error: Option, } impl LiquidOverview { - pub fn new(breez_client: Arc) -> Self { + pub fn new(breez_client: Arc) -> Self { Self { breez_client, btc_balance: Amount::from_sat(0), @@ -202,12 +201,11 @@ impl State for LiquidOverview { self.btc_balance = *balance; self.usdt_balance = *usdt_balance; - let recent: Vec = recent_payment.iter().take(5).cloned().collect(); + let recent: Vec = + recent_payment.iter().take(5).cloned().collect(); self.recent_payments = recent.clone(); - let usdt_id = - crate::app::breez::assets::usdt_asset_id(self.breez_client.network()) - .unwrap_or(""); + let usdt_id = usdt_asset_id(self.breez_client.network()).unwrap_or(""); if !recent.is_empty() { let fiat_converter: Option = @@ -218,35 +216,24 @@ impl State for LiquidOverview { let status = payment.status; let time_ago = format_time_ago(payment.timestamp.into()); - // Detect USDt payments and build display string - let is_usdt = matches!( - &payment.details, - PaymentDetails::Liquid { asset_id, .. } - if !usdt_id.is_empty() && asset_id == usdt_id - ); - - // For USDt, extract the correct amount from asset_info - // (amount_sat is BTC-denominated and wrong for USDt). - let amount = if is_usdt { - if let PaymentDetails::Liquid { - asset_info: Some(ref info), + // Detect USDt payments and build display string. + let usdt_amount_minor = match &payment.details { + DomainPaymentDetails::LiquidAsset { + asset_id, + asset_info, .. - } = &payment.details - { - Amount::from_sat( - (info.amount - * 10_f64.powi( - crate::app::breez::assets::USDT_PRECISION - as i32, - )) - .round() - as u64, - ) - } else { - Amount::from_sat(payment.amount_sat) + } if !usdt_id.is_empty() && asset_id == usdt_id => { + asset_info.as_ref().map(|i| i.amount_minor) } - } else { - Amount::from_sat(payment.amount_sat) + _ => None, + }; + let is_usdt = usdt_amount_minor.is_some(); + + // For USDt, display the asset amount; otherwise display + // the BTC amount from `amount_sat`. + let amount = match usdt_amount_minor { + Some(minor) => Amount::from_sat(minor), + None => Amount::from_sat(payment.amount_sat), }; // Only compute fiat for BTC rows; USDt has its own display. @@ -263,38 +250,16 @@ impl State for LiquidOverview { "USDt Transfer".to_owned(), Some(format!( "{} USDt", - crate::app::breez::assets::format_usdt_display( + crate::app::breez_liquid::assets::format_usdt_display( amount.to_sat() ) )), ) } else { - let d: &str = match &payment.details { - PaymentDetails::Lightning { - payer_note, - description, - .. - } => payer_note - .as_ref() - .filter(|s| !s.is_empty()) - .unwrap_or(description), - PaymentDetails::Liquid { - payer_note, - description, - .. - } => payer_note - .as_ref() - .filter(|s| !s.is_empty()) - .unwrap_or(description), - PaymentDetails::Bitcoin { description, .. } => description, - }; - (d.to_owned(), None) + (payment.details.description().to_owned(), None) }; - let is_incoming = matches!( - payment.payment_type, - breez_sdk_liquid::prelude::PaymentType::Receive - ); + let is_incoming = payment.is_incoming(); let details = payment.details.clone(); let fees_sat = Amount::from_sat(payment.fees_sat); view::liquid::RecentTransaction { diff --git a/coincube-gui/src/app/state/liquid/receive.rs b/coincube-gui/src/app/state/liquid/receive.rs index 19b8086c7..aae26ff2c 100644 --- a/coincube-gui/src/app/state/liquid/receive.rs +++ b/coincube-gui/src/app/state/liquid/receive.rs @@ -2,15 +2,13 @@ use std::convert::TryInto; use std::sync::Arc; use std::time::Duration; -use breez_sdk_liquid::model::PaymentDetails; -use breez_sdk_liquid::prelude::{Payment, PaymentState}; use coincube_core::miniscript::bitcoin::{Amount, Denomination}; use coincube_ui::component::form; use coincube_ui::widget::*; use iced::{clipboard, widget::qr_code, Subscription, Task}; use super::sideshift_receive::SideshiftReceiveFlow; -use crate::app::breez::assets::{ +use crate::app::breez_liquid::assets::{ format_usdt_display, parse_asset_to_minor_units, usdt_asset_id, USDT_PRECISION, }; use crate::app::menu::LiquidSubMenu; @@ -18,13 +16,31 @@ use crate::app::settings::unit::BitcoinDisplayUnit; use crate::app::state::liquid::send::SendAsset; use crate::app::state::redirect; use crate::app::view::{LiquidReceiveMessage, ReceiveMethod, SenderNetwork}; -use crate::app::{breez::BreezClient, cache::Cache, menu::Menu, state::State}; +use crate::app::wallets::{ + DomainPayment, DomainPaymentDetails, DomainPaymentStatus, LiquidBackend, +}; + +/// Return the base-unit USDt amount carried by `details`, if the payment is a +/// Liquid payment for the given `usdt_id`. +fn usdt_amount_minor(details: &DomainPaymentDetails, usdt_id: &str) -> Option { + match details { + DomainPaymentDetails::LiquidAsset { + asset_id, + asset_info, + .. + } if !usdt_id.is_empty() && asset_id == usdt_id => { + asset_info.as_ref().map(|i| i.amount_minor) + } + _ => None, + } +} +use crate::app::{cache::Cache, menu::Menu, state::State}; use crate::app::{message::Message, view, wallet::Wallet}; use crate::daemon::Daemon; use crate::utils::format_time_ago; pub struct LiquidReceive { - breez_client: Arc, + breez_client: Arc, receive_method: ReceiveMethod, sideshift_flow: Option, /// Asset the user wants to receive into their wallet. @@ -53,7 +69,7 @@ pub struct LiquidReceive { btc_balance: Amount, usdt_balance: u64, recent_transaction: Vec, - recent_payments: Vec, + recent_payments: Vec, show_qr_modal: bool, /// Show the "Payment received!" celebration screen. show_received_celebration: bool, @@ -63,8 +79,8 @@ pub struct LiquidReceive { } impl LiquidReceive { - /// Returns a clone of the inner `Arc`. - pub fn breez_client_arc(&self) -> Arc { + /// Returns a clone of the inner `Arc`. + pub fn breez_client_arc(&self) -> Arc { self.breez_client.clone() } @@ -84,7 +100,7 @@ impl LiquidReceive { self.sender_picker_open } - pub fn new(breez_client: Arc) -> Self { + pub fn new(breez_client: Arc) -> Self { Self { breez_client, receive_method: ReceiveMethod::Lightning, @@ -125,7 +141,7 @@ impl LiquidReceive { } async fn generate_lightning_invoice( - client: Arc, + client: Arc, amount: Amount, description: Option, ) -> Result { @@ -137,7 +153,7 @@ impl LiquidReceive { Ok(response.destination) } - async fn generate_onchain_address(client: Arc) -> Result { + async fn generate_onchain_address(client: Arc) -> Result { let response = client .receive_onchain(None) .await @@ -146,7 +162,7 @@ impl LiquidReceive { Ok(response.destination) } - async fn generate_liquid_address(client: Arc) -> Result { + async fn generate_liquid_address(client: Arc) -> Result { let response = client.receive_liquid().await.map_err(|e| e.to_string())?; Ok(response.destination) @@ -597,14 +613,10 @@ impl State for LiquidReceive { let receive_usdt = self.receive_asset == SendAsset::Usdt; // Filter payments by receive asset, matching Send behavior - let filtered: Vec = recent_payment + let filtered: Vec = recent_payment .into_iter() .filter(|p| { - let is_usdt = matches!( - &p.details, - PaymentDetails::Liquid { asset_id, .. } - if !usdt_id.is_empty() && asset_id == usdt_id - ); + let is_usdt = usdt_amount_minor(&p.details, usdt_id).is_some(); if receive_usdt { is_usdt } else { @@ -623,30 +635,14 @@ impl State for LiquidReceive { if Some(&payment.tx_id) == prev_head_tx_id { break; } - let is_receive = matches!( - payment.payment_type, - breez_sdk_liquid::prelude::PaymentType::Receive - ); - if is_receive && matches!(payment.status, PaymentState::Complete) { - let usdt_id_str = - usdt_asset_id(self.breez_client.network()).unwrap_or(""); - let is_usdt = matches!( - &payment.details, - PaymentDetails::Liquid { asset_id, .. } - if !usdt_id_str.is_empty() && asset_id == usdt_id_str - ); - self.received_amount_display = if is_usdt { - let usdt_base = if let PaymentDetails::Liquid { - asset_info: Some(ref info), - .. - } = &payment.details - { - (info.amount * 10_f64.powi(USDT_PRECISION as i32)).round() - as u64 - } else { - payment.amount_sat - }; - format!("{} USDt", format_usdt_display(usdt_base)) + let is_receive = payment.is_incoming(); + if is_receive + && matches!(payment.status, DomainPaymentStatus::Complete) + { + let usdt_amount = + usdt_amount_minor(&payment.details, usdt_id); + self.received_amount_display = if let Some(minor) = usdt_amount { + format!("{} USDt", format_usdt_display(minor)) } else { use coincube_ui::component::amount::DisplayAmount; Amount::from_sat(payment.amount_sat) @@ -675,27 +671,12 @@ impl State for LiquidReceive { .map(|payment| { let status = payment.status; let time_ago = format_time_ago(payment.timestamp.into()); - let is_usdt = matches!( - &payment.details, - PaymentDetails::Liquid { asset_id, .. } - if !usdt_id.is_empty() && asset_id == usdt_id - ); - - let amount = if is_usdt { - if let PaymentDetails::Liquid { - asset_info: Some(ref info), - .. - } = &payment.details - { - Amount::from_sat( - (info.amount * 10_f64.powi(USDT_PRECISION as i32)).round() - as u64, - ) - } else { - Amount::from_sat(payment.amount_sat) - } - } else { - Amount::from_sat(payment.amount_sat) + let usdt_amount = usdt_amount_minor(&payment.details, usdt_id); + let is_usdt = usdt_amount.is_some(); + + let amount = match usdt_amount { + Some(minor) => Amount::from_sat(minor), + None => Amount::from_sat(payment.amount_sat), }; // Only compute fiat for BTC rows; USDt has its own display. @@ -707,50 +688,16 @@ impl State for LiquidReceive { .map(|c: &view::FiatAmountConverter| c.convert(amount)) }; - let (desc, usdt_display) = if is_usdt { - let display = if let PaymentDetails::Liquid { - asset_info: Some(info), - .. - } = &payment.details - { - format_usdt_display( - (info.amount * 10_f64.powi(USDT_PRECISION as i32)).round() - as u64, - ) - } else { - format_usdt_display(payment.amount_sat) - }; + let (desc, usdt_display) = if let Some(minor) = usdt_amount { ( "USDt Transfer".to_owned(), - Some(format!("{} USDt", display)), + Some(format!("{} USDt", format_usdt_display(minor))), ) } else { - let d: &str = match &payment.details { - PaymentDetails::Lightning { - payer_note, - description, - .. - } => payer_note - .as_ref() - .filter(|s| !s.is_empty()) - .unwrap_or(description), - PaymentDetails::Liquid { - payer_note, - description, - .. - } => payer_note - .as_ref() - .filter(|s| !s.is_empty()) - .unwrap_or(description), - PaymentDetails::Bitcoin { description, .. } => description, - }; - (d.to_owned(), None) + (payment.details.description().to_owned(), None) }; - let is_incoming = matches!( - payment.payment_type, - breez_sdk_liquid::prelude::PaymentType::Receive - ); + let is_incoming = payment.is_incoming(); let details = payment.details.clone(); let fees_sat = Amount::from_sat(payment.fees_sat); view::liquid::RecentTransaction { diff --git a/coincube-gui/src/app/state/liquid/send.rs b/coincube-gui/src/app/state/liquid/send.rs index 0b95e57c9..51dd256dd 100644 --- a/coincube-gui/src/app/state/liquid/send.rs +++ b/coincube-gui/src/app/state/liquid/send.rs @@ -2,8 +2,6 @@ use std::convert::TryInto; use std::sync::Arc; use std::time::Duration; -use breez_sdk_liquid::model::PaymentDetails; -use breez_sdk_liquid::prelude::Payment; use breez_sdk_liquid::InputType; use coincube_core::miniscript::bitcoin::Amount; use coincube_ui::{component::form, widget::*}; @@ -20,7 +18,7 @@ fn friendly_prepare_error(e: &impl std::fmt::Display) -> String { format!("Failed to prepare payment: {}", msg) } } -use crate::app::breez::assets::{ +use crate::app::breez_liquid::assets::{ asset_kind_for_id, format_usdt_display, lbtc_asset_id, parse_asset_to_minor_units, usdt_asset_id, AssetKind, USDT_PRECISION, }; @@ -28,7 +26,8 @@ use crate::app::menu::{LiquidSubMenu, Menu}; use crate::app::settings::unit::BitcoinDisplayUnit; use crate::app::state::{redirect, State}; use crate::app::view::SendPopupMessage; -use crate::app::{breez::BreezClient, cache::Cache}; +use crate::app::wallets::{DomainPayment, DomainPaymentDetails, LiquidBackend}; +use crate::app::cache::Cache; use crate::app::{message::Message, view, wallet::Wallet}; use crate::daemon::Daemon; use crate::utils::format_time_ago; @@ -149,7 +148,7 @@ pub enum LiquidSendFlowState { /// LiquidSend manages the send interface for all Liquid wallet assets. pub struct LiquidSend { - breez_client: Arc, + breez_client: Arc, sideshift_flow: Option, btc_balance: Amount, usdt_balance: u64, @@ -173,8 +172,8 @@ pub struct LiquidSend { /// Whether the "They Receive" picker modal is open. receive_picker_open: bool, recent_transaction: Vec, - recent_payments: Vec, - selected_payment: Option, + recent_payments: Vec, + selected_payment: Option, input: form::Value, input_type: Option, lightning_limits: Option<(u64, u64)>, // (min_sats, max_sats) @@ -202,7 +201,7 @@ pub struct LiquidSend { } impl LiquidSend { - pub fn new(breez_client: Arc) -> Self { + pub fn new(breez_client: Arc) -> Self { Self { breez_client, sideshift_flow: None, @@ -285,7 +284,7 @@ impl LiquidSend { ) } - pub fn breez_client(&self) -> &Arc { + pub fn breez_client(&self) -> &Arc { &self.breez_client } @@ -335,12 +334,13 @@ impl LiquidSend { }; let all_payments = payments.unwrap_or_default(); - let payments: Vec<_> = all_payments + let payments: Vec = all_payments .into_iter() .filter(|p| { let is_usdt = matches!( &p.details, - PaymentDetails::Liquid { asset_id, .. } if asset_id == usdt_id + DomainPaymentDetails::LiquidAsset { asset_id, .. } + if asset_id == usdt_id ); if usdt_only { is_usdt @@ -780,24 +780,27 @@ impl State for LiquidSend { if !recent_payment.is_empty() { let fiat_converter: Option = cache.fiat_price.as_ref().and_then(|p| p.try_into().ok()); + let usdt_id = + usdt_asset_id(self.breez_client.network()).unwrap_or(""); let txns = recent_payment .iter() .map(|payment| { let status = payment.status; let time_ago = format_time_ago(payment.timestamp.into()); - let is_usdt_payment = matches!( - &payment.details, - PaymentDetails::Liquid { asset_id, .. } - if asset_id == usdt_asset_id(self.breez_client.network()).unwrap_or("") - ); - let amount = if is_usdt_payment { - if let PaymentDetails::Liquid { asset_info: Some(ref ai), .. } = &payment.details { - Amount::from_sat((ai.amount * 10_f64.powi(USDT_PRECISION as i32)).round() as u64) - } else { - Amount::from_sat(payment.amount_sat) + let usdt_amount_minor = match &payment.details { + DomainPaymentDetails::LiquidAsset { + asset_id, + asset_info, + .. + } if !usdt_id.is_empty() && asset_id == usdt_id => { + asset_info.as_ref().map(|i| i.amount_minor) } - } else { - Amount::from_sat(payment.amount_sat) + _ => None, + }; + let is_usdt_payment = usdt_amount_minor.is_some(); + let amount = match usdt_amount_minor { + Some(minor) => Amount::from_sat(minor), + None => Amount::from_sat(payment.amount_sat), }; let fiat_amount = if is_usdt_payment { None @@ -807,30 +810,30 @@ impl State for LiquidSend { .map(|c: &view::FiatAmountConverter| c.convert(amount)) }; - let desc: &str = match &payment.details { - PaymentDetails::Lightning { payer_note, description, .. } => payer_note - .as_ref() - .filter(|s| !s.is_empty()) - .unwrap_or(description), - PaymentDetails::Liquid { payer_note, description, .. } => { - let fallback = if is_usdt_payment && description.is_empty() { + // Description: prefer payer_note, then fall back to the + // invoice description. For empty USDt Liquid payments, show + // "USDt Transfer" as a friendly default. + let desc: String = match &payment.details { + DomainPaymentDetails::LiquidAsset { + payer_note, + description, + .. + } if is_usdt_payment => { + let fallback = if description.is_empty() { "USDt Transfer" } else { description.as_str() }; payer_note - .as_ref() + .as_deref() .filter(|s| !s.is_empty()) - .map(|s| s.as_str()) .unwrap_or(fallback) + .to_owned() } - PaymentDetails::Bitcoin { description, .. } => description, + _ => payment.details.description().to_owned(), }; - let is_incoming = matches!( - payment.payment_type, - breez_sdk_liquid::prelude::PaymentType::Receive - ); + let is_incoming = payment.is_incoming(); let fees_sat = Amount::from_sat(payment.fees_sat); @@ -845,7 +848,7 @@ impl State for LiquidSend { }; view::liquid::RecentTransaction { - description: desc.to_owned(), + description: desc, time_ago, amount, fiat_amount, @@ -1448,7 +1451,7 @@ impl State for LiquidSend { destination, &to_asset_id, amount_sat, - crate::app::breez::assets::LBTC_PRECISION, + crate::app::breez_liquid::assets::LBTC_PRECISION, Some(&from_asset_id), ) .await diff --git a/coincube-gui/src/app/state/liquid/settings.rs b/coincube-gui/src/app/state/liquid/settings.rs index 6f98358e0..145aa73fd 100644 --- a/coincube-gui/src/app/state/liquid/settings.rs +++ b/coincube-gui/src/app/state/liquid/settings.rs @@ -6,7 +6,8 @@ use rand::seq::SliceRandom; use crate::app::settings::{update_settings_file, Settings}; use crate::app::view::LiquidSettingsMessage; -use crate::app::{breez::BreezClient, cache::Cache, menu::Menu, state::State}; +use crate::app::wallets::LiquidBackend; +use crate::app::{cache::Cache, menu::Menu, state::State}; use crate::app::{message::Message, view, wallet::Wallet}; use crate::daemon::Daemon; use crate::dir::CoincubeDirectory; @@ -31,7 +32,7 @@ pub enum LiquidSettingsFlowState { /// LiquidSettings is a placeholder panel for the Liquid Settings page pub struct LiquidSettings { - breez_client: Arc, + breez_client: Arc, flow_state: LiquidSettingsFlowState, } @@ -48,7 +49,7 @@ fn generate_random_word_indices(mnemonic_len: usize) -> Option<[usize; 3]> { } impl LiquidSettings { - pub fn new(breez_client: Arc) -> Self { + pub fn new(breez_client: Arc) -> Self { let backed_up = fetch_main_menu_state(breez_client.clone()); Self { breez_client, @@ -246,7 +247,7 @@ impl State for LiquidSettings { let network_dir = dir.network_directory(breez_client.network()); update_settings_file(&network_dir, |mut settings| { if let Some(cube) = settings.cubes.iter_mut().find(|cube| { - cube.liquid_wallet_signer_fingerprint.as_ref() + cube.breez_wallet_signer_fingerprint.as_ref() == Some(&fingerprint) }) { cube.backed_up = true; @@ -294,7 +295,7 @@ impl State for LiquidSettings { /// Fetches the main menu state (backed_up) from settings file. /// Uses spawn_blocking to avoid blocking the async runtime if file I/O hangs. -fn fetch_main_menu_state(breez_client: Arc) -> bool { +fn fetch_main_menu_state(breez_client: Arc) -> bool { // Run blocking I/O in a blocking context to prevent hanging the async runtime tokio::task::block_in_place(|| { let mut backed_up = false; @@ -313,7 +314,7 @@ fn fetch_main_menu_state(breez_client: Arc) -> bool { match Settings::from_file(&network_dir) { Ok(settings) => { let cube = settings.cubes.into_iter().find(|cube| { - cube.liquid_wallet_signer_fingerprint.as_ref() == Some(&fingerprint) + cube.breez_wallet_signer_fingerprint.as_ref() == Some(&fingerprint) }); if let Some(cube) = cube { backed_up = cube.backed_up; diff --git a/coincube-gui/src/app/state/liquid/sideshift_receive.rs b/coincube-gui/src/app/state/liquid/sideshift_receive.rs index d2b5821dd..cf6f8b826 100644 --- a/coincube-gui/src/app/state/liquid/sideshift_receive.rs +++ b/coincube-gui/src/app/state/liquid/sideshift_receive.rs @@ -4,8 +4,8 @@ use std::time::Duration; use coincube_ui::widget::*; use iced::{clipboard, widget::qr_code, Subscription, Task}; -use crate::app::breez::assets::usdt_asset_id; -use crate::app::breez::BreezClient; +use crate::app::breez_liquid::assets::usdt_asset_id; +use crate::app::wallets::LiquidBackend; use crate::app::cache::Cache; use crate::app::menu::Menu; use crate::app::message::Message; @@ -44,7 +44,7 @@ pub enum ReceivePhase { // --------------------------------------------------------------------------- pub struct SideshiftReceiveFlow { - breez_client: Arc, + breez_client: Arc, coincube_client: CoincubeClient, sideshift_client: SideshiftClient, @@ -64,7 +64,7 @@ pub struct SideshiftReceiveFlow { } impl SideshiftReceiveFlow { - pub fn new(breez_client: Arc) -> Self { + pub fn new(breez_client: Arc) -> Self { Self { breez_client, coincube_client: CoincubeClient::new(), diff --git a/coincube-gui/src/app/state/liquid/sideshift_send.rs b/coincube-gui/src/app/state/liquid/sideshift_send.rs index aded9c5b1..bd6c5b550 100644 --- a/coincube-gui/src/app/state/liquid/sideshift_send.rs +++ b/coincube-gui/src/app/state/liquid/sideshift_send.rs @@ -4,8 +4,8 @@ use std::time::Duration; use coincube_ui::widget::*; use iced::{clipboard, Subscription, Task}; -use crate::app::breez::assets::{parse_asset_to_minor_units, usdt_asset_id, USDT_PRECISION}; -use crate::app::breez::BreezClient; +use crate::app::breez_liquid::assets::{parse_asset_to_minor_units, usdt_asset_id, USDT_PRECISION}; +use crate::app::wallets::LiquidBackend; use crate::app::cache::Cache; use crate::app::menu::Menu; use crate::app::message::Message; @@ -298,7 +298,7 @@ impl SideshiftSendFlow { pub fn update( &mut self, msg: &SideshiftSendMessage, - breez_client: &Arc, + breez_client: &Arc, usdt_balance: u64, ) -> Task { match msg { diff --git a/coincube-gui/src/app/state/liquid/transactions.rs b/coincube-gui/src/app/state/liquid/transactions.rs index c863f313c..5aafcc28c 100644 --- a/coincube-gui/src/app/state/liquid/transactions.rs +++ b/coincube-gui/src/app/state/liquid/transactions.rs @@ -1,17 +1,20 @@ use std::convert::TryInto; use std::sync::Arc; -use breez_sdk_liquid::model::{PaymentDetails, RefundRequest}; -use breez_sdk_liquid::prelude::{Payment, RefundableSwap}; +use breez_sdk_liquid::model::RefundRequest; use coincube_core::miniscript::bitcoin::Amount; use coincube_ui::component::form; use coincube_ui::component::quote_display::{self, Quote}; use coincube_ui::widget::*; use iced::{widget::image, Task}; -use crate::app::breez::assets::usdt_asset_id; +use crate::app::breez_liquid::assets::usdt_asset_id; use crate::app::view::FeeratePriority; -use crate::app::{breez::BreezClient, cache::Cache, menu::Menu, state::State}; +use crate::app::wallets::{ + DomainPayment, DomainPaymentDetails, DomainPaymentDirection, DomainRefundableSwap, + LiquidBackend, +}; +use crate::app::{cache::Cache, menu::Menu, state::State}; use crate::app::{message::Message, view, wallet::Wallet}; use crate::daemon::Daemon; use crate::export::{ImportExportMessage, ImportExportState}; @@ -24,11 +27,11 @@ enum LiquidTransactionsModal { } pub struct LiquidTransactions { - breez_client: Arc, - payments: Vec, - refundables: Vec, - selected_payment: Option, - selected_refundable: Option, + breez_client: Arc, + payments: Vec, + refundables: Vec, + selected_payment: Option, + selected_refundable: Option, loading: bool, balance: Amount, modal: LiquidTransactionsModal, @@ -49,7 +52,7 @@ pub enum AssetFilter { } impl LiquidTransactions { - pub fn new(breez_client: Arc) -> Self { + pub fn new(breez_client: Arc) -> Self { let empty_state_quote = quote_display::random_quote("empty-wallet"); let empty_state_image_handle = quote_display::image_handle_for_context("empty-wallet"); Self { @@ -75,20 +78,16 @@ impl LiquidTransactions { self.asset_filter } - pub fn preselect(&mut self, payment: Payment) { + pub fn preselect(&mut self, payment: DomainPayment) { self.selected_payment = Some(payment); } fn calculate_balance(&self) -> Amount { - use breez_sdk_liquid::prelude::PaymentType; let usdt_id = usdt_asset_id(self.breez_client.network()).unwrap_or(""); let mut balance: i64 = 0; for payment in &self.payments { - let is_usdt = matches!( - &payment.details, - PaymentDetails::Liquid { asset_id, .. } if !usdt_id.is_empty() && asset_id == usdt_id - ); + let is_usdt = is_usdt_payment(&payment.details, usdt_id); match self.asset_filter { AssetFilter::UsdtOnly if !is_usdt => continue, @@ -103,11 +102,11 @@ impl LiquidTransactions { _ => {} } - match payment.payment_type { - PaymentType::Receive => { + match payment.direction { + DomainPaymentDirection::Receive => { balance += payment.amount_sat as i64; } - PaymentType::Send => { + DomainPaymentDirection::Send => { balance -= payment.amount_sat as i64; } } @@ -117,6 +116,15 @@ impl LiquidTransactions { } } +/// `true` if the payment carries the configured USDt asset id. +fn is_usdt_payment(details: &DomainPaymentDetails, usdt_id: &str) -> bool { + matches!( + details, + DomainPaymentDetails::LiquidAsset { asset_id, .. } + if !usdt_id.is_empty() && asset_id == usdt_id + ) +} + impl State for LiquidTransactions { fn view<'a>(&'a self, menu: &'a Menu, cache: &'a Cache) -> Element<'a, view::Message> { let fiat_converter = cache.fiat_price.as_ref().and_then(|p| p.try_into().ok()); @@ -205,23 +213,11 @@ impl State for LiquidTransactions { self.payments = match self.asset_filter { AssetFilter::UsdtOnly => payments .into_iter() - .filter(|p| { - matches!( - &p.details, - PaymentDetails::Liquid { asset_id, .. } - if asset_id == usdt_id - ) - }) + .filter(|p| is_usdt_payment(&p.details, usdt_id)) .collect(), AssetFilter::LbtcOnly => payments .into_iter() - .filter(|p| { - !matches!( - &p.details, - PaymentDetails::Liquid { asset_id, .. } - if asset_id == usdt_id - ) - }) + .filter(|p| !is_usdt_payment(&p.details, usdt_id)) .collect(), AssetFilter::All => payments, }; @@ -296,7 +292,7 @@ impl State for LiquidTransactions { self.modal = LiquidTransactionsModal::Export { state: ImportExportState::Started, }; - let breez_client = self.breez_client.clone(); + let breez_client = self.breez_client.client().clone(); Task::perform( async move { crate::export::export_liquid_payments( diff --git a/coincube-gui/src/app/state/mod.rs b/coincube-gui/src/app/state/mod.rs index 0b9fc105f..45a79c6a9 100644 --- a/coincube-gui/src/app/state/mod.rs +++ b/coincube-gui/src/app/state/mod.rs @@ -3,6 +3,7 @@ pub mod connect; mod global_home; pub mod liquid; pub mod settings; +pub mod spark; pub mod vault; use std::sync::Arc; @@ -27,6 +28,9 @@ pub use liquid::receive::LiquidReceive; pub use liquid::send::LiquidSend; pub use liquid::settings::{BackupWalletState, LiquidSettings, LiquidSettingsFlowState}; pub use liquid::transactions::LiquidTransactions; +pub use spark::{ + SparkOverview, SparkReceive, SparkSend, SparkSettings, SparkTransactions, +}; pub use vault::coins::CoinsPanel; pub use vault::label::LabelsEdited; pub use vault::overview::VaultOverview; diff --git a/coincube-gui/src/app/state/spark/mod.rs b/coincube-gui/src/app/state/spark/mod.rs new file mode 100644 index 000000000..645a713f3 --- /dev/null +++ b/coincube-gui/src/app/state/spark/mod.rs @@ -0,0 +1,18 @@ +//! Spark wallet panel state machines. +//! +//! One module per Menu::Spark entry — Overview, Send, Receive, +//! Transactions, Settings. Each holds an `Option>` +//! (None when the cube has no Spark signer or the bridge subprocess +//! failed to spawn) and renders an "unavailable" stub in that case. + +pub mod overview; +pub mod receive; +pub mod send; +pub mod settings; +pub mod transactions; + +pub use overview::SparkOverview; +pub use receive::{SparkReceive, SparkReceiveMethod, SparkReceivePhase}; +pub use send::{SparkSend, SparkSendPhase}; +pub use settings::{SparkSettings, SparkSettingsSnapshot}; +pub use transactions::SparkTransactions; diff --git a/coincube-gui/src/app/state/spark/overview.rs b/coincube-gui/src/app/state/spark/overview.rs new file mode 100644 index 000000000..29246037f --- /dev/null +++ b/coincube-gui/src/app/state/spark/overview.rs @@ -0,0 +1,152 @@ +//! Spark overview panel. +//! +//! Fetches the wallet balance + identity pubkey via +//! [`SparkBackend::get_info`] and the Stable Balance flag via +//! `get_user_settings`. Renders a minimal balance line with an +//! optional "Stable" badge when the SDK reports an active stable +//! token. A richer layout (recent transactions, send/receive +//! shortcuts) copied from [`crate::app::state::liquid::overview::LiquidOverview`] +//! is a future polish pass — the current view is intentionally thin +//! so the Spark wallet can ship while the Liquid overview remains +//! the richer reference. + +use std::sync::Arc; + +use coincube_ui::widget::Element; +use iced::Task; + +use crate::app::cache::Cache; +use crate::app::menu::Menu; +use crate::app::message::Message; +use crate::app::state::State; +use crate::app::view::spark::SparkOverviewView; +use crate::app::wallets::SparkBackend; + +/// Loaded info + timestamp snapshot. `None` while the first `reload()` +/// is in flight or if the bridge returned an error. +#[derive(Debug, Clone)] +pub struct SparkBalanceSnapshot { + pub balance_sats: u64, + pub identity_pubkey: String, +} + +/// Phase 3 placeholder for the Spark Overview panel. +pub struct SparkOverview { + backend: Option>, + snapshot: Option, + error: Option, + loading: bool, + /// Phase 6: cached Stable Balance flag. `None` until the + /// `get_user_settings` RPC returns; `Some(true)` renders a + /// "Stable" badge next to the balance in the overview. + stable_balance_active: Option, +} + +impl SparkOverview { + pub fn new(backend: Option>) -> Self { + Self { + backend, + snapshot: None, + error: None, + loading: false, + stable_balance_active: None, + } + } +} + +impl State for SparkOverview { + fn view<'a>(&'a self, menu: &'a Menu, cache: &'a Cache) -> Element<'a, crate::app::view::Message> { + let status = if self.backend.is_none() { + crate::app::view::spark::SparkStatus::Unavailable + } else if self.loading && self.snapshot.is_none() { + crate::app::view::spark::SparkStatus::Loading + } else if let Some(snapshot) = &self.snapshot { + crate::app::view::spark::SparkStatus::Connected(snapshot.clone()) + } else if let Some(err) = &self.error { + crate::app::view::spark::SparkStatus::Error(err.clone()) + } else { + crate::app::view::spark::SparkStatus::Loading + }; + + crate::app::view::dashboard( + menu, + cache, + SparkOverviewView { + status, + stable_balance_active: self.stable_balance_active.unwrap_or(false), + } + .render(), + ) + } + + fn reload( + &mut self, + _daemon: Option>, + _wallet: Option>, + ) -> Task { + let Some(backend) = self.backend.clone() else { + return Task::none(); + }; + self.loading = true; + self.error = None; + let info_task = Task::perform( + { + let backend = backend.clone(); + async move { backend.get_info().await } + }, + |result| match result { + Ok(info) => Message::View(crate::app::view::Message::SparkOverview( + crate::app::view::SparkOverviewMessage::DataLoaded(SparkBalanceSnapshot { + balance_sats: info.balance_sats, + identity_pubkey: info.identity_pubkey, + }), + )), + Err(e) => Message::View(crate::app::view::Message::SparkOverview( + crate::app::view::SparkOverviewMessage::Error(e.to_string()), + )), + }, + ); + let settings_task = Task::perform( + async move { backend.get_user_settings().await }, + |result| match result { + Ok(settings) => Message::View(crate::app::view::Message::SparkOverview( + crate::app::view::SparkOverviewMessage::StableBalanceLoaded( + settings.stable_balance_active, + ), + )), + Err(e) => { + tracing::warn!("spark overview get_user_settings failed: {}", e); + Message::View(crate::app::view::Message::SparkOverview( + crate::app::view::SparkOverviewMessage::StableBalanceLoaded(false), + )) + } + }, + ); + Task::batch(vec![info_task, settings_task]) + } + + fn update( + &mut self, + _daemon: Option>, + _cache: &Cache, + message: Message, + ) -> Task { + if let Message::View(crate::app::view::Message::SparkOverview(msg)) = message { + match msg { + crate::app::view::SparkOverviewMessage::DataLoaded(snapshot) => { + self.loading = false; + self.snapshot = Some(snapshot); + self.error = None; + } + crate::app::view::SparkOverviewMessage::Error(err) => { + self.loading = false; + self.error = Some(err); + } + crate::app::view::SparkOverviewMessage::StableBalanceLoaded(active) => { + self.stable_balance_active = Some(active); + } + } + } + Task::none() + } +} diff --git a/coincube-gui/src/app/state/spark/receive.rs b/coincube-gui/src/app/state/spark/receive.rs new file mode 100644 index 000000000..b1aeb720e --- /dev/null +++ b/coincube-gui/src/app/state/spark/receive.rs @@ -0,0 +1,377 @@ +//! Real Spark Receive panel — Phase 4c. +//! +//! State machine (per picked method): +//! +//! ```text +//! Idle { method } ──(Generate)──▶ Generating { method } +//! │ +//! ┌────┴─────┐ +//! ▼ ▼ +//! Generated(ok) Error(msg) +//! │ │ +//! └── Reset ─┘ +//! ▼ +//! Idle { method } +//! ``` +//! +//! The user picks a method (BOLT11 Lightning / on-chain Bitcoin), +//! optionally fills in amount + description for BOLT11, clicks +//! Generate, sees the result as a copyable text string. QR codes, +//! Lightning Address display, and the on-chain claim lifecycle all +//! land in Phase 4d. + +use std::sync::Arc; + +use coincube_spark_protocol::{DepositInfo, ReceivePaymentOk}; +use coincube_ui::widget::Element; +use iced::{widget::qr_code, Task}; + +use crate::app::cache::Cache; +use crate::app::menu::Menu; +use crate::app::message::Message; +use crate::app::state::State; +use crate::app::view::spark::SparkReceiveView; +use crate::app::wallets::SparkBackend; + +/// Which receive flow the user has picked. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum SparkReceiveMethod { + Bolt11, + OnchainBitcoin, +} + +impl SparkReceiveMethod { + pub fn label(self) -> &'static str { + match self { + Self::Bolt11 => "Lightning (BOLT11)", + Self::OnchainBitcoin => "On-chain Bitcoin", + } + } +} + +#[derive(Debug, Clone)] +pub enum SparkReceivePhase { + /// User is picking/configuring a method. + Idle, + /// `receive_bolt11` or `receive_onchain` RPC is in flight. + Generating, + /// RPC succeeded — `payment_request` is the copyable result. + Generated(ReceivePaymentOk), + /// A `PaymentSucceeded` event arrived while the panel was in + /// `Generated` state. Carries the amount from the event so the + /// confirmation screen can show it directly (saves a follow-up + /// `list_payments` round-trip). + /// + /// Phase 4d treats "any payment succeeded while an invoice is + /// on screen" as matching the displayed invoice. That's wrong in + /// the edge case where multiple channels settle at once, but + /// it's the simplest MVP. Phase 4e can correlate via the + /// payment's bolt11 field when we plumb richer event payloads. + Received { + amount_sat: i64, + }, + /// RPC failed — user-visible error. + Error(String), +} + +/// Real Spark Receive panel. +pub struct SparkReceive { + backend: Option>, + /// Currently selected method. Toggling methods resets the phase. + pub method: SparkReceiveMethod, + /// Amount input for BOLT11. Ignored for on-chain. + pub amount_input: String, + /// Invoice description shown to the payer for BOLT11. Ignored for on-chain. + pub description_input: String, + phase: SparkReceivePhase, + /// Pre-rendered QR code for the current `Generated` payment + /// request. Built once when `GenerateSucceeded` fires so the view + /// renderer doesn't have to re-encode the (potentially long) + /// BOLT11 invoice on every frame. `None` when no invoice is on + /// screen, or when encoding failed (unlikely for BOLT11/BTC + /// addresses but handled gracefully). + pub qr_data: Option, + /// Phase 4f: pending on-chain deposits surfaced by the SDK's + /// `list_unclaimed_deposits` RPC. Refreshed on panel reload and on + /// every `Event::DepositsChanged`. The view renders this as a + /// dedicated "Pending deposits" card below the main phase body. + pub pending_deposits: Vec, + /// Phase 4f: tracks which deposit is currently being claimed so + /// the UI can disable the row's button while the RPC is in flight. + /// Keyed by `(txid, vout)`. Cleared when the RPC finishes + /// (success or failure). + pub claiming: Option<(String, u32)>, + /// Phase 4f: surface a transient claim error to the user. Cleared + /// on the next reload or successful claim. + pub claim_error: Option, + /// Phase 4f: the BOLT11 invoice string of the currently-displayed + /// generated invoice, captured at `GenerateSucceeded` time. Used + /// by the auto-advance handler to correlate `PaymentSucceeded` + /// events against THIS invoice instead of accepting any + /// incoming payment. `None` while in idle / error / received + /// phases. + pub displayed_invoice: Option, +} + +impl SparkReceive { + pub fn new(backend: Option>) -> Self { + Self { + backend, + method: SparkReceiveMethod::Bolt11, + amount_input: String::new(), + description_input: String::new(), + phase: SparkReceivePhase::Idle, + qr_data: None, + pending_deposits: Vec::new(), + claiming: None, + claim_error: None, + displayed_invoice: None, + } + } + + pub fn phase(&self) -> &SparkReceivePhase { + &self.phase + } +} + +impl State for SparkReceive { + fn view<'a>( + &'a self, + menu: &'a Menu, + cache: &'a Cache, + ) -> Element<'a, crate::app::view::Message> { + let backend_available = self.backend.is_some(); + crate::app::view::dashboard( + menu, + cache, + SparkReceiveView { + backend_available, + method: self.method, + amount_input: &self.amount_input, + description_input: &self.description_input, + phase: &self.phase, + qr_data: self.qr_data.as_ref(), + pending_deposits: &self.pending_deposits, + claiming: self.claiming.as_ref(), + claim_error: self.claim_error.as_deref(), + } + .render(), + ) + } + + fn reload( + &mut self, + _daemon: Option>, + _wallet: Option>, + ) -> Task { + // Refresh the pending-deposits list whenever the panel + // becomes active. Errors degrade silently — the rest of the + // panel still works. + fetch_deposits_task(self.backend.clone()) + } + + fn update( + &mut self, + _daemon: Option>, + _cache: &Cache, + message: Message, + ) -> Task { + let Message::View(crate::app::view::Message::SparkReceive(msg)) = message else { + return Task::none(); + }; + + use crate::app::view::SparkReceiveMessage; + match msg { + SparkReceiveMessage::MethodSelected(method) => { + self.method = method; + self.phase = SparkReceivePhase::Idle; + self.qr_data = None; + self.displayed_invoice = None; + Task::none() + } + SparkReceiveMessage::AmountInputChanged(value) => { + self.amount_input = value; + self.phase = SparkReceivePhase::Idle; + self.qr_data = None; + self.displayed_invoice = None; + Task::none() + } + SparkReceiveMessage::DescriptionInputChanged(value) => { + self.description_input = value; + self.phase = SparkReceivePhase::Idle; + self.qr_data = None; + self.displayed_invoice = None; + Task::none() + } + SparkReceiveMessage::GenerateRequested => { + let Some(backend) = self.backend.clone() else { + self.phase = SparkReceivePhase::Error( + "Spark backend is not available.".to_string(), + ); + return Task::none(); + }; + self.phase = SparkReceivePhase::Generating; + match self.method { + SparkReceiveMethod::Bolt11 => { + let amount_sat = if self.amount_input.trim().is_empty() { + None + } else { + match self.amount_input.trim().parse::() { + Ok(n) => Some(n), + Err(_) => { + self.phase = SparkReceivePhase::Error( + "Amount must be a whole number of sats.".to_string(), + ); + return Task::none(); + } + } + }; + let description = self.description_input.clone(); + Task::perform( + async move { + backend.receive_bolt11(amount_sat, description, None).await + }, + |result| match result { + Ok(ok) => Message::View(crate::app::view::Message::SparkReceive( + SparkReceiveMessage::GenerateSucceeded(ok), + )), + Err(e) => Message::View(crate::app::view::Message::SparkReceive( + SparkReceiveMessage::GenerateFailed(e.to_string()), + )), + }, + ) + } + SparkReceiveMethod::OnchainBitcoin => Task::perform( + async move { backend.receive_onchain(None).await }, + |result| match result { + Ok(ok) => Message::View(crate::app::view::Message::SparkReceive( + SparkReceiveMessage::GenerateSucceeded(ok), + )), + Err(e) => Message::View(crate::app::view::Message::SparkReceive( + SparkReceiveMessage::GenerateFailed(e.to_string()), + )), + }, + ), + } + } + SparkReceiveMessage::GenerateSucceeded(ok) => { + // Encode the QR eagerly so the view renderer doesn't + // re-encode on every frame. + self.qr_data = qr_code::Data::new(&ok.payment_request).ok(); + // Phase 4f: capture the BOLT11 string so PaymentReceived + // events can correlate against THIS invoice. + self.displayed_invoice = Some(ok.payment_request.clone()); + self.phase = SparkReceivePhase::Generated(ok); + Task::none() + } + SparkReceiveMessage::GenerateFailed(err) => { + self.qr_data = None; + self.displayed_invoice = None; + self.phase = SparkReceivePhase::Error(err); + Task::none() + } + SparkReceiveMessage::PaymentReceived { amount_sat, bolt11 } => { + // Only react if the user is actually looking at an + // invoice — events that arrive while the panel is + // idle or showing an error are no-ops. + if !matches!(self.phase, SparkReceivePhase::Generated(_)) { + return Task::none(); + } + + // Phase 4f BOLT11 correlation. If we have a displayed + // invoice AND the event carries a BOLT11, only advance + // when they match exactly. If either side is absent + // (on-chain receive, Spark-native payment, etc.), + // fall back to the Phase 4d behavior of advancing on + // any incoming payment. + let matches_invoice = match (&self.displayed_invoice, &bolt11) { + (Some(displayed), Some(event_bolt11)) => displayed == event_bolt11, + _ => true, + }; + if !matches_invoice { + return Task::none(); + } + + self.qr_data = None; + self.displayed_invoice = None; + self.phase = SparkReceivePhase::Received { amount_sat }; + Task::none() + } + SparkReceiveMessage::PendingDepositsLoaded(deposits) => { + self.pending_deposits = deposits; + self.claim_error = None; + Task::none() + } + SparkReceiveMessage::PendingDepositsFailed(err) => { + tracing::warn!("Spark list_unclaimed_deposits failed: {}", err); + // Don't surface as a hard error — the rest of the + // panel still works. Just clear the displayed list. + self.pending_deposits.clear(); + Task::none() + } + SparkReceiveMessage::ClaimDepositRequested { txid, vout } => { + let Some(backend) = self.backend.clone() else { + return Task::none(); + }; + self.claiming = Some((txid.clone(), vout)); + self.claim_error = None; + Task::perform( + async move { backend.claim_deposit(txid, vout).await }, + |result| match result { + Ok(ok) => Message::View(crate::app::view::Message::SparkReceive( + crate::app::view::SparkReceiveMessage::ClaimDepositSucceeded(ok), + )), + Err(e) => Message::View(crate::app::view::Message::SparkReceive( + crate::app::view::SparkReceiveMessage::ClaimDepositFailed( + e.to_string(), + ), + )), + }, + ) + } + SparkReceiveMessage::ClaimDepositSucceeded(_ok) => { + // The actual reload happens via the DepositsChanged + // event the SDK fires post-claim, but we also refresh + // here defensively in case the event got dropped. + self.claiming = None; + self.claim_error = None; + fetch_deposits_task(self.backend.clone()) + } + SparkReceiveMessage::ClaimDepositFailed(err) => { + self.claiming = None; + self.claim_error = Some(err); + Task::none() + } + SparkReceiveMessage::DepositsChanged => { + fetch_deposits_task(self.backend.clone()) + } + SparkReceiveMessage::Reset => { + self.qr_data = None; + self.displayed_invoice = None; + self.phase = SparkReceivePhase::Idle; + Task::none() + } + } + } +} + +/// Fire a `list_unclaimed_deposits` RPC and translate the result into +/// the appropriate view message. Pulled out as a helper so the +/// `reload`, `ClaimDepositSucceeded`, and `DepositsChanged` paths can +/// share it without duplicating the closure boilerplate. +fn fetch_deposits_task(backend: Option>) -> Task { + let Some(backend) = backend else { + return Task::none(); + }; + Task::perform( + async move { backend.list_unclaimed_deposits().await }, + |result| match result { + Ok(ok) => Message::View(crate::app::view::Message::SparkReceive( + crate::app::view::SparkReceiveMessage::PendingDepositsLoaded(ok.deposits), + )), + Err(e) => Message::View(crate::app::view::Message::SparkReceive( + crate::app::view::SparkReceiveMessage::PendingDepositsFailed(e.to_string()), + )), + }, + ) +} diff --git a/coincube-gui/src/app/state/spark/send.rs b/coincube-gui/src/app/state/spark/send.rs new file mode 100644 index 000000000..c2fb8ddc0 --- /dev/null +++ b/coincube-gui/src/app/state/spark/send.rs @@ -0,0 +1,280 @@ +//! Real Spark Send panel — Phase 4c. +//! +//! State machine: +//! +//! ```text +//! Idle ──(input+Prepare)──▶ Preparing +//! │ +//! ▼ +//! Prepared { preview } +//! │ Confirm +//! ▼ +//! Sending +//! │ +//! ┌───────┴────────┐ +//! ▼ ▼ +//! Sent { ok } Error(msg) +//! │ │ +//! └──── Reset ─────┘ +//! ▼ +//! Idle +//! ``` +//! +//! The `handle` returned by `prepare_send` is stored inside `Prepared` +//! and consumed by `send_payment` on confirm. Changing the input after +//! `Prepared` drops the handle (the SDK's prepare is single-use). + +use std::sync::Arc; + +use coincube_spark_protocol::{ParseInputKind, PrepareSendOk, SendPaymentOk}; +use coincube_ui::widget::Element; +use iced::Task; + +use crate::app::cache::Cache; +use crate::app::menu::Menu; +use crate::app::message::Message; +use crate::app::state::State; +use crate::app::view::spark::SparkSendView; +use crate::app::wallets::SparkBackend; + +/// Shape of the Send panel at any instant. +#[derive(Debug, Clone)] +pub enum SparkSendPhase { + /// Empty state — user hasn't entered anything, or just reset. + Idle, + /// Awaiting the `prepare_send` RPC response. + Preparing, + /// `prepare_send` returned; the caller can review the preview and + /// either confirm (→ `send_payment`) or go back to `Idle`. + Prepared(PrepareSendOk), + /// Awaiting the `send_payment` RPC response. + Sending, + /// `send_payment` returned successfully. + Sent(SendPaymentOk), + /// Any step failed. Carries the user-visible message. + Error(String), +} + +/// Real Spark Send panel. +pub struct SparkSend { + backend: Option>, + /// Free-text destination input (BOLT11 / BIP21 / on-chain address). + pub destination_input: String, + /// Amount override for amountless invoices / on-chain sends, in sats. + pub amount_input: String, + phase: SparkSendPhase, +} + +impl SparkSend { + pub fn new(backend: Option>) -> Self { + Self { + backend, + destination_input: String::new(), + amount_input: String::new(), + phase: SparkSendPhase::Idle, + } + } + + pub fn phase(&self) -> &SparkSendPhase { + &self.phase + } +} + +impl State for SparkSend { + fn view<'a>( + &'a self, + menu: &'a Menu, + cache: &'a Cache, + ) -> Element<'a, crate::app::view::Message> { + let backend_available = self.backend.is_some(); + crate::app::view::dashboard( + menu, + cache, + SparkSendView { + backend_available, + destination_input: &self.destination_input, + amount_input: &self.amount_input, + phase: &self.phase, + } + .render(), + ) + } + + fn update( + &mut self, + _daemon: Option>, + _cache: &Cache, + message: Message, + ) -> Task { + let Message::View(crate::app::view::Message::SparkSend(msg)) = message else { + return Task::none(); + }; + + use crate::app::view::SparkSendMessage; + match msg { + SparkSendMessage::DestinationInputChanged(value) => { + self.destination_input = value; + // Editing the destination invalidates any in-flight + // preview — drop back to Idle so the user can re-prepare. + self.phase = SparkSendPhase::Idle; + Task::none() + } + SparkSendMessage::AmountInputChanged(value) => { + self.amount_input = value; + self.phase = SparkSendPhase::Idle; + Task::none() + } + SparkSendMessage::PrepareRequested => { + let Some(backend) = self.backend.clone() else { + self.phase = SparkSendPhase::Error( + "Spark backend is not available.".to_string(), + ); + return Task::none(); + }; + if self.destination_input.trim().is_empty() { + self.phase = + SparkSendPhase::Error("Enter a destination first.".to_string()); + return Task::none(); + } + let amount_sat = if self.amount_input.trim().is_empty() { + None + } else { + match self.amount_input.trim().parse::() { + Ok(n) => Some(n), + Err(_) => { + self.phase = SparkSendPhase::Error( + "Amount must be a whole number of sats.".to_string(), + ); + return Task::none(); + } + } + }; + let input = self.destination_input.trim().to_string(); + self.phase = SparkSendPhase::Preparing; + // Phase 4e: chain `parse_input` + `prepare_*` in a + // single async task so the user only sees one + // "Preparing…" phase regardless of which SDK code + // path runs underneath. The closure returns + // `Result` so the existing + // `PrepareSucceeded` / `PrepareFailed` messages + // handle both regular sends and LNURL-pay sends + // uniformly. + Task::perform( + async move { resolve_and_prepare(backend, input, amount_sat).await }, + |result| match result { + Ok(ok) => Message::View(crate::app::view::Message::SparkSend( + SparkSendMessage::PrepareSucceeded(ok), + )), + Err(e) => Message::View(crate::app::view::Message::SparkSend( + SparkSendMessage::PrepareFailed(e), + )), + }, + ) + } + SparkSendMessage::PrepareSucceeded(ok) => { + self.phase = SparkSendPhase::Prepared(ok); + Task::none() + } + SparkSendMessage::PrepareFailed(err) => { + self.phase = SparkSendPhase::Error(err); + Task::none() + } + SparkSendMessage::ConfirmRequested => { + let SparkSendPhase::Prepared(prepare) = &self.phase else { + return Task::none(); + }; + let Some(backend) = self.backend.clone() else { + self.phase = SparkSendPhase::Error( + "Spark backend is not available.".to_string(), + ); + return Task::none(); + }; + let handle = prepare.handle.clone(); + self.phase = SparkSendPhase::Sending; + Task::perform( + async move { backend.send_payment(handle).await }, + |result| match result { + Ok(ok) => Message::View(crate::app::view::Message::SparkSend( + SparkSendMessage::SendSucceeded(ok), + )), + Err(e) => Message::View(crate::app::view::Message::SparkSend( + SparkSendMessage::SendFailed(e.to_string()), + )), + }, + ) + } + SparkSendMessage::SendSucceeded(ok) => { + self.phase = SparkSendPhase::Sent(ok); + // Clear the inputs so a follow-up send doesn't re-use them. + self.destination_input.clear(); + self.amount_input.clear(); + Task::none() + } + SparkSendMessage::SendFailed(err) => { + self.phase = SparkSendPhase::Error(err); + Task::none() + } + SparkSendMessage::Reset => { + self.destination_input.clear(); + self.amount_input.clear(); + self.phase = SparkSendPhase::Idle; + Task::none() + } + } + } +} + +/// Phase 4e: classify the user-supplied destination via `parse_input` +/// and dispatch to the right prepare RPC (`prepare_send` for +/// BOLT11/on-chain/Other, `prepare_lnurl_pay` for LNURL/Lightning +/// Address). Returns a `Result` so the calling +/// closure can wrap the success/failure into the existing +/// `SparkSendMessage::Prepare*` variants without a new branch in the +/// state machine. +/// +/// LNURL inputs validate the amount against the server's min/max +/// range up front so the gui can surface a useful error before +/// actually hitting the LNURL callback URL. +async fn resolve_and_prepare( + backend: Arc, + input: String, + amount_sat: Option, +) -> Result { + let parsed = backend + .parse_input(input.clone()) + .await + .map_err(|e| format!("parse failed: {e}"))?; + + match parsed.kind { + ParseInputKind::LnurlPay | ParseInputKind::LightningAddress => { + let amount = amount_sat.ok_or_else(|| { + "Lightning address sends require an amount in the Amount field.".to_string() + })?; + // LNURL servers always declare a min/max range. Validate + // the user's amount before hitting the callback URL — + // catches the obvious mistakes (zero, way too high) with + // a clear message instead of a cryptic SDK error. + let min = parsed.lnurl_min_sendable_sat.unwrap_or(0); + let max = parsed + .lnurl_max_sendable_sat + .unwrap_or(u64::MAX); + if amount < min || amount > max { + return Err(format!( + "This LNURL server accepts payments between {} and {} sats; \ + you entered {}.", + min, max, amount + )); + } + backend + .prepare_lnurl_pay(input, amount, None) + .await + .map_err(|e| format!("prepare_lnurl_pay failed: {e}")) + } + ParseInputKind::Bolt11Invoice + | ParseInputKind::BitcoinAddress + | ParseInputKind::Other => backend + .prepare_send(input, amount_sat) + .await + .map_err(|e| format!("prepare_send failed: {e}")), + } +} diff --git a/coincube-gui/src/app/state/spark/settings.rs b/coincube-gui/src/app/state/spark/settings.rs new file mode 100644 index 000000000..eab954ca6 --- /dev/null +++ b/coincube-gui/src/app/state/spark/settings.rs @@ -0,0 +1,270 @@ +//! Spark Settings panel. +//! +//! Renders: +//! - Read-only diagnostics from the bridge's `get_info` RPC +//! (balance, identity pubkey, network). +//! - A "Default Lightning backend" picker (Phase 5) — selects the +//! backend that fulfills incoming `@coincube.io` Lightning +//! Address invoices. Persists to the cube's settings file and +//! triggers `Message::SettingsSaved` so the rest of the app +//! picks up the new value. +//! - A "Stable Balance" toggle (Phase 6) — enables or disables the +//! Spark SDK's USD-pegged balance feature via +//! `update_user_settings(stable_balance_active_label)`. +//! +//! Signer rotation and a "bridge health / reconnect" control remain +//! future work. + +use std::sync::Arc; + +use coincube_ui::widget::Element; +use coincube_spark_protocol::{GetInfoOk, GetUserSettingsOk}; +use iced::Task; + +use crate::app::cache::Cache; +use crate::app::menu::Menu; +use crate::app::message::Message; +use crate::app::state::State; +use crate::app::view::spark::SparkSettingsView; +use crate::app::wallets::SparkBackend; + +/// What [`SparkSettings::reload`] produces when the bridge answers. +#[derive(Debug, Clone)] +pub struct SparkSettingsSnapshot { + pub balance_sats: u64, + pub identity_pubkey: String, +} + +/// Phase 4b Spark Settings panel. +pub struct SparkSettings { + backend: Option>, + snapshot: Option, + error: Option, + loading: bool, + /// Phase 6: latest Stable Balance state read from the bridge. + /// `None` until the first `get_user_settings` round-trip + /// completes; the view renders the toggle in a "loading" + /// state while it's `None`. Updated optimistically when the + /// user flips the toggle, then reconciled on + /// `StableBalanceSaved`. + stable_balance_active: Option, + /// Phase 6: `true` while a `set_stable_balance` RPC is in + /// flight — the toggle is disabled in that window. + stable_balance_saving: bool, +} + +impl SparkSettings { + pub fn new(backend: Option>) -> Self { + Self { + backend, + snapshot: None, + error: None, + loading: false, + stable_balance_active: None, + stable_balance_saving: false, + } + } +} + +impl State for SparkSettings { + fn view<'a>( + &'a self, + menu: &'a Menu, + cache: &'a Cache, + ) -> Element<'a, crate::app::view::Message> { + let status = if self.backend.is_none() { + crate::app::view::spark::SparkSettingsStatus::Unavailable + } else if self.loading && self.snapshot.is_none() { + crate::app::view::spark::SparkSettingsStatus::Loading + } else if let Some(snapshot) = &self.snapshot { + crate::app::view::spark::SparkSettingsStatus::Loaded(snapshot.clone()) + } else if let Some(err) = &self.error { + crate::app::view::spark::SparkSettingsStatus::Error(err.clone()) + } else { + crate::app::view::spark::SparkSettingsStatus::Loading + }; + + crate::app::view::dashboard( + menu, + cache, + SparkSettingsView { + status, + network: cache.network, + default_lightning_backend: cache.default_lightning_backend, + spark_available: self.backend.is_some(), + stable_balance_active: self.stable_balance_active, + stable_balance_saving: self.stable_balance_saving, + } + .render(), + ) + } + + fn reload( + &mut self, + _daemon: Option>, + _wallet: Option>, + ) -> Task { + let Some(backend) = self.backend.clone() else { + return Task::none(); + }; + self.loading = true; + self.error = None; + let info_task = Task::perform( + { + let backend = backend.clone(); + async move { backend.get_info().await } + }, + |result: Result| match result { + Ok(info) => Message::View(crate::app::view::Message::SparkSettings( + crate::app::view::SparkSettingsMessage::DataLoaded(SparkSettingsSnapshot { + balance_sats: info.balance_sats, + identity_pubkey: info.identity_pubkey, + }), + )), + Err(e) => Message::View(crate::app::view::Message::SparkSettings( + crate::app::view::SparkSettingsMessage::Error(e.to_string()), + )), + }, + ); + let settings_task = Task::perform( + async move { backend.get_user_settings().await }, + |result: Result| match result { + Ok(settings) => Message::View(crate::app::view::Message::SparkSettings( + crate::app::view::SparkSettingsMessage::UserSettingsLoaded(settings), + )), + Err(e) => { + tracing::warn!("get_user_settings failed: {}", e); + // Swallow into a no-op — the Stable Balance + // toggle just stays in its "loading" state + // and the rest of the panel still works. + Message::View(crate::app::view::Message::SparkSettings( + crate::app::view::SparkSettingsMessage::UserSettingsLoaded( + GetUserSettingsOk { + stable_balance_active: false, + private_mode_enabled: false, + }, + ), + )) + } + }, + ); + Task::batch(vec![info_task, settings_task]) + } + + fn update( + &mut self, + _daemon: Option>, + cache: &Cache, + message: Message, + ) -> Task { + if let Message::View(crate::app::view::Message::SparkSettings(msg)) = message { + match msg { + crate::app::view::SparkSettingsMessage::DataLoaded(snapshot) => { + self.loading = false; + self.snapshot = Some(snapshot); + self.error = None; + } + crate::app::view::SparkSettingsMessage::Error(err) => { + self.loading = false; + self.error = Some(err); + } + crate::app::view::SparkSettingsMessage::DefaultLightningBackendChanged(kind) => { + // Persist asynchronously. On success, emit + // SettingsSaved so App reloads cube_settings + // and cache from disk. + let datadir = cache.datadir_path.clone(); + let network = cache.network; + let cube_id = cache.cube_id.clone(); + return Task::perform( + async move { + use crate::app::settings::update_settings_file; + let network_dir = datadir.network_directory(network); + update_settings_file(&network_dir, |mut settings| { + if let Some(cube) = + settings.cubes.iter_mut().find(|c| c.id == cube_id) + { + cube.default_lightning_backend = kind; + } else { + tracing::error!( + "Cube not found (id={}) — cannot save default_lightning_backend", + cube_id + ); + } + Some(settings) + }) + .await + .map_err(|e| e.to_string()) + }, + |result| match result { + Ok(()) => Message::SettingsSaved, + Err(err) => Message::View( + crate::app::view::Message::SparkSettings( + crate::app::view::SparkSettingsMessage::DefaultLightningBackendSaved( + Some(err), + ), + ), + ), + }, + ); + } + crate::app::view::SparkSettingsMessage::DefaultLightningBackendSaved(err) => { + if let Some(err) = err { + tracing::warn!("Failed to persist default_lightning_backend: {}", err); + self.error = Some(err); + } + } + crate::app::view::SparkSettingsMessage::UserSettingsLoaded(settings) => { + self.stable_balance_active = Some(settings.stable_balance_active); + } + crate::app::view::SparkSettingsMessage::StableBalanceToggled(enabled) => { + let Some(backend) = self.backend.clone() else { + return Task::none(); + }; + // Optimistic update — snap the toggle immediately so + // the UI feels responsive. StableBalanceSaved + // reconciles if the RPC fails. + self.stable_balance_active = Some(enabled); + self.stable_balance_saving = true; + return Task::perform( + async move { backend.set_stable_balance(enabled).await }, + move |result| match result { + Ok(()) => Message::View( + crate::app::view::Message::SparkSettings( + crate::app::view::SparkSettingsMessage::StableBalanceSaved( + Ok(enabled), + ), + ), + ), + Err(e) => Message::View( + crate::app::view::Message::SparkSettings( + crate::app::view::SparkSettingsMessage::StableBalanceSaved( + Err(e.to_string()), + ), + ), + ), + }, + ); + } + crate::app::view::SparkSettingsMessage::StableBalanceSaved(result) => { + self.stable_balance_saving = false; + match result { + Ok(enabled) => { + self.stable_balance_active = Some(enabled); + } + Err(err) => { + tracing::warn!("set_stable_balance failed: {}", err); + // Revert optimistic update by flipping + // back to the previous state (whatever + // value is not the attempted one). + if let Some(current) = self.stable_balance_active { + self.stable_balance_active = Some(!current); + } + self.error = Some(err); + } + } + } + } + } + Task::none() + } +} diff --git a/coincube-gui/src/app/state/spark/transactions.rs b/coincube-gui/src/app/state/spark/transactions.rs new file mode 100644 index 000000000..59edc755d --- /dev/null +++ b/coincube-gui/src/app/state/spark/transactions.rs @@ -0,0 +1,113 @@ +//! Real Spark Transactions panel — Phase 4b. +//! +//! Consumes the existing `list_payments` RPC already exposed by the bridge. +//! No new protocol methods needed for this panel, so it can ship ahead of +//! the Send/Receive flows (which need new write-path bridge methods). +//! +//! Rendering path: `reload()` → bridge `list_payments` → store the +//! `Vec` → the view module renders each entry as a row +//! with direction/amount/time/status. Status strings come pre-formatted +//! from [`PaymentSummary::status`] (the bridge's `{:?}` debug format of +//! the Spark SDK `PaymentStatus` enum) — Phase 4c can promote those to +//! typed enum variants if the UI starts branching on them. + +use std::sync::Arc; + +use coincube_ui::widget::Element; +use coincube_spark_protocol::PaymentSummary; +use iced::Task; + +use crate::app::cache::Cache; +use crate::app::menu::Menu; +use crate::app::message::Message; +use crate::app::state::State; +use crate::app::view::spark::SparkTransactionsView; +use crate::app::wallets::SparkBackend; + +/// Phase 4b Spark Transactions panel. +pub struct SparkTransactions { + backend: Option>, + payments: Vec, + loading: bool, + error: Option, +} + +impl SparkTransactions { + pub fn new(backend: Option>) -> Self { + Self { + backend, + payments: Vec::new(), + loading: false, + error: None, + } + } +} + +impl State for SparkTransactions { + fn view<'a>( + &'a self, + menu: &'a Menu, + cache: &'a Cache, + ) -> Element<'a, crate::app::view::Message> { + let status = if self.backend.is_none() { + crate::app::view::spark::SparkTransactionsStatus::Unavailable + } else if self.loading && self.payments.is_empty() { + crate::app::view::spark::SparkTransactionsStatus::Loading + } else if let Some(err) = &self.error { + crate::app::view::spark::SparkTransactionsStatus::Error(err.clone()) + } else { + crate::app::view::spark::SparkTransactionsStatus::Loaded(self.payments.clone()) + }; + + crate::app::view::dashboard( + menu, + cache, + SparkTransactionsView { status }.render(), + ) + } + + fn reload( + &mut self, + _daemon: Option>, + _wallet: Option>, + ) -> Task { + let Some(backend) = self.backend.clone() else { + return Task::none(); + }; + self.loading = true; + self.error = None; + Task::perform( + async move { backend.list_payments(Some(100)).await }, + |result| match result { + Ok(list) => Message::View(crate::app::view::Message::SparkTransactions( + crate::app::view::SparkTransactionsMessage::DataLoaded(list.payments), + )), + Err(e) => Message::View(crate::app::view::Message::SparkTransactions( + crate::app::view::SparkTransactionsMessage::Error(e.to_string()), + )), + }, + ) + } + + fn update( + &mut self, + _daemon: Option>, + _cache: &Cache, + message: Message, + ) -> Task { + if let Message::View(crate::app::view::Message::SparkTransactions(msg)) = message { + match msg { + crate::app::view::SparkTransactionsMessage::DataLoaded(payments) => { + self.loading = false; + self.payments = payments; + self.error = None; + } + crate::app::view::SparkTransactionsMessage::Error(err) => { + self.loading = false; + self.error = Some(err); + } + } + } + Task::none() + } +} diff --git a/coincube-gui/src/app/state/vault/export.rs b/coincube-gui/src/app/state/vault/export.rs index 8fa078513..ef787d7cc 100644 --- a/coincube-gui/src/app/state/vault/export.rs +++ b/coincube-gui/src/app/state/vault/export.rs @@ -23,7 +23,7 @@ pub struct VaultExportModal { state: ImportExportState, error: Option, daemon: Option>, - breez_client: Option>, + breez_client: Option>, import_export_type: ImportExportType, } diff --git a/coincube-gui/src/app/view/buysell/mavapay/mod.rs b/coincube-gui/src/app/view/buysell/mavapay/mod.rs index 2c8c24fb4..26f6712b8 100644 --- a/coincube-gui/src/app/view/buysell/mavapay/mod.rs +++ b/coincube-gui/src/app/view/buysell/mavapay/mod.rs @@ -1,7 +1,7 @@ pub mod ui; use crate::app::{ - breez::BreezClient, + breez_liquid::BreezClient, view::{self, buysell::panel}, }; diff --git a/coincube-gui/src/app/view/buysell/panel.rs b/coincube-gui/src/app/view/buysell/panel.rs index c445dcfd2..52ad4192f 100644 --- a/coincube-gui/src/app/view/buysell/panel.rs +++ b/coincube-gui/src/app/view/buysell/panel.rs @@ -84,7 +84,7 @@ pub struct BuySellPanel { // services used by several buysell providers pub coincube_client: crate::services::coincube::CoincubeClient, - pub breez_client: std::sync::Arc, + pub breez_client: std::sync::Arc, pub detected_country: Option<&'static crate::services::coincube::Country>, // coincube session information, restored from OS keyring @@ -98,7 +98,7 @@ impl BuySellPanel { pub fn new( network: bitcoin::Network, wallet: std::sync::Arc, - breez_client: std::sync::Arc, + breez_client: std::sync::Arc, ) -> Self { BuySellPanel { // Start in detecting location state diff --git a/coincube-gui/src/app/view/global_home.rs b/coincube-gui/src/app/view/global_home.rs index 7d98b2226..1bbea6fa7 100644 --- a/coincube-gui/src/app/view/global_home.rs +++ b/coincube-gui/src/app/view/global_home.rs @@ -15,7 +15,7 @@ use iced::{ }; use iced_anim::AnimationBuilder; -use crate::app::breez::assets::format_usdt_display; +use crate::app::breez_liquid::assets::format_usdt_display; use crate::app::{ menu::Menu, view::{vault::receive::address_card, FiatAmountConverter}, diff --git a/coincube-gui/src/app/view/liquid/overview.rs b/coincube-gui/src/app/view/liquid/overview.rs index 4fb72bd80..b0fd42502 100644 --- a/coincube-gui/src/app/view/liquid/overview.rs +++ b/coincube-gui/src/app/view/liquid/overview.rs @@ -1,5 +1,6 @@ -use breez_sdk_liquid::model::{PaymentDetails, PaymentState}; use coincube_core::miniscript::bitcoin::Amount; + +use crate::app::wallets::{DomainPaymentDetails, DomainPaymentStatus}; use coincube_ui::{ color, component::{ @@ -17,7 +18,7 @@ use iced::{ Alignment, Background, Length, }; -use crate::app::breez::assets::format_usdt_display; +use crate::app::breez_liquid::assets::format_usdt_display; use crate::app::view::{liquid::RecentTransaction, FiatAmountConverter, LiquidOverviewMessage}; #[allow(clippy::too_many_arguments)] @@ -40,7 +41,7 @@ pub fn liquid_overview_view<'a>( let pending_outgoing_sats: u64 = recent_transaction .iter() .filter(|t| { - !t.is_incoming && t.usdt_display.is_none() && matches!(t.status, PaymentState::Pending) + !t.is_incoming && t.usdt_display.is_none() && matches!(t.status, DomainPaymentStatus::Pending) }) .map(|t| (t.amount + t.fees_sat).to_sat()) .sum(); @@ -48,7 +49,7 @@ pub fn liquid_overview_view<'a>( let pending_incoming_sats: u64 = recent_transaction .iter() .filter(|t| { - t.is_incoming && t.usdt_display.is_none() && matches!(t.status, PaymentState::Pending) + t.is_incoming && t.usdt_display.is_none() && matches!(t.status, DomainPaymentStatus::Pending) }) .map(|t| t.amount.to_sat()) .sum(); @@ -250,13 +251,13 @@ pub fn liquid_overview_view<'a>( coincube_ui::image::asset_network_logo("usdt", "liquid", 40.0) } else { match &tx.details { - PaymentDetails::Lightning { .. } => { + DomainPaymentDetails::Lightning { .. } => { coincube_ui::image::asset_network_logo("btc", "lightning", 40.0) } - PaymentDetails::Liquid { .. } => { + DomainPaymentDetails::LiquidAsset { .. } => { coincube_ui::image::asset_network_logo("lbtc", "liquid", 40.0) } - PaymentDetails::Bitcoin { .. } => { + DomainPaymentDetails::OnChainBitcoin { .. } => { coincube_ui::image::asset_network_logo("btc", "bitcoin", 40.0) } } @@ -286,7 +287,7 @@ pub fn liquid_overview_view<'a>( } } - if matches!(tx.status, PaymentState::Pending) { + if matches!(tx.status, DomainPaymentStatus::Pending) { let (bg, fg) = (color::GREY_3, color::BLACK); let pending_badge = Container::new( Row::new() diff --git a/coincube-gui/src/app/view/liquid/receive.rs b/coincube-gui/src/app/view/liquid/receive.rs index f18cc2fa3..462a4ab05 100644 --- a/coincube-gui/src/app/view/liquid/receive.rs +++ b/coincube-gui/src/app/view/liquid/receive.rs @@ -1,6 +1,7 @@ -use breez_sdk_liquid::model::{PaymentDetails, PaymentState}; use coincube_core::miniscript::bitcoin::{Amount, Denomination}; +use crate::app::wallets::{DomainPaymentDetails, DomainPaymentStatus}; + use coincube_ui::{ color, component::{ @@ -23,7 +24,7 @@ use iced::{ use coincube_ui::image::asset_network_logo; use crate::app::{ - breez::assets::{format_usdt_display, parse_asset_to_minor_units, USDT_PRECISION}, + breez_liquid::assets::{format_usdt_display, parse_asset_to_minor_units, USDT_PRECISION}, settings::unit::BitcoinDisplayUnit, state::liquid::send::SendAsset, view::{liquid::RecentTransaction, LiquidReceiveMessage, ReceiveMethod, SenderNetwork}, @@ -181,13 +182,13 @@ pub fn liquid_receive_view<'a>( coincube_ui::image::asset_network_logo("usdt", "liquid", 40.0) } else { match &tx.details { - PaymentDetails::Lightning { .. } => { + DomainPaymentDetails::Lightning { .. } => { coincube_ui::image::asset_network_logo("btc", "lightning", 40.0) } - PaymentDetails::Liquid { .. } => { + DomainPaymentDetails::LiquidAsset { .. } => { coincube_ui::image::asset_network_logo("lbtc", "liquid", 40.0) } - PaymentDetails::Bitcoin { .. } => { + DomainPaymentDetails::OnChainBitcoin { .. } => { coincube_ui::image::asset_network_logo("btc", "bitcoin", 40.0) } } @@ -219,7 +220,7 @@ pub fn liquid_receive_view<'a>( } } - if matches!(tx.status, PaymentState::Pending) { + if matches!(tx.status, DomainPaymentStatus::Pending) { let (bg, fg) = (color::GREY_3, color::BLACK); let pending_badge = Container::new( Row::new() diff --git a/coincube-gui/src/app/view/liquid/send.rs b/coincube-gui/src/app/view/liquid/send.rs index 6f4e4be16..59b821516 100644 --- a/coincube-gui/src/app/view/liquid/send.rs +++ b/coincube-gui/src/app/view/liquid/send.rs @@ -1,7 +1,4 @@ -use breez_sdk_liquid::{ - model::{PaymentDetails, PaymentState}, - InputType, -}; +use breez_sdk_liquid::InputType; use coincube_core::miniscript::bitcoin::Amount; use coincube_ui::{ color, @@ -20,7 +17,8 @@ use iced::{ Alignment, Background, Length, }; -use crate::app::breez::assets::{format_usdt_display, AssetKind}; +use crate::app::breez_liquid::assets::{format_usdt_display, AssetKind}; +use crate::app::wallets::{DomainPaymentDetails, DomainPaymentStatus}; use crate::app::menu::Menu; use crate::app::state::liquid::send::{LiquidSendFlowState, Modal, ReceiveNetwork, SendAsset}; use crate::app::view::{ @@ -485,14 +483,20 @@ pub fn liquid_send_view<'a>( // Determine combo icon from payment details let tx_icon = match &tx.details { - PaymentDetails::Lightning { .. } => asset_network_logo("btc", "lightning", 40.0), - PaymentDetails::Liquid { asset_id, .. } + DomainPaymentDetails::Lightning { .. } => { + asset_network_logo("btc", "lightning", 40.0) + } + DomainPaymentDetails::LiquidAsset { asset_id, .. } if !usdt_asset_id.is_empty() && asset_id == usdt_asset_id => { asset_network_logo("usdt", "liquid", 40.0) } - PaymentDetails::Liquid { .. } => asset_network_logo("lbtc", "liquid", 40.0), - PaymentDetails::Bitcoin { .. } => asset_network_logo("btc", "bitcoin", 40.0), + DomainPaymentDetails::LiquidAsset { .. } => { + asset_network_logo("lbtc", "liquid", 40.0) + } + DomainPaymentDetails::OnChainBitcoin { .. } => { + asset_network_logo("btc", "bitcoin", 40.0) + } }; let mut item = TransactionListItem::new(direction, &display_amount, bitcoin_unit) @@ -505,7 +509,7 @@ pub fn liquid_send_view<'a>( item = item.with_amount_override(usdt_str.clone()); } - if matches!(tx.status, PaymentState::Pending) { + if matches!(tx.status, DomainPaymentStatus::Pending) { let (bg, fg) = (color::GREY_3, color::BLACK); let pending_badge = Container::new( Row::new() @@ -792,8 +796,8 @@ pub struct RecentTransaction { pub fees_sat: Amount, pub fiat_amount: Option, pub is_incoming: bool, - pub status: PaymentState, - pub details: PaymentDetails, + pub status: DomainPaymentStatus, + pub details: DomainPaymentDetails, /// When set, the transaction displays this string instead of the BTC amount (e.g. "5.00 USDt"). pub usdt_display: Option, } @@ -1505,7 +1509,7 @@ pub fn final_check_page<'a>( if let Some(asset_fee) = usdt_asset_fees { // Fees paid in USDt — convert f64 to base units for consistent formatting let fee_base = (asset_fee - * 10_u64.pow(crate::app::breez::assets::USDT_PRECISION as u32) as f64) + * 10_u64.pow(crate::app::breez_liquid::assets::USDT_PRECISION as u32) as f64) .ceil() as u64; details_box = details_box.push( Row::new() @@ -1514,7 +1518,7 @@ pub fn final_check_page<'a>( .push( text(format!( "{} USDt", - crate::app::breez::assets::format_usdt_display(fee_base) + crate::app::breez_liquid::assets::format_usdt_display(fee_base) )) .size(16) .bold(), diff --git a/coincube-gui/src/app/view/liquid/sideshift_send.rs b/coincube-gui/src/app/view/liquid/sideshift_send.rs index e207a24a3..b5d1befd3 100644 --- a/coincube-gui/src/app/view/liquid/sideshift_send.rs +++ b/coincube-gui/src/app/view/liquid/sideshift_send.rs @@ -11,12 +11,12 @@ use iced::{ Alignment, Length, }; -use crate::app::breez::assets::format_usdt_display; +use crate::app::breez_liquid::assets::format_usdt_display; use crate::app::state::liquid::sideshift_send::SendPhase; use crate::app::view::liquid::RecentTransaction; use crate::app::view::{SideshiftSendMessage, SideshiftShiftType}; +use crate::app::wallets::DomainPaymentDetails; use crate::services::sideshift::{ShiftResponse, ShiftStatusKind, SideshiftNetwork}; -use breez_sdk_liquid::model::PaymentDetails; // --------------------------------------------------------------------------- // Top-level entry point @@ -238,15 +238,16 @@ fn address_input_view<'a>( tx_list = tx_list.push(h4_bold("Last transactions")); for tx in recent_transactions.iter().take(5) { - let usdt_amount = if let PaymentDetails::Liquid { asset_id, .. } = &tx.details { - if !usdt_asset_id.is_empty() && asset_id == usdt_asset_id { - Some(format_usdt_display(tx.amount.to_sat())) + let usdt_amount = + if let DomainPaymentDetails::LiquidAsset { asset_id, .. } = &tx.details { + if !usdt_asset_id.is_empty() && asset_id == usdt_asset_id { + Some(format_usdt_display(tx.amount.to_sat())) + } else { + None + } } else { None - } - } else { - None - }; + }; let amount_text = if let Some(ref usdt) = usdt_amount { format!("{}{} USDt", if tx.is_incoming { "+ " } else { "- " }, usdt) diff --git a/coincube-gui/src/app/view/liquid/transactions.rs b/coincube-gui/src/app/view/liquid/transactions.rs index fe5dd0b91..e6b1dac87 100644 --- a/coincube-gui/src/app/view/liquid/transactions.rs +++ b/coincube-gui/src/app/view/liquid/transactions.rs @@ -1,8 +1,10 @@ -use breez_sdk_liquid::model::{PaymentDetails, PaymentState}; -use breez_sdk_liquid::prelude::{Payment, PaymentType, RefundableSwap}; use coincube_core::miniscript::bitcoin::Amount; use iced::widget::image; +use crate::app::wallets::{ + DomainPayment, DomainPaymentDetails, DomainPaymentStatus, DomainRefundableSwap, +}; + use coincube_ui::{ component::{ amount::DisplayAmount, @@ -21,7 +23,7 @@ use iced::{ use coincube_ui::image::asset_network_logo; -use crate::app::breez::assets::{format_usdt_display, USDT_PRECISION}; +use crate::app::breez_liquid::assets::format_usdt_display; use crate::app::menu::Menu; use crate::app::state::liquid::transactions::AssetFilter; use crate::app::view::message::{FeeratePriority, Message}; @@ -30,22 +32,19 @@ use crate::export::ImportExportMessage; use crate::utils::{format_time_ago, format_timestamp}; /// Returns `Some(formatted_usdt_string)` when the payment is a USDt asset payment. -fn usdt_amount_str(payment: &Payment, usdt_id: &str) -> Option { - if let PaymentDetails::Liquid { +fn usdt_amount_str(payment: &DomainPayment, usdt_id: &str) -> Option { + if let DomainPaymentDetails::LiquidAsset { asset_id, asset_info, .. } = &payment.details { if !usdt_id.is_empty() && asset_id == usdt_id { - let display = if let Some(info) = asset_info { - format_usdt_display( - (info.amount * 10_f64.powi(USDT_PRECISION as i32)).round() as u64 - ) - } else { - format_usdt_display(payment.amount_sat) - }; - return Some(format!("{} USDt", display)); + let minor = asset_info + .as_ref() + .map(|i| i.amount_minor) + .unwrap_or(payment.amount_sat); + return Some(format!("{} USDt", format_usdt_display(minor))); } } None @@ -53,8 +52,8 @@ fn usdt_amount_str(payment: &Payment, usdt_id: &str) -> Option { #[allow(clippy::too_many_arguments)] pub fn liquid_transactions_view<'a>( - payments: &'a [Payment], - refundables: &'a [RefundableSwap], + payments: &'a [DomainPayment], + refundables: &'a [DomainRefundableSwap], _balance: &'a Amount, fiat_converter: Option, _loading: bool, @@ -189,39 +188,21 @@ pub fn liquid_transactions_view<'a>( fn transaction_row<'a>( i: usize, - payment: &'a Payment, + payment: &'a DomainPayment, fiat_converter: Option, bitcoin_unit: coincube_ui::component::amount::BitcoinDisplayUnit, usdt_id: &str, show_direction_badges: bool, ) -> Element<'a, Message> { - let is_receive = matches!(payment.payment_type, PaymentType::Receive); + let is_receive = payment.is_incoming(); let usdt_str = usdt_amount_str(payment, usdt_id); // Extract description — label USDt payments explicitly let is_usdt = usdt_str.is_some(); - let description: &str = if is_usdt { - "USDt Transfer" + let description: String = if is_usdt { + "USDt Transfer".to_owned() } else { - match &payment.details { - PaymentDetails::Lightning { - payer_note, - description, - .. - } => payer_note - .as_ref() - .filter(|s| !s.is_empty()) - .unwrap_or(description), - PaymentDetails::Liquid { - payer_note, - description, - .. - } => payer_note - .as_ref() - .filter(|s| !s.is_empty()) - .unwrap_or(description), - PaymentDetails::Bitcoin { description, .. } => description, - } + payment.details.description().to_owned() }; let time_ago = format_time_ago(payment.timestamp.into()); @@ -237,15 +218,17 @@ fn transaction_row<'a>( asset_network_logo("usdt", "liquid", 40.0) } else { match &payment.details { - PaymentDetails::Lightning { .. } => asset_network_logo("btc", "lightning", 40.0), - PaymentDetails::Liquid { .. } => asset_network_logo("lbtc", "liquid", 40.0), - PaymentDetails::Bitcoin { .. } => asset_network_logo("btc", "bitcoin", 40.0), + DomainPaymentDetails::Lightning { .. } => asset_network_logo("btc", "lightning", 40.0), + DomainPaymentDetails::LiquidAsset { .. } => asset_network_logo("lbtc", "liquid", 40.0), + DomainPaymentDetails::OnChainBitcoin { .. } => { + asset_network_logo("btc", "bitcoin", 40.0) + } } }; if let Some(ref usdt_display) = usdt_str { let item = TransactionListItem::new(direction, &Amount::ZERO, bitcoin_unit) - .with_label(description.to_string()) + .with_label(description.clone()) .with_time_ago(time_ago) .with_custom_icon(combo_icon) .with_show_direction_badge(show_direction_badges) @@ -259,7 +242,7 @@ fn transaction_row<'a>( } let mut item = TransactionListItem::new(direction, &btc_amount, bitcoin_unit) - .with_label(description.to_string()) + .with_label(description) .with_time_ago(time_ago) .with_custom_icon(combo_icon) .with_show_direction_badge(show_direction_badges); @@ -276,7 +259,7 @@ fn transaction_row<'a>( fn refundable_row<'a>( i: usize, - refundable: &'a RefundableSwap, + refundable: &'a DomainRefundableSwap, fiat_converter: Option, bitcoin_unit: coincube_ui::component::amount::BitcoinDisplayUnit, show_direction_badges: bool, @@ -303,12 +286,12 @@ fn refundable_row<'a>( } pub fn transaction_detail_view<'a>( - payment: &'a Payment, + payment: &'a DomainPayment, fiat_converter: Option, bitcoin_unit: coincube_ui::component::amount::BitcoinDisplayUnit, usdt_id: &str, ) -> Element<'a, Message> { - let is_receive = matches!(payment.payment_type, PaymentType::Receive); + let is_receive = payment.is_incoming(); let usdt_str = usdt_amount_str(payment, usdt_id); let btc_amount = Amount::from_sat(payment.amount_sat); let fees_sat = Amount::from_sat(payment.fees_sat); @@ -323,28 +306,10 @@ pub fn transaction_detail_view<'a>( format_timestamp(payment.timestamp as u64).unwrap_or_else(|| "Unknown".to_string()); // Extract description — label USDt payments explicitly - let description: &str = if usdt_str.is_some() { - "USDt Transfer" + let description: String = if usdt_str.is_some() { + "USDt Transfer".to_owned() } else { - match &payment.details { - PaymentDetails::Lightning { - payer_note, - description, - .. - } => payer_note - .as_ref() - .filter(|s| !s.is_empty()) - .unwrap_or(description), - PaymentDetails::Liquid { - payer_note, - description, - .. - } => payer_note - .as_ref() - .filter(|s| !s.is_empty()) - .unwrap_or(description), - PaymentDetails::Bitcoin { description, .. } => description, - } + payment.details.description().to_owned() }; let title = if is_receive { @@ -355,14 +320,14 @@ pub fn transaction_detail_view<'a>( // Helper: combo icon for detail view based on payment type let make_detail_icon = - |is_usdt: bool, details: &PaymentDetails| -> (&'static str, &'static str) { + |is_usdt: bool, details: &DomainPaymentDetails| -> (&'static str, &'static str) { if is_usdt { ("usdt", "liquid") } else { match details { - PaymentDetails::Lightning { .. } => ("btc", "lightning"), - PaymentDetails::Liquid { .. } => ("lbtc", "liquid"), - PaymentDetails::Bitcoin { .. } => ("btc", "bitcoin"), + DomainPaymentDetails::Lightning { .. } => ("btc", "lightning"), + DomainPaymentDetails::LiquidAsset { .. } => ("lbtc", "liquid"), + DomainPaymentDetails::OnChainBitcoin { .. } => ("btc", "bitcoin"), } } }; @@ -370,15 +335,10 @@ pub fn transaction_detail_view<'a>( if let Some(ref usdt_display) = usdt_str { // USDt detail view: show USDt amount + L-BTC fees let usdt_num = match &payment.details { - PaymentDetails::Liquid { asset_info, .. } => { - if let Some(info) = asset_info { - format_usdt_display( - (info.amount * 10_f64.powi(USDT_PRECISION as i32)).round() as u64 - ) - } else { - format_usdt_display(payment.amount_sat) - } - } + DomainPaymentDetails::LiquidAsset { asset_info, .. } => asset_info + .as_ref() + .map(|i| format_usdt_display(i.amount_minor)) + .unwrap_or_else(|| format_usdt_display(payment.amount_sat)), _ => format_usdt_display(payment.amount_sat), }; let amount_row = if is_receive { @@ -442,28 +402,28 @@ pub fn transaction_detail_view<'a>( ) .push(Column::new().width(Length::FillPortion(2)).push( match payment.status { - PaymentState::Complete => { + DomainPaymentStatus::Complete => { text("Complete").style(theme::text::success) } - PaymentState::Pending => { + DomainPaymentStatus::Pending => { text("Pending").style(theme::text::secondary) } - PaymentState::Created => { + DomainPaymentStatus::Created => { text("Created").style(theme::text::secondary) } - PaymentState::Failed => { + DomainPaymentStatus::Failed => { text("Failed").style(theme::text::destructive) } - PaymentState::TimedOut => { + DomainPaymentStatus::TimedOut => { text("Timed Out").style(theme::text::destructive) } - PaymentState::Refundable => { + DomainPaymentStatus::Refundable => { text("Refundable").style(theme::text::destructive) } - PaymentState::RefundPending => { + DomainPaymentStatus::RefundPending => { text("Refund Pending").style(theme::text::secondary) } - PaymentState::WaitingFeeAcceptance => { + DomainPaymentStatus::WaitingFeeAcceptance => { text("Waiting Fee Acceptance").style(theme::text::secondary) } }, @@ -588,28 +548,28 @@ pub fn transaction_detail_view<'a>( ) .push(Column::new().width(Length::FillPortion(2)).push( match payment.status { - PaymentState::Complete => { + DomainPaymentStatus::Complete => { text("Complete").style(theme::text::success) } - PaymentState::Pending => { + DomainPaymentStatus::Pending => { text("Pending").style(theme::text::secondary) } - PaymentState::Created => { + DomainPaymentStatus::Created => { text("Created").style(theme::text::secondary) } - PaymentState::Failed => { + DomainPaymentStatus::Failed => { text("Failed").style(theme::text::destructive) } - PaymentState::TimedOut => { + DomainPaymentStatus::TimedOut => { text("Timed Out").style(theme::text::destructive) } - PaymentState::Refundable => { + DomainPaymentStatus::Refundable => { text("Refundable").style(theme::text::destructive) } - PaymentState::RefundPending => { + DomainPaymentStatus::RefundPending => { text("Refund Pending").style(theme::text::secondary) } - PaymentState::WaitingFeeAcceptance => { + DomainPaymentStatus::WaitingFeeAcceptance => { text("Waiting Fee Acceptance").style(theme::text::secondary) } }, @@ -650,7 +610,7 @@ pub fn transaction_detail_view<'a>( } pub fn refundable_detail_view<'a>( - refundable: &'a RefundableSwap, + refundable: &'a DomainRefundableSwap, fiat_converter: Option, bitcoin_unit: coincube_ui::component::amount::BitcoinDisplayUnit, refund_address: &'a form::Value, diff --git a/coincube-gui/src/app/view/message.rs b/coincube-gui/src/app/view/message.rs index 89b052c66..d1d83e23f 100644 --- a/coincube-gui/src/app/view/message.rs +++ b/coincube-gui/src/app/view/message.rs @@ -23,9 +23,9 @@ pub enum FeeratePriority { High, } -use breez_sdk_liquid::prelude::{ - InputType, Payment, PreparePayOnchainResponse, PrepareSendResponse, -}; +use breez_sdk_liquid::prelude::{InputType, PreparePayOnchainResponse, PrepareSendResponse}; + +use crate::app::wallets::DomainPayment; use coincube_core::miniscript::bitcoin::Amount; use coincube_core::miniscript::bitcoin::{bip32::Fingerprint, Address, OutPoint}; use coincube_core::spend::SpendCreationError; @@ -59,6 +59,7 @@ pub enum Message { Clipboard(String), Menu(Menu), ToggleVault, + ToggleSpark, ToggleLiquid, ToggleMarketplace, ToggleMarketplaceP2P, @@ -93,11 +94,16 @@ pub enum Message { OpenUrl(String), Home(HomeMessage), LiquidOverview(LiquidOverviewMessage), + SparkOverview(crate::app::view::spark::SparkOverviewMessage), + SparkTransactions(crate::app::view::spark::SparkTransactionsMessage), + SparkSettings(crate::app::view::spark::SparkSettingsMessage), + SparkSend(crate::app::view::spark::SparkSendMessage), + SparkReceive(crate::app::view::spark::SparkReceiveMessage), LiquidReceive(LiquidReceiveMessage), VaultReceive(VaultReceiveMessage), LiquidSend(LiquidSendMessage), LiquidSettings(LiquidSettingsMessage), - PreselectPayment(Payment), + PreselectPayment(DomainPayment), SetAssetFilter(crate::app::state::liquid::transactions::AssetFilter), ShowError(String), ShowSuccess(String), @@ -407,7 +413,7 @@ pub enum LiquidOverviewMessage { DataLoaded { balance: Amount, usdt_balance: u64, - recent_payment: Vec, + recent_payment: Vec, }, Error(String), RefreshRequested, @@ -425,7 +431,7 @@ pub enum LiquidSendMessage { DataLoaded { balance: Amount, usdt_balance: u64, - recent_payment: Vec, + recent_payment: Vec, }, Error(String), ClearError, @@ -516,7 +522,7 @@ pub enum LiquidReceiveMessage { DataLoaded { btc_balance: coincube_core::miniscript::bitcoin::Amount, usdt_balance: u64, - recent_payment: Vec, + recent_payment: Vec, }, /// User tapped a recent transaction row. SelectTransaction(usize), diff --git a/coincube-gui/src/app/view/mod.rs b/coincube-gui/src/app/view/mod.rs index 021c711f1..513a67e20 100644 --- a/coincube-gui/src/app/view/mod.rs +++ b/coincube-gui/src/app/view/mod.rs @@ -6,6 +6,7 @@ pub mod global_home; pub mod liquid; pub mod p2p; pub mod settings; +pub mod spark; pub mod vault; @@ -13,6 +14,11 @@ use std::iter::FromIterator; pub use liquid::*; pub use message::*; +pub use spark::{ + SparkOverviewMessage, SparkOverviewView, SparkReceiveMessage, SparkReceiveView, + SparkSendMessage, SparkSendView, SparkSettingsMessage, SparkSettingsStatus, SparkSettingsView, + SparkStatus, SparkTransactionsMessage, SparkTransactionsStatus, SparkTransactionsView, +}; pub use vault::fiat::FiatAmountConverter; pub use vault::warning::warn; @@ -158,6 +164,141 @@ pub fn sidebar<'a>( menu_column = menu_column.push(home_button); + // ── Spark wallet section ────────────────────────────────────────────── + // + // Sits above Liquid in the sidebar because Spark is the default + // wallet for everyday Lightning UX; Liquid is the advanced slot + // for L-BTC, USDt, and other Liquid-native flows. + let is_spark_expanded = cache.spark_expanded; + + let spark_chevron = if is_spark_expanded { + up_icon() + } else { + down_icon() + }; + let spark_button = Button::new( + Row::new() + .spacing(10) + .align_y(iced::alignment::Vertical::Center) + .push(coincube_ui::icon::lightning_icon().style(theme::text::secondary)) + .push(text("Spark").size(15)) + .push(Space::new().width(Length::Fill)) + .push(spark_chevron.style(theme::text::secondary)) + .padding(10), + ) + .width(iced::Length::Fill) + .style(theme::button::menu) + .on_press(Message::ToggleSpark); + + menu_column = menu_column.push(spark_button); + + if is_spark_expanded { + use crate::app::menu::SparkSubMenu; + + let spark_overview_button = if matches!(menu, Menu::Spark(SparkSubMenu::Overview)) { + row!( + Space::new().width(Length::Fixed(20.0)), + button::menu_active(Some(home_icon()), "Overview") + .on_press(Message::Reload) + .width(iced::Length::Fill), + menu_bar_highlight() + ) + .width(Length::Fill) + } else { + row!( + Space::new().width(Length::Fixed(20.0)), + button::menu(Some(home_icon()), "Overview") + .on_press(Message::Menu(Menu::Spark(SparkSubMenu::Overview))) + .width(iced::Length::Fill), + ) + .width(Length::Fill) + }; + + let spark_send_button = if matches!(menu, Menu::Spark(SparkSubMenu::Send)) { + row!( + Space::new().width(Length::Fixed(20.0)), + button::menu_active(Some(send_icon()), "Send") + .on_press(Message::Reload) + .width(iced::Length::Fill), + menu_bar_highlight() + ) + .width(Length::Fill) + } else { + row!( + Space::new().width(Length::Fixed(20.0)), + button::menu(Some(send_icon()), "Send") + .on_press(Message::Menu(Menu::Spark(SparkSubMenu::Send))) + .width(iced::Length::Fill), + ) + .width(Length::Fill) + }; + + let spark_receive_button = if matches!(menu, Menu::Spark(SparkSubMenu::Receive)) { + row!( + Space::new().width(Length::Fixed(20.0)), + button::menu_active(Some(receive_icon()), "Receive") + .on_press(Message::Reload) + .width(iced::Length::Fill), + menu_bar_highlight() + ) + .width(Length::Fill) + } else { + row!( + Space::new().width(Length::Fixed(20.0)), + button::menu(Some(receive_icon()), "Receive") + .on_press(Message::Menu(Menu::Spark(SparkSubMenu::Receive))) + .width(iced::Length::Fill), + ) + .width(Length::Fill) + }; + + let spark_transactions_button = + if matches!(menu, Menu::Spark(SparkSubMenu::Transactions(_))) { + row!( + Space::new().width(Length::Fixed(20.0)), + button::menu_active(Some(receipt_icon()), "Transactions") + .on_press(Message::Reload) + .width(iced::Length::Fill), + menu_bar_highlight() + ) + .width(Length::Fill) + } else { + row!( + Space::new().width(Length::Fixed(20.0)), + button::menu(Some(receipt_icon()), "Transactions") + .on_press(Message::Menu(Menu::Spark(SparkSubMenu::Transactions(None)))) + .width(iced::Length::Fill), + ) + .width(Length::Fill) + }; + + let spark_settings_button = if matches!(menu, Menu::Spark(SparkSubMenu::Settings(_))) { + row!( + Space::new().width(Length::Fixed(20.0)), + button::menu_active(Some(settings_icon()), "Settings") + .on_press(Message::Reload) + .width(iced::Length::Fill), + menu_bar_highlight() + ) + .width(Length::Fill) + } else { + row!( + Space::new().width(Length::Fixed(20.0)), + button::menu(Some(settings_icon()), "Settings") + .on_press(Message::Menu(Menu::Spark(SparkSubMenu::Settings(None)))) + .width(iced::Length::Fill), + ) + .width(Length::Fill) + }; + + menu_column = menu_column + .push(spark_overview_button) + .push(spark_send_button) + .push(spark_receive_button) + .push(spark_transactions_button) + .push(spark_settings_button); + } + // Check if Liquid submenu is expanded from cache let is_liquid_expanded = cache.liquid_expanded; diff --git a/coincube-gui/src/app/view/spark/mod.rs b/coincube-gui/src/app/view/spark/mod.rs new file mode 100644 index 000000000..4def15284 --- /dev/null +++ b/coincube-gui/src/app/view/spark/mod.rs @@ -0,0 +1,135 @@ +//! View-layer types for the Spark wallet panels. +//! +//! Modules: Overview (balance + "Stable" badge), Send (BOLT11 / +//! BIP21 / LNURL-pay), Receive (BOLT11 invoice, on-chain deposit +//! address with claim lifecycle), Transactions (recent payments), +//! and Settings (Stable Balance toggle, default Lightning backend +//! picker, diagnostics). [`SparkPlaceholderView`] is kept around +//! as a generic "coming soon" slot for future panels. + +pub mod overview; +pub mod receive; +pub mod send; +pub mod settings; +pub mod transactions; + +pub use overview::{SparkOverviewView, SparkStatus}; +pub use receive::SparkReceiveView; +pub use send::SparkSendView; +pub use settings::{SparkSettingsStatus, SparkSettingsView}; +pub use transactions::{SparkTransactionsStatus, SparkTransactionsView}; + +/// View-level messages for the Spark Overview panel. +#[derive(Debug, Clone)] +pub enum SparkOverviewMessage { + /// Bridge returned `get_info` success — carries a snapshot. + DataLoaded(crate::app::state::spark::overview::SparkBalanceSnapshot), + /// Bridge returned an error response for `get_info`. + Error(String), + /// Phase 6: bridge returned the current Stable Balance flag, + /// fetched alongside `get_info` in `reload`. Drives the + /// "Stable" badge next to the balance line. + StableBalanceLoaded(bool), +} + +/// View-level messages for the Phase 4b Spark Transactions panel. +#[derive(Debug, Clone)] +pub enum SparkTransactionsMessage { + /// Bridge returned `list_payments` success — carries the page. + DataLoaded(Vec), + /// Bridge returned an error response for `list_payments`. + Error(String), +} + +/// View-level messages for the Phase 4b Spark Settings panel. +#[derive(Debug, Clone)] +pub enum SparkSettingsMessage { + /// Bridge returned `get_info` success — carries a snapshot used + /// for the read-only diagnostics view. + DataLoaded(crate::app::state::spark::settings::SparkSettingsSnapshot), + /// Bridge returned an error response. + Error(String), + /// Phase 5: user clicked a radio in the "Default Lightning backend" + /// picker. The state panel persists the new value to the cube + /// settings file and emits `Message::SettingsSaved` on success. + DefaultLightningBackendChanged(crate::app::wallets::WalletKind), + /// Phase 5: the background save task finished. Carries an error + /// string if persistence failed, else `None`. + DefaultLightningBackendSaved(Option), + /// Phase 6: bridge returned the current Stable Balance + private + /// mode state. Fired from the panel's `reload` task so the view + /// can reflect whatever the SDK persisted across restarts. + UserSettingsLoaded(coincube_spark_protocol::GetUserSettingsOk), + /// Phase 6: the user flipped the Stable Balance toggle — fires a + /// `set_stable_balance` RPC on the bridge. + StableBalanceToggled(bool), + /// Phase 6: `set_stable_balance` RPC finished. `Ok(enabled)` + /// carries the new state so the view can update immediately + /// without re-fetching; `Err` surfaces the SDK error. + StableBalanceSaved(Result), +} + +/// View-level messages for the Phase 4c Spark Send panel. Drives the +/// state machine in [`crate::app::state::spark::send::SparkSend`]. +#[derive(Debug, Clone)] +pub enum SparkSendMessage { + DestinationInputChanged(String), + AmountInputChanged(String), + PrepareRequested, + PrepareSucceeded(coincube_spark_protocol::PrepareSendOk), + PrepareFailed(String), + ConfirmRequested, + SendSucceeded(coincube_spark_protocol::SendPaymentOk), + SendFailed(String), + /// Reset back to the `Idle` phase, clearing inputs and any + /// prepared/sent state. Fired from the "Send another" / "Try + /// again" / "Cancel" buttons. + Reset, +} + +/// View-level messages for the Phase 4c Spark Receive panel. +#[derive(Debug, Clone)] +pub enum SparkReceiveMessage { + MethodSelected(crate::app::state::spark::receive::SparkReceiveMethod), + AmountInputChanged(String), + DescriptionInputChanged(String), + GenerateRequested, + GenerateSucceeded(coincube_spark_protocol::ReceivePaymentOk), + GenerateFailed(String), + /// Forwarded from the app-level Spark event handler when a + /// `PaymentSucceeded` event arrives. Carries the payment's + /// amount (signed sats — positive for incoming) and an optional + /// BOLT11 string from the SDK Payment's details. Phase 4f + /// uses the BOLT11 to correlate against the panel's currently + /// displayed invoice — events for unrelated payments are + /// ignored. Pre-Phase-4f BOLT11-less events (Spark-native / + /// on-chain / token) still trigger the auto-advance. + PaymentReceived { + amount_sat: i64, + bolt11: Option, + }, + /// Phase 4f: a `Method::ListUnclaimedDeposits` RPC came back with + /// a fresh deposit list. + PendingDepositsLoaded(Vec), + /// Phase 4f: a `Method::ListUnclaimedDeposits` RPC failed. We + /// log + clear the displayed list rather than surface a hard + /// error in the UI — the panel's primary purpose is generating + /// invoices, not managing deposits, so a deposits-list failure + /// shouldn't block the rest of the panel. + PendingDepositsFailed(String), + /// Phase 4f: user clicked "Claim" on a specific (txid, vout). + ClaimDepositRequested { + txid: String, + vout: u32, + }, + /// Phase 4f: a `claim_deposit` RPC succeeded. Triggers a deposits + /// reload so the row disappears. + ClaimDepositSucceeded(coincube_spark_protocol::ClaimDepositOk), + /// Phase 4f: a `claim_deposit` RPC failed. Surface the SDK error + /// in the panel and keep the row. + ClaimDepositFailed(String), + /// Phase 4f: app-level signal that the bridge emitted a + /// `DepositsChanged` event. The panel re-fetches the list. + DepositsChanged, + Reset, +} diff --git a/coincube-gui/src/app/view/spark/overview.rs b/coincube-gui/src/app/view/spark/overview.rs new file mode 100644 index 000000000..5363935a3 --- /dev/null +++ b/coincube-gui/src/app/view/spark/overview.rs @@ -0,0 +1,97 @@ +//! View renderer for the Spark Overview panel. +//! +//! Renders one of four states as plain text + a small heading: +//! - [`SparkStatus::Unavailable`] — the cube has no Spark signer or the +//! bridge subprocess failed to spawn. +//! - [`SparkStatus::Loading`] — first `get_info` still in flight. +//! - [`SparkStatus::Connected`] — bridge returned balance + pubkey. +//! Renders a "Stable" badge next to the balance when the SDK +//! reports an active Stable Balance token. +//! - [`SparkStatus::Error`] — bridge returned an error response. +//! +//! A richer layout (balance card, fiat conversion, recent transactions) +//! copied from the Liquid panel is a future polish pass. + +use coincube_ui::{ + component::text::{h2, p1_regular, p2_regular}, + widget::{Column, Container, Element, Row}, +}; +use iced::Length; +use iced::widget::Space; +use iced::Alignment; + +use crate::app::state::spark::overview::SparkBalanceSnapshot; +use crate::app::view::Message; + +/// High-level status of the Spark backend for the current cube. +#[derive(Debug, Clone)] +pub enum SparkStatus { + /// No Spark signer configured for this cube (or bridge spawn failed). + Unavailable, + /// First `get_info` is still in flight. + Loading, + /// Bridge returned a balance snapshot. + Connected(SparkBalanceSnapshot), + /// Bridge returned an error response. + Error(String), +} + +/// View wrapper that knows how to render a [`SparkStatus`] as the Spark +/// Overview panel. +pub struct SparkOverviewView { + pub status: SparkStatus, + /// Phase 6: `true` when the SDK reports an active Stable Balance + /// token. Drives the "Stable" badge rendered next to the balance + /// line in the `Connected` state. + pub stable_balance_active: bool, +} + +impl SparkOverviewView { + /// Build the Element to hand to [`crate::app::view::dashboard`]. + pub fn render<'a>(self) -> Element<'a, Message> { + let heading = Container::new(h2("Spark Wallet")); + + let body: Element<'_, Message> = match self.status { + SparkStatus::Unavailable => Column::new() + .push(p1_regular( + "Spark is not configured for this cube yet. Set up a Spark \ + signer and restart the app to connect the bridge.", + )) + .into(), + SparkStatus::Loading => Column::new() + .push(p1_regular("Connecting to the Spark bridge…")) + .into(), + SparkStatus::Connected(snapshot) => { + let mut balance_row = Row::new() + .spacing(10) + .align_y(Alignment::Center) + .push(p1_regular(format!("Balance: {} sats", snapshot.balance_sats))); + if self.stable_balance_active { + balance_row = balance_row.push(p2_regular("· Stable")); + } + Column::new() + .spacing(10) + .push(balance_row) + .push(p2_regular(format!("Identity: {}", snapshot.identity_pubkey))) + .push(Space::new().height(Length::Fixed(12.0))) + .push(p2_regular( + "This is the Phase 3 Spark Overview stub. Full panels \ + (Send, Receive, Transactions, Settings) land in the next \ + phase.", + )) + .into() + } + SparkStatus::Error(err) => Column::new() + .spacing(10) + .push(p1_regular("Spark bridge error")) + .push(p2_regular(err)) + .into(), + }; + + Column::new() + .spacing(20) + .push(heading) + .push(body) + .into() + } +} diff --git a/coincube-gui/src/app/view/spark/receive.rs b/coincube-gui/src/app/view/spark/receive.rs new file mode 100644 index 000000000..ebb82bd1a --- /dev/null +++ b/coincube-gui/src/app/view/spark/receive.rs @@ -0,0 +1,428 @@ +//! View renderer for [`crate::app::state::spark::receive::SparkReceive`]. +//! +//! Phase 4c ships the minimum viable Receive UI: method radio buttons, +//! BOLT11 amount/description inputs (hidden for on-chain), a Generate +//! button, and a result card that shows the invoice/address as a +//! selectable text field. QR codes, copy animations, Lightning Address +//! display, and the on-chain claim-deposit lifecycle land in Phase 4d. + +use coincube_ui::{ + component::{ + button, + text::{h2, h4_bold, p1_regular, p2_regular}, + }, + theme, + widget::{Column, Container, Element, Row}, +}; +use iced::{ + widget::{qr_code, text_input, Space, QRCode}, + Alignment, Length, +}; + +use coincube_spark_protocol::DepositInfo; + +use crate::app::state::spark::receive::{SparkReceiveMethod, SparkReceivePhase}; +use crate::app::view::Message; + +pub struct SparkReceiveView<'a> { + pub backend_available: bool, + pub method: SparkReceiveMethod, + pub amount_input: &'a str, + pub description_input: &'a str, + pub phase: &'a SparkReceivePhase, + pub qr_data: Option<&'a qr_code::Data>, + /// Phase 4f: list of pending on-chain deposits surfaced from the + /// SDK's `list_unclaimed_deposits` RPC. Rendered as a dedicated + /// card below the main phase body. + pub pending_deposits: &'a [DepositInfo], + /// Phase 4f: which deposit is currently being claimed (in-flight + /// RPC). Used to disable the row's button while waiting. + pub claiming: Option<&'a (String, u32)>, + /// Phase 4f: transient error from the most recent claim attempt. + pub claim_error: Option<&'a str>, +} + +impl<'a> SparkReceiveView<'a> { + pub fn render(self) -> Element<'a, Message> { + let heading = Container::new(h2("Spark — Receive")); + + if !self.backend_available { + return Column::new() + .spacing(20) + .push(heading) + .push(p1_regular( + "Spark is not available for this cube. Set up a Spark \ + signer to receive payments.", + )) + .into(); + } + + let mut content = Column::new().spacing(20).push(heading); + + // ── Method picker ───────────────────────────────────────────── + let method_picker = Container::new( + Column::new() + .spacing(10) + .push(h4_bold("Method")) + .push( + Row::new() + .spacing(10) + .push(method_chip( + "Lightning (BOLT11)", + self.method == SparkReceiveMethod::Bolt11, + SparkReceiveMethod::Bolt11, + )) + .push(method_chip( + "On-chain Bitcoin", + self.method == SparkReceiveMethod::OnchainBitcoin, + SparkReceiveMethod::OnchainBitcoin, + )), + ), + ) + .padding(16) + .style(theme::card::simple); + content = content.push(method_picker); + + // ── Method-specific inputs ──────────────────────────────────── + if self.method == SparkReceiveMethod::Bolt11 { + let amount = text_input("Amount in sats (optional)", self.amount_input) + .on_input(|v| { + Message::SparkReceive( + crate::app::view::SparkReceiveMessage::AmountInputChanged(v), + ) + }) + .padding(10); + + let description = text_input( + "Description shown to the payer (optional)", + self.description_input, + ) + .on_input(|v| { + Message::SparkReceive( + crate::app::view::SparkReceiveMessage::DescriptionInputChanged(v), + ) + }) + .padding(10); + + let form_card = Container::new( + Column::new() + .spacing(10) + .push(h4_bold("Invoice details")) + .push(amount) + .push(Space::new().height(Length::Fixed(6.0))) + .push(description), + ) + .padding(16) + .style(theme::card::simple); + content = content.push(form_card); + } else { + // On-chain: nothing to configure in Phase 4c. Explain the + // deposit model so the user knows what to expect. + let info_card = Container::new( + Column::new() + .spacing(8) + .push(h4_bold("Spark on-chain receive")) + .push(p2_regular( + "Spark uses a deposit-address model — the user sends \ + BTC to the address below, the bridge notices the \ + incoming tx, and the funds become spendable after \ + the SDK claims the deposit (automatic background \ + process in a future phase; today the user may need \ + to restart the cube to surface the claim). Phase \ + 4d wires the explicit claim lifecycle into the UI.", + )), + ) + .padding(16) + .style(theme::card::simple); + content = content.push(info_card); + } + + // ── Phase-specific body ─────────────────────────────────────── + content = content.push(phase_body(self.phase, self.qr_data)); + + // ── Phase 4f: pending on-chain deposits card ────────────────── + // Renders only when there's something to show. The list + // refreshes automatically on `DepositsChanged` events from + // the bridge, so the card appears the moment the SDK + // observes an incoming deposit (no manual refresh needed). + if !self.pending_deposits.is_empty() || self.claim_error.is_some() { + content = content.push(pending_deposits_card( + self.pending_deposits, + self.claiming, + self.claim_error, + )); + } + + content.into() + } +} + +fn pending_deposits_card<'a>( + deposits: &'a [DepositInfo], + claiming: Option<&'a (String, u32)>, + claim_error: Option<&'a str>, +) -> Element<'a, Message> { + let mut card = Column::new() + .spacing(12) + .push(h4_bold("Pending deposits")) + .push(p2_regular( + "Spark notices incoming on-chain transactions automatically. \ + Mature deposits can be claimed into your wallet below.", + )); + + if let Some(err) = claim_error { + card = card.push(p2_regular(format!("Claim failed: {}", err))); + } + + for deposit in deposits { + card = card.push(deposit_row(deposit, claiming)); + } + + Container::new(card) + .padding(16) + .style(theme::card::simple) + .into() +} + +fn deposit_row<'a>( + deposit: &'a DepositInfo, + claiming: Option<&'a (String, u32)>, +) -> Element<'a, Message> { + let is_being_claimed = claiming + .map(|(txid, vout)| txid == &deposit.txid && *vout == deposit.vout) + .unwrap_or(false); + + let amount_label = format!("{} sats", deposit.amount_sat); + let txid_short = if deposit.txid.len() > 16 { + format!("{}…{}", &deposit.txid[..8], &deposit.txid[deposit.txid.len() - 8..]) + } else { + deposit.txid.clone() + }; + let txid_label = format!("{}:{}", txid_short, deposit.vout); + + // Right-side action: either a Claim button (mature, idle), a + // disabled "Claiming…" button (in-flight), or a status text + // (immature / errored). + let action: Element<'_, Message> = if is_being_claimed { + // Disabled button — `on_press_maybe(None)` keeps the visual + // weight without firing on click. + button::primary(None, "Claiming…") + .on_press_maybe(None) + .width(Length::Fixed(140.0)) + .into() + } else if !deposit.is_mature { + // Immature — show waiting status, no button. + p2_regular("Waiting for confirmation").into() + } else if let Some(err) = &deposit.claim_error { + // Previous claim attempt failed for a reason the SDK + // surfaces. Show a short hint + Retry button. + Row::new() + .spacing(8) + .align_y(Alignment::Center) + .push(p2_regular(format!("Error: {}", short_error(err)))) + .push( + button::secondary(None, "Retry") + .on_press(Message::SparkReceive( + crate::app::view::SparkReceiveMessage::ClaimDepositRequested { + txid: deposit.txid.clone(), + vout: deposit.vout, + }, + )) + .width(Length::Fixed(100.0)), + ) + .into() + } else { + button::primary(None, "Claim") + .on_press(Message::SparkReceive( + crate::app::view::SparkReceiveMessage::ClaimDepositRequested { + txid: deposit.txid.clone(), + vout: deposit.vout, + }, + )) + .width(Length::Fixed(140.0)) + .into() + }; + + Container::new( + Row::new() + .spacing(12) + .align_y(Alignment::Center) + .push( + Column::new() + .spacing(4) + .width(Length::Fill) + .push(p1_regular(amount_label)) + .push(p2_regular(txid_label)), + ) + .push(action), + ) + .padding(10) + .style(theme::card::simple) + .into() +} + +/// Truncate a long SDK error string for inline display. The full +/// error stays in the panel state; this just keeps the row from +/// blowing up vertically. +fn short_error(err: &str) -> String { + const MAX: usize = 60; + if err.len() <= MAX { + err.to_string() + } else { + format!("{}…", &err[..MAX]) + } +} + +fn method_chip<'a>( + label: &'static str, + active: bool, + target: SparkReceiveMethod, +) -> Element<'a, Message> { + let btn = if active { + button::primary(None, label) + } else { + button::transparent_border(None, label) + }; + btn.on_press(Message::SparkReceive( + crate::app::view::SparkReceiveMessage::MethodSelected(target), + )) + .width(Length::Fixed(200.0)) + .into() +} + +fn phase_body<'a>( + phase: &'a SparkReceivePhase, + qr_data: Option<&'a qr_code::Data>, +) -> Element<'a, Message> { + use crate::app::view::SparkReceiveMessage; + + match phase { + SparkReceivePhase::Idle => Container::new( + Column::new() + .spacing(10) + .push( + button::primary(None, "Generate") + .on_press(Message::SparkReceive( + SparkReceiveMessage::GenerateRequested, + )) + .width(Length::Fixed(160.0)), + ), + ) + .padding(16) + .style(theme::card::simple) + .into(), + + SparkReceivePhase::Generating => Container::new( + Column::new() + .spacing(10) + .push(p1_regular( + "Generating… asking the Spark bridge for a payment request.", + )), + ) + .padding(16) + .style(theme::card::simple) + .into(), + + SparkReceivePhase::Generated(ok) => { + // QR code block — rendered only if we successfully + // encoded the payment request in state. Falls back to a + // plain "QR unavailable" note if encoding failed; for + // typical BOLT11 / BTC address payloads this branch + // should never fire in practice. + let qr_block: Element<'_, Message> = if let Some(qr) = qr_data { + Container::new(QRCode::::new(qr).cell_size(6)) + .align_x(iced::alignment::Horizontal::Center) + .width(Length::Fill) + .into() + } else { + p2_regular("QR code unavailable for this payload.").into() + }; + + let payment_request = ok.payment_request.clone(); + Container::new( + Column::new() + .spacing(14) + .align_x(Alignment::Center) + .push(h4_bold("Payment request")) + .push(qr_block) + .push(Space::new().height(Length::Fixed(6.0))) + // Click-to-select text_input as a manual-copy + // fallback when the button isn't enough. + .push(text_input("", &ok.payment_request).padding(10)) + .push( + Row::new() + .spacing(10) + .push( + button::primary(None, "Copy") + .on_press(Message::Clipboard(payment_request)) + .width(Length::Fixed(120.0)), + ) + .push( + button::transparent_border(None, "Generate another") + .on_press(Message::SparkReceive( + SparkReceiveMessage::Reset, + )) + .width(Length::Fixed(180.0)), + ), + ) + .push(kv_row("Fee", format!("{} sats", ok.fee_sat))), + ) + .padding(16) + .style(theme::card::simple) + .into() + } + + SparkReceivePhase::Received { amount_sat } => Container::new( + Column::new() + .spacing(14) + .align_x(Alignment::Center) + .push(h4_bold("Payment received")) + .push(p1_regular(format!("+{} sats", amount_sat.unsigned_abs()))) + .push(p2_regular( + "Your wallet balance has been updated. Check the \ + Transactions tab for the full record.", + )) + .push(Space::new().height(Length::Fixed(8.0))) + .push( + button::primary(None, "Generate another") + .on_press(Message::SparkReceive(SparkReceiveMessage::Reset)) + .width(Length::Fixed(180.0)), + ), + ) + .padding(16) + .style(theme::card::simple) + .into(), + + SparkReceivePhase::Error(err) => Container::new( + Column::new() + .spacing(10) + .push(h4_bold("Error")) + .push(p1_regular(err.clone())) + .push(Space::new().height(Length::Fixed(8.0))) + .push( + button::primary(None, "Try again") + .on_press(Message::SparkReceive(SparkReceiveMessage::Reset)) + .width(Length::Fixed(140.0)), + ), + ) + .padding(16) + .style(theme::card::simple) + .into(), + } +} + +fn kv_row<'a>(label: &'a str, value: String) -> Element<'a, Message> { + Row::new() + .spacing(20) + .push( + Column::new() + .width(Length::FillPortion(1)) + .push(h4_bold(label)), + ) + .push( + Column::new() + .width(Length::FillPortion(3)) + .push(p1_regular(value)), + ) + .into() +} diff --git a/coincube-gui/src/app/view/spark/send.rs b/coincube-gui/src/app/view/spark/send.rs new file mode 100644 index 000000000..a4bd4607c --- /dev/null +++ b/coincube-gui/src/app/view/spark/send.rs @@ -0,0 +1,227 @@ +//! View renderer for [`crate::app::state::spark::send::SparkSend`]. +//! +//! Phase 4c ships the minimum viable Send UI: destination input, amount +//! input, Prepare / Confirm / Try again buttons, and tiny status cards +//! for each phase of the state machine. Intentionally plain — +//! polish (fee tier picker, fiat estimate, address book, QR scanner) +//! lands in a later phase once the bridge write path has soaked a bit. + +use coincube_ui::{ + component::{ + button, + text::{h2, h4_bold, p1_regular, p2_regular}, + }, + theme, + widget::{Column, Container, Element, Row}, +}; +use iced::{ + widget::{text_input, Space}, + Length, +}; + +use crate::app::state::spark::send::SparkSendPhase; +use crate::app::view::Message; + +pub struct SparkSendView<'a> { + pub backend_available: bool, + pub destination_input: &'a str, + pub amount_input: &'a str, + pub phase: &'a SparkSendPhase, +} + +impl<'a> SparkSendView<'a> { + pub fn render(self) -> Element<'a, Message> { + let heading = Container::new(h2("Spark — Send")); + + if !self.backend_available { + return Column::new() + .spacing(20) + .push(heading) + .push(p1_regular( + "Spark is not available for this cube. Set up a Spark \ + signer to send payments.", + )) + .into(); + } + + let mut content = Column::new().spacing(20).push(heading); + + // ── Input card ──────────────────────────────────────────────── + let destination = text_input( + "BOLT11 invoice, Lightning address, BIP21 URI, or Bitcoin address", + self.destination_input, + ) + .on_input(|v| { + Message::SparkSend( + crate::app::view::SparkSendMessage::DestinationInputChanged(v), + ) + }) + .padding(10); + + let amount = text_input( + "Amount in sats (optional for invoices with amount)", + self.amount_input, + ) + .on_input(|v| { + Message::SparkSend(crate::app::view::SparkSendMessage::AmountInputChanged(v)) + }) + .padding(10); + + let input_card = Container::new( + Column::new() + .spacing(10) + .push(h4_bold("Destination")) + .push(destination) + .push(Space::new().height(Length::Fixed(8.0))) + .push(h4_bold("Amount")) + .push(amount), + ) + .padding(16) + .style(theme::card::simple); + content = content.push(input_card); + + // ── Phase-specific body ─────────────────────────────────────── + content = content.push(phase_body(self.phase)); + + content.into() + } +} + +fn phase_body<'a>(phase: &SparkSendPhase) -> Element<'a, Message> { + use crate::app::view::SparkSendMessage; + + match phase { + SparkSendPhase::Idle => Container::new( + Column::new() + .spacing(10) + .push(p2_regular( + "Enter a destination and amount above, then press Prepare \ + to see the fee quote.", + )) + .push(Space::new().height(Length::Fixed(8.0))) + .push( + button::primary(None, "Prepare") + .on_press(Message::SparkSend(SparkSendMessage::PrepareRequested)) + .width(Length::Fixed(160.0)), + ), + ) + .padding(16) + .style(theme::card::simple) + .into(), + + SparkSendPhase::Preparing => Container::new( + Column::new() + .spacing(10) + .push(p1_regular("Preparing send… asking the Spark bridge for a fee quote.")), + ) + .padding(16) + .style(theme::card::simple) + .into(), + + SparkSendPhase::Prepared(ok) => Container::new( + Column::new() + .spacing(14) + .push(h4_bold("Preview")) + .push(kv_row("Method", ok.method.clone())) + .push(kv_row("Amount", format!("{} sats", ok.amount_sat))) + .push(kv_row("Fee", format!("{} sats", ok.fee_sat))) + .push(kv_row( + "Total", + format!("{} sats", ok.amount_sat.saturating_add(ok.fee_sat)), + )) + .push(Space::new().height(Length::Fixed(8.0))) + .push( + Row::new() + .spacing(10) + .push( + button::primary(None, "Confirm and send") + .on_press(Message::SparkSend( + SparkSendMessage::ConfirmRequested, + )) + .width(Length::Fixed(200.0)), + ) + .push( + button::transparent_border(None, "Cancel") + .on_press(Message::SparkSend(SparkSendMessage::Reset)) + .width(Length::Fixed(120.0)), + ), + ), + ) + .padding(16) + .style(theme::card::simple) + .into(), + + SparkSendPhase::Sending => Container::new( + Column::new() + .spacing(10) + .push(p1_regular("Sending… waiting for the Spark SDK to settle the payment.")), + ) + .padding(16) + .style(theme::card::simple) + .into(), + + SparkSendPhase::Sent(ok) => { + // Clone the payment id so we can hand it to both the kv + // row (display) and the Copy button (Clipboard message). + let payment_id = ok.payment_id.clone(); + Container::new( + Column::new() + .spacing(14) + .push(h4_bold("Sent")) + .push(kv_row("Payment id", ok.payment_id.clone())) + .push(kv_row("Amount", format!("{} sats", ok.amount_sat))) + .push(kv_row("Fee", format!("{} sats", ok.fee_sat))) + .push(Space::new().height(Length::Fixed(8.0))) + .push( + Row::new() + .spacing(10) + .push( + button::secondary(None, "Copy id") + .on_press(Message::Clipboard(payment_id)) + .width(Length::Fixed(140.0)), + ) + .push( + button::primary(None, "Send another") + .on_press(Message::SparkSend(SparkSendMessage::Reset)) + .width(Length::Fixed(160.0)), + ), + ), + ) + .padding(16) + .style(theme::card::simple) + .into() + } + + SparkSendPhase::Error(err) => Container::new( + Column::new() + .spacing(10) + .push(h4_bold("Error")) + .push(p1_regular(err.clone())) + .push(Space::new().height(Length::Fixed(8.0))) + .push( + button::primary(None, "Try again") + .on_press(Message::SparkSend(SparkSendMessage::Reset)) + .width(Length::Fixed(140.0)), + ), + ) + .padding(16) + .style(theme::card::simple) + .into(), + } +} + +fn kv_row<'a>(label: &'a str, value: String) -> Element<'a, Message> { + Row::new() + .spacing(20) + .push( + Column::new() + .width(Length::FillPortion(1)) + .push(h4_bold(label)), + ) + .push( + Column::new() + .width(Length::FillPortion(3)) + .push(p1_regular(value)), + ) + .into() +} diff --git a/coincube-gui/src/app/view/spark/settings.rs b/coincube-gui/src/app/view/spark/settings.rs new file mode 100644 index 000000000..320c13234 --- /dev/null +++ b/coincube-gui/src/app/view/spark/settings.rs @@ -0,0 +1,287 @@ +//! View renderer for [`crate::app::state::spark::settings::SparkSettings`]. +//! +//! Renders: +//! - A "Stable Balance" toggle card (USD-pegging feature) with a +//! clear on/off status line and a toggle button. Disabled while +//! the bridge is unavailable or an `update_user_settings` RPC +//! is in flight. +//! - A "Default Lightning backend" picker card with Spark/Liquid +//! chips. Controls which backend fulfills incoming LN Address +//! invoices for this cube. +//! - A read-only diagnostics card (identity pubkey, balance, +//! network) fed by the bridge's `get_info` RPC. + +use coincube_core::miniscript::bitcoin::Network; +use coincube_ui::{ + component::{ + button, + text::{h2, h4_bold, p1_regular, p2_regular}, + }, + theme, + widget::{Column, Container, Element, Row}, +}; +use iced::widget::Space; +use iced::{Alignment, Length}; + +use crate::app::state::spark::settings::SparkSettingsSnapshot; +use crate::app::view::{Message, SparkSettingsMessage}; +use crate::app::wallets::WalletKind; + +#[derive(Debug, Clone)] +pub enum SparkSettingsStatus { + Unavailable, + Loading, + Error(String), + Loaded(SparkSettingsSnapshot), +} + +pub struct SparkSettingsView { + pub status: SparkSettingsStatus, + pub network: Network, + /// Current `default_lightning_backend` preference, read from + /// `Cache`. The picker reflects this value and fires a + /// `DefaultLightningBackendChanged` message on click. + pub default_lightning_backend: WalletKind, + /// Whether the Spark backend is actually available for this cube. + /// Drives whether the "Spark" chip is enabled — if the bridge is + /// down the user shouldn't be able to pick it as the default. + pub spark_available: bool, + /// Phase 6: Stable Balance on/off. `None` means the first + /// `get_user_settings` RPC hasn't returned yet — the toggle + /// renders as "Loading…" in that state. + pub stable_balance_active: Option, + /// Phase 6: `true` while a `set_stable_balance` RPC is in + /// flight. Disables the toggle buttons so the user can't queue + /// a second flip mid-rpc. + pub stable_balance_saving: bool, +} + +impl SparkSettingsView { + pub fn render<'a>(self) -> Element<'a, Message> { + let heading = Container::new(h2("Spark — Settings")); + let picker = lightning_backend_picker( + self.default_lightning_backend, + self.spark_available, + ); + let stable_balance_card = stable_balance_card( + self.stable_balance_active, + self.stable_balance_saving, + self.spark_available, + ); + + let body: Element<'_, Message> = match self.status { + SparkSettingsStatus::Unavailable => Column::new() + .spacing(10) + .push(p1_regular( + "Spark is not configured for this cube, or the bridge \ + subprocess failed to spawn.", + )) + .push(p2_regular( + "Configure a Spark signer on this cube and restart the \ + app to connect. If you already have one configured, \ + check the stderr logs from coincube-spark-bridge to \ + see why the spawn failed — the bridge binary must be \ + locatable via COINCUBE_SPARK_BRIDGE_PATH or sit \ + alongside the main coincube binary.", + )) + .into(), + SparkSettingsStatus::Loading => Column::new() + .push(p1_regular("Fetching wallet info from the Spark bridge…")) + .into(), + SparkSettingsStatus::Error(err) => Column::new() + .spacing(10) + .push(p1_regular("Spark bridge error")) + .push(p2_regular(err)) + .into(), + SparkSettingsStatus::Loaded(snapshot) => Column::new() + .spacing(14) + .push(setting_row( + "Balance", + format!("{} sats", snapshot.balance_sats), + )) + .push(setting_row( + "Identity pubkey", + snapshot.identity_pubkey, + )) + .push(setting_row("Network", format_network(self.network))) + .push(Space::new().height(Length::Fixed(12.0))) + .push(diagnostic_note()) + .into(), + }; + + Column::new() + .spacing(20) + .push(heading) + .push(stable_balance_card) + .push(picker) + .push(body) + .into() + } +} + +fn stable_balance_card<'a>( + active: Option, + saving: bool, + spark_available: bool, +) -> Element<'a, Message> { + let status_line: Element<'_, Message> = match (active, spark_available) { + (_, false) => p2_regular("Spark bridge unavailable — toggle disabled.").into(), + (None, true) => p2_regular("Loading…").into(), + (Some(true), true) => p2_regular("Stable Balance is ON").into(), + (Some(false), true) => p2_regular("Stable Balance is OFF").into(), + }; + + // Target state for the button: if currently ON, pressing turns + // it OFF, and vice versa. When we don't know yet (loading) or + // saving is in flight, press is disabled. + let can_toggle = spark_available && active.is_some() && !saving; + let target = active.unwrap_or(false); + let (button_label, next_state) = if target { + ("Turn off", false) + } else { + ("Turn on", true) + }; + let toggle_btn = button::primary(None, button_label).width(Length::Fixed(140.0)); + let toggle_btn: Element<'_, Message> = if can_toggle { + toggle_btn + .on_press(Message::SparkSettings( + SparkSettingsMessage::StableBalanceToggled(next_state), + )) + .into() + } else { + toggle_btn.on_press_maybe(None).into() + }; + + Container::new( + Column::new() + .spacing(10) + .push(h4_bold("Stable Balance")) + .push(p2_regular( + "Keep a portion of your Bitcoin balance pegged to \ + USD. Your spendable balance stays stable against \ + fiat even as BTC price moves. You can still send \ + Bitcoin normally — the wallet automatically \ + converts between the stable and Bitcoin balances \ + as needed.", + )) + .push( + Row::new() + .spacing(12) + .align_y(Alignment::Center) + .push(status_line) + .push(Space::new().width(Length::Fill)) + .push(toggle_btn), + ), + ) + .padding(12) + .style(theme::card::simple) + .into() +} + +fn lightning_backend_picker<'a>( + current: WalletKind, + spark_available: bool, +) -> Element<'a, Message> { + let spark_btn = if current == WalletKind::Spark { + button::primary(None, "Spark") + } else { + button::transparent_border(None, "Spark") + }; + let spark_btn = spark_btn.width(Length::Fixed(140.0)); + let spark_btn: Element<'_, Message> = if spark_available { + spark_btn + .on_press(Message::SparkSettings( + SparkSettingsMessage::DefaultLightningBackendChanged(WalletKind::Spark), + )) + .into() + } else { + // No bridge — disable the chip so the user can't select a + // backend that isn't wired up for this cube. + spark_btn.on_press_maybe(None).into() + }; + + let liquid_btn = if current == WalletKind::Liquid { + button::primary(None, "Liquid") + } else { + button::transparent_border(None, "Liquid") + }; + let liquid_btn: Element<'_, Message> = liquid_btn + .width(Length::Fixed(140.0)) + .on_press(Message::SparkSettings( + SparkSettingsMessage::DefaultLightningBackendChanged(WalletKind::Liquid), + )) + .into(); + + Container::new( + Column::new() + .spacing(10) + .push(h4_bold("Default Lightning backend")) + .push(p2_regular( + "Chooses which wallet fulfills incoming Lightning \ + Address invoices for this cube. Spark is the default \ + when available; Liquid is the fallback and handles \ + NIP-57 zaps (whose description is too long for \ + Spark's invoice description field).", + )) + .push( + Row::new() + .spacing(12) + .align_y(Alignment::Center) + .push(spark_btn) + .push(liquid_btn), + ), + ) + .padding(12) + .style(theme::card::simple) + .into() +} + +fn setting_row<'a>(label: &'a str, value: String) -> Element<'a, Message> { + Container::new( + Row::new() + .spacing(20) + .align_y(Alignment::Start) + .push( + Column::new() + .width(Length::FillPortion(1)) + .push(h4_bold(label)), + ) + .push( + Column::new() + .width(Length::FillPortion(3)) + .push(p1_regular(value)), + ), + ) + .padding(12) + .style(theme::card::simple) + .into() +} + +fn format_network(network: Network) -> String { + // We only currently support Bitcoin + Regtest on the Spark side + // (see the network check in breez_spark::config). Render the + // mainnet label as "Mainnet" instead of "bitcoin" for readability. + match network { + Network::Bitcoin => "Mainnet".to_string(), + Network::Regtest => "Regtest".to_string(), + other => format!("{}", other), + } +} + +fn diagnostic_note<'a>() -> Element<'a, Message> { + Container::new( + Column::new() + .spacing(6) + .push(h4_bold("Diagnostics")) + .push(p2_regular( + "The Spark SDK runs in a sibling process (coincube-spark-bridge) \ + and talks to the gui over JSON-RPC on stdio. Restarting the \ + cube re-spawns the bridge. Advanced controls (Stable Balance \ + toggle, signer rotation, manual reconnect) land in a later \ + phase.", + )), + ) + .padding(12) + .style(theme::card::simple) + .into() +} diff --git a/coincube-gui/src/app/view/spark/transactions.rs b/coincube-gui/src/app/view/spark/transactions.rs new file mode 100644 index 000000000..82503bd68 --- /dev/null +++ b/coincube-gui/src/app/view/spark/transactions.rs @@ -0,0 +1,127 @@ +//! View renderer for [`crate::app::state::spark::transactions::SparkTransactions`]. +//! +//! Renders one of four states: +//! - [`SparkTransactionsStatus::Unavailable`] — no backend wired. +//! - [`SparkTransactionsStatus::Loading`] — first fetch in flight. +//! - [`SparkTransactionsStatus::Error`] — bridge returned an error. +//! - [`SparkTransactionsStatus::Loaded`] — list populated (possibly empty). +//! +//! Row layout: [direction arrow] [amount] [time ago] [status pill]. +//! Intentionally minimal — the real `TransactionListItem` widget used by +//! the Liquid panel expects domain types we haven't mapped for Spark +//! yet, so Phase 4b uses a straightforward row builder. + +use coincube_ui::{ + color, + component::text::{h2, p1_regular, p2_regular}, + theme, + widget::{Column, Container, Element, Row}, +}; +use coincube_spark_protocol::PaymentSummary; +use iced::widget::{scrollable, Space}; +use iced::{Alignment, Length}; + +use crate::app::view::Message; +use crate::utils::format_time_ago; + +/// Tri-state status the Transactions panel can be in. +#[derive(Debug, Clone)] +pub enum SparkTransactionsStatus { + Unavailable, + Loading, + Error(String), + Loaded(Vec), +} + +/// View wrapper that renders the Transactions panel. +pub struct SparkTransactionsView { + pub status: SparkTransactionsStatus, +} + +impl SparkTransactionsView { + pub fn render<'a>(self) -> Element<'a, Message> { + let heading = Container::new(h2("Spark — Transactions")); + + let body: Element<'_, Message> = match self.status { + SparkTransactionsStatus::Unavailable => Column::new() + .push(p1_regular( + "Spark is not configured for this cube. Set up a Spark \ + signer to see your payment history here.", + )) + .into(), + SparkTransactionsStatus::Loading => Column::new() + .push(p1_regular("Loading payment history from the Spark bridge…")) + .into(), + SparkTransactionsStatus::Error(err) => Column::new() + .spacing(10) + .push(p1_regular("Failed to load payment history")) + .push(p2_regular(err)) + .into(), + SparkTransactionsStatus::Loaded(payments) if payments.is_empty() => Column::new() + .push(p1_regular( + "No payments yet. Incoming and outgoing payments will \ + appear here once the Send / Receive panels land.", + )) + .into(), + SparkTransactionsStatus::Loaded(payments) => { + let mut list = Column::new().spacing(8); + for payment in &payments { + list = list.push(payment_row(payment)); + } + scrollable(list).height(Length::Fill).into() + } + }; + + Column::new() + .spacing(20) + .push(heading) + .push(body) + .into() + } +} + +fn payment_row<'a>(payment: &PaymentSummary) -> Element<'a, Message> { + let is_receive = payment.direction.eq_ignore_ascii_case("Receive"); + let arrow = if is_receive { "↓" } else { "↑" }; + let amount_str = if is_receive { + format!("+{} sats", payment.amount_sat.unsigned_abs()) + } else { + format!("-{} sats", payment.amount_sat.unsigned_abs()) + }; + let time_ago = format_time_ago(payment.timestamp as i64); + + // Use the fg color directly via `.color(...)` so the pill reads as + // a quick at-a-glance status without needing a custom theme token. + let status_color = if payment.status.eq_ignore_ascii_case("Completed") + || payment.status.eq_ignore_ascii_case("Complete") + { + color::GREEN + } else if payment.status.eq_ignore_ascii_case("Failed") + || payment.status.eq_ignore_ascii_case("TimedOut") + { + color::RED + } else { + color::GREY_3 + }; + + Container::new( + Row::new() + .spacing(12) + .align_y(Alignment::Center) + .push(p1_regular(arrow)) + .push( + Column::new() + .spacing(4) + .push(p1_regular(amount_str)) + .push(p2_regular(time_ago).style(theme::text::secondary)), + ) + .push(Space::new().width(Length::Fill)) + .push( + p2_regular(payment.status.clone()) + .style(move |_| iced::widget::text::Style { color: Some(status_color) }), + ), + ) + .padding(12) + .style(theme::card::simple) + .into() +} diff --git a/coincube-gui/src/app/wallets/liquid.rs b/coincube-gui/src/app/wallets/liquid.rs new file mode 100644 index 000000000..e6797f50e --- /dev/null +++ b/coincube-gui/src/app/wallets/liquid.rs @@ -0,0 +1,75 @@ +//! Liquid-specific backend adapter. +//! +//! [`LiquidBackend`] wraps an [`Arc`] and exposes domain-typed +//! read methods ([`DomainPayment`] / [`DomainRefundableSwap`]) on top of the +//! SDK. All other [`BreezClient`] methods remain accessible via `Deref`, so +//! callers that still need raw SDK types (send/receive flows, event handling) +//! don't have to change anything beyond the field type. +//! +//! This is the first concrete wallet backend in the [`crate::app::wallets`] +//! layer. [`SparkBackend`] will land alongside it in Phase 3 and they will +//! share a [`WalletBackend`] trait at that point — introducing the trait now +//! would be guessing at the interface before seeing a second implementor. +//! +//! [`SparkBackend`]: crate::app::wallets +//! [`WalletBackend`]: crate::app::wallets + +use std::ops::Deref; +use std::sync::Arc; + +use crate::app::breez_liquid::{BreezClient, BreezError}; + +use super::types::{DomainPayment, DomainRefundableSwap}; + +/// Liquid backend: wraps [`BreezClient`] and exposes the domain read API. +/// +/// Cheap to clone (internally an `Arc`). Methods that return +/// domain types are defined inherently on `LiquidBackend`; everything else +/// on [`BreezClient`] remains reachable via `Deref`. +#[derive(Clone, Debug)] +pub struct LiquidBackend { + client: Arc, +} + +impl LiquidBackend { + pub fn new(client: Arc) -> Self { + Self { client } + } + + /// Access the underlying [`BreezClient`] when you explicitly want the raw + /// SDK handle (e.g. passing it to code that still expects `Arc`). + /// + /// Most call sites can reach SDK methods directly via `Deref` — prefer + /// `self.backend.some_sdk_method()` over `self.backend.client().some_sdk_method()`. + pub fn client(&self) -> &Arc { + &self.client + } + + /// Fetch the payment history and map each record into a [`DomainPayment`]. + /// + /// `limit` mirrors the underlying [`BreezClient::list_payments`] parameter. + pub async fn list_payments( + &self, + limit: Option, + ) -> Result, BreezError> { + let payments = self.client.list_payments(limit).await?; + Ok(payments.into_iter().map(DomainPayment::from).collect()) + } + + /// Fetch refundable swaps and map each record into a [`DomainRefundableSwap`]. + pub async fn list_refundables(&self) -> Result, BreezError> { + let refundables = self.client.list_refundables().await?; + Ok(refundables + .into_iter() + .map(DomainRefundableSwap::from) + .collect()) + } +} + +impl Deref for LiquidBackend { + type Target = BreezClient; + + fn deref(&self) -> &BreezClient { + &self.client + } +} diff --git a/coincube-gui/src/app/wallets/mod.rs b/coincube-gui/src/app/wallets/mod.rs new file mode 100644 index 000000000..0e9ef8183 --- /dev/null +++ b/coincube-gui/src/app/wallets/mod.rs @@ -0,0 +1,27 @@ +//! Wallet backend abstraction layer. +//! +//! This module sits between the SDK-specific wrappers (e.g. +//! [`crate::app::breez_liquid`]) and the UI panels. It exposes domain types +//! and a concrete [`LiquidBackend`] so panels can be ported to additional +//! backends (Spark next) without leaking SDK-specific payment types into the +//! UI code. +//! +//! The read surface is complete: panels consume [`DomainPayment`] and +//! [`DomainRefundableSwap`] everywhere. The write surface (send/receive, +//! refunds, swaps) is still Liquid-specific and reachable via +//! [`LiquidBackend::client`] or `Deref` — that layer will grow when the Spark +//! backend lands and we extract a shared `WalletBackend` trait from the +//! concrete implementations. + +pub mod liquid; +pub mod registry; +pub mod spark; +pub mod types; + +pub use liquid::LiquidBackend; +pub use registry::{LightningRoute, WalletRegistry}; +pub use spark::SparkBackend; +pub use types::{ + DomainLiquidAssetInfo, DomainPayment, DomainPaymentDetails, DomainPaymentDirection, + DomainPaymentStatus, DomainRefundableSwap, WalletKind, +}; diff --git a/coincube-gui/src/app/wallets/registry.rs b/coincube-gui/src/app/wallets/registry.rs new file mode 100644 index 000000000..ffb6072cb --- /dev/null +++ b/coincube-gui/src/app/wallets/registry.rs @@ -0,0 +1,147 @@ +//! Registry that owns the app's wallet backends and exposes routing hooks. +//! +//! Holds one [`LiquidBackend`] (always present) and an optional +//! [`SparkBackend`] (present when the cube has a Spark signer and +//! the bridge subprocess spawned successfully). +//! [`WalletRegistry::route_lightning_address`] consults the cube's +//! `default_lightning_backend` setting and returns the backend that +//! should fulfill the next incoming Lightning Address invoice. +//! +//! The registry is the single place the app decides *which* backend +//! handles *which* payment type — keeping that logic in one module +//! means the "Spark default / Liquid advanced" policy is a one-file +//! change. + +use std::sync::Arc; + +use super::liquid::LiquidBackend; +use super::spark::SparkBackend; +use super::types::WalletKind; + +/// Which backend a routing decision picked. Carries the backend handle +/// so callers don't have to re-resolve it from the registry. +/// +/// The `Spark` variant only appears when the Spark backend is +/// actually present — callers never see `Spark(None)`. +#[derive(Clone)] +pub enum LightningRoute { + Liquid(Arc), + Spark(Arc), +} + +/// Owns the per-cube wallet backends. +/// +/// Cheap to clone — the backends live behind `Arc`s so clones share state. +#[derive(Clone)] +pub struct WalletRegistry { + liquid: Arc, + /// `None` if the cube has no Spark signer configured, or if the + /// bridge subprocess failed to spawn / handshake. Panels code + /// checks this and shows a "Spark unavailable" placeholder when + /// absent. + spark: Option>, +} + +impl WalletRegistry { + pub fn new(liquid: Arc) -> Self { + Self { + liquid, + spark: None, + } + } + + pub fn with_spark( + liquid: Arc, + spark: Option>, + ) -> Self { + Self { liquid, spark } + } + + /// Access the Liquid backend. + pub fn liquid(&self) -> &Arc { + &self.liquid + } + + /// Access the Spark backend, if the bridge is up for this cube. + pub fn spark(&self) -> Option<&Arc> { + self.spark.as_ref() + } + + /// Returns the backend that should fulfill incoming Lightning Address + /// requests given the cube's preference. + /// + /// Falls back to Liquid when the caller asks for Spark but the + /// bridge is unavailable (no signer, subprocess crashed, etc.), + /// so a misconfigured Spark setup never loses invoice requests. + pub fn route_lightning_address(&self, preferred: WalletKind) -> LightningRoute { + match resolve_lightning_backend(preferred, self.spark.is_some()) { + WalletKind::Spark => LightningRoute::Spark( + self.spark + .clone() + .expect("resolve_lightning_backend only returns Spark when spark is Some"), + ), + WalletKind::Liquid => LightningRoute::Liquid(self.liquid.clone()), + } + } +} + +/// Pure decision function for [`WalletRegistry::route_lightning_address`]. +/// +/// Split out from the registry method so it's unit-testable without +/// having to construct real backend handles (the backends wrap SDK +/// clients that require network/process resources). +/// +/// Rules: honor `preferred` when Spark is available, fall back to +/// Liquid otherwise. Liquid requests stay on Liquid. +fn resolve_lightning_backend(preferred: WalletKind, spark_available: bool) -> WalletKind { + match preferred { + WalletKind::Spark if spark_available => WalletKind::Spark, + WalletKind::Spark => WalletKind::Liquid, + WalletKind::Liquid => WalletKind::Liquid, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spark_preferred_and_available_returns_spark() { + assert_eq!( + resolve_lightning_backend(WalletKind::Spark, true), + WalletKind::Spark + ); + } + + #[test] + fn spark_preferred_but_unavailable_falls_back_to_liquid() { + // Core Phase 5 guarantee: a misconfigured Spark setup + // never drops incoming invoice requests. + assert_eq!( + resolve_lightning_backend(WalletKind::Spark, false), + WalletKind::Liquid + ); + } + + #[test] + fn liquid_preferred_stays_on_liquid_regardless_of_spark_availability() { + assert_eq!( + resolve_lightning_backend(WalletKind::Liquid, true), + WalletKind::Liquid + ); + assert_eq!( + resolve_lightning_backend(WalletKind::Liquid, false), + WalletKind::Liquid + ); + } + + #[test] + fn default_wallet_kind_is_spark() { + // Phase 5 contract: new cubes land on Spark for Lightning + // fulfilment. Changing this is a product-level decision, + // not a refactor — if this test fails, the default flip + // needs to be deliberate and documented in the release + // notes. + assert_eq!(WalletKind::default(), WalletKind::Spark); + } +} diff --git a/coincube-gui/src/app/wallets/spark.rs b/coincube-gui/src/app/wallets/spark.rs new file mode 100644 index 000000000..e69240085 --- /dev/null +++ b/coincube-gui/src/app/wallets/spark.rs @@ -0,0 +1,156 @@ +//! Spark-specific backend adapter. +//! +//! [`SparkBackend`] wraps an [`Arc`] and exposes the same +//! panel-facing read surface as [`LiquidBackend`] (`list_payments`, +//! `get_info`, …), plus a method-forwarding `client()` accessor so +//! write-path callers can reach the subprocess client directly without +//! the wallet abstraction getting in the way. +//! +//! Structurally this is the Spark counterpart to +//! [`crate::app::wallets::liquid::LiquidBackend`]. The two types +//! deliberately don't share a `WalletBackend` trait yet — the Liquid +//! backend's methods are sync/local, Spark's are async/IPC with a real +//! subprocess cost model, and sharing a trait would paper over that +//! difference without earning much. Registry routing (Phase 5 Lightning +//! Address handoff) will use an enum dispatch instead. + +use std::sync::Arc; + +use coincube_spark_protocol::{ + ClaimDepositOk, GetInfoOk, GetUserSettingsOk, ListPaymentsOk, ListUnclaimedDepositsOk, + ParseInputOk, PrepareSendOk, ReceivePaymentOk, SendPaymentOk, +}; + +use crate::app::breez_spark::{SparkClient, SparkClientError, SparkClientEvent}; + +/// Spark backend: wraps [`SparkClient`] and exposes the domain read API. +/// +/// Cheap to clone (internally an `Arc`). Methods that return +/// domain types are defined inherently on `SparkBackend`; everything +/// else on [`SparkClient`] remains reachable via [`Self::client`]. +#[derive(Clone, Debug)] +pub struct SparkBackend { + client: Arc, +} + +impl SparkBackend { + pub fn new(client: Arc) -> Self { + Self { client } + } + + /// Access the underlying [`SparkClient`]. + pub fn client(&self) -> &Arc { + &self.client + } + + /// Fetch wallet info (balance + identity pubkey). + pub async fn get_info(&self) -> Result { + self.client.get_info().await + } + + /// List recent payments. + pub async fn list_payments( + &self, + limit: Option, + ) -> Result { + self.client.list_payments(limit).await + } + + /// Phase 4e: classify a destination string. The Send panel calls + /// this before `prepare_send` so it can route LNURL inputs to + /// `prepare_lnurl_pay` instead. + pub async fn parse_input(&self, input: String) -> Result { + self.client.parse_input(input).await + } + + /// Phase 4c: parse a destination + compute send preview. + pub async fn prepare_send( + &self, + input: String, + amount_sat: Option, + ) -> Result { + self.client.prepare_send(input, amount_sat).await + } + + /// Phase 4e: prepare an LNURL-pay / Lightning-address send. + /// Returns the same shape as `prepare_send` so the Send panel + /// state machine doesn't need a parallel branch. + pub async fn prepare_lnurl_pay( + &self, + input: String, + amount_sat: u64, + comment: Option, + ) -> Result { + self.client.prepare_lnurl_pay(input, amount_sat, comment).await + } + + /// Phase 4c: execute a previously-prepared send. + pub async fn send_payment( + &self, + prepare_handle: String, + ) -> Result { + self.client.send_payment(prepare_handle).await + } + + /// Phase 4c: generate a BOLT11 invoice. + pub async fn receive_bolt11( + &self, + amount_sat: Option, + description: String, + expiry_secs: Option, + ) -> Result { + self.client + .receive_bolt11(amount_sat, description, expiry_secs) + .await + } + + /// Phase 4c: generate an on-chain deposit address. + pub async fn receive_onchain( + &self, + new_address: Option, + ) -> Result { + self.client.receive_onchain(new_address).await + } + + /// Phase 4f: list pending on-chain deposits. + pub async fn list_unclaimed_deposits( + &self, + ) -> Result { + self.client.list_unclaimed_deposits().await + } + + /// Phase 4f: claim a specific deposit by (txid, vout). + pub async fn claim_deposit( + &self, + txid: String, + vout: u32, + ) -> Result { + self.client.claim_deposit(txid, vout).await + } + + /// Phase 6: read the Stable Balance toggle state. + pub async fn get_user_settings(&self) -> Result { + self.client.get_user_settings().await + } + + /// Phase 6: enable or disable Stable Balance. + pub async fn set_stable_balance(&self, enabled: bool) -> Result<(), SparkClientError> { + self.client.set_stable_balance(enabled).await + } + + /// Build an iced [`Subscription`](iced::Subscription) over the + /// bridge's event stream. Forwards through to + /// [`SparkClient::event_subscription`]; the app-level runner + /// wraps the resulting [`SparkClientEvent`] into + /// [`crate::app::Message::SparkEvent`]. + pub fn event_subscription(&self) -> iced::Subscription { + self.client.event_subscription() + } + + /// Gracefully shut down the bridge subprocess. After this returns + /// the backend (and any cloned handles) is unusable; further calls + /// fail with [`SparkClientError::BridgeUnavailable`]. + pub async fn shutdown(&self) -> Result<(), SparkClientError> { + self.client.shutdown().await + } +} diff --git a/coincube-gui/src/app/wallets/types.rs b/coincube-gui/src/app/wallets/types.rs new file mode 100644 index 000000000..8f9cc0354 --- /dev/null +++ b/coincube-gui/src/app/wallets/types.rs @@ -0,0 +1,260 @@ +//! Domain types for wallet backends. +//! +//! These types decouple the panels from SDK-specific payment representations +//! (e.g. [`breez_sdk_liquid::prelude::Payment`]). Each wallet backend maps its +//! native types into these domain types at the boundary. + +use breez_sdk_liquid::model::{ + PaymentDetails as LiquidPaymentDetails, PaymentState as LiquidPaymentState, + PaymentType as LiquidPaymentType, +}; +use breez_sdk_liquid::prelude::{Payment as LiquidPayment, RefundableSwap as LiquidRefundableSwap}; + +use crate::app::breez_liquid::assets::USDT_PRECISION; + +/// Identifies a wallet backend. +/// +/// Default is [`Self::Spark`] as of Phase 5 — new cubes route incoming +/// Lightning Address invoices through the Spark bridge. Existing cubes +/// that previously deserialized with Liquid default keep their +/// explicit value (serde only applies the default when the field is +/// absent entirely). Users can override per-cube in Spark Settings. +#[derive( + Debug, Clone, Copy, Default, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum WalletKind { + /// Spark wallet — default for everyday Lightning UX and the + /// Phase 5 routing target for incoming Lightning Address invoices. + #[default] + Spark, + /// Liquid wallet — advanced wallet for L-BTC, USDt, and + /// Liquid-specific receive flows. + Liquid, +} + +/// Direction of a payment from the wallet's point of view. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum DomainPaymentDirection { + Send, + Receive, +} + +/// Composite status of a payment, mirroring the states the UI distinguishes. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum DomainPaymentStatus { + Created, + Pending, + Complete, + Failed, + TimedOut, + Refundable, + RefundPending, + WaitingFeeAcceptance, +} + +impl DomainPaymentStatus { + /// `true` for states that show as destructive (red) in the UI. + pub fn is_destructive(self) -> bool { + matches!( + self, + Self::Failed | Self::TimedOut | Self::Refundable + ) + } + + /// `true` for in-flight states that should not contribute to confirmed balance. + pub fn is_in_flight(self) -> bool { + matches!( + self, + Self::Created | Self::Pending | Self::RefundPending | Self::WaitingFeeAcceptance + ) + } +} + +/// Liquid-only asset info carried on `DomainPaymentDetails::LiquidAsset`. +/// +/// Amounts are carried as base units (`amount_minor`) so the UI doesn't have to +/// re-derive them from the SDK's `f64` field. +#[derive(Debug, Clone, PartialEq)] +pub struct DomainLiquidAssetInfo { + pub amount_minor: u64, + pub precision: u8, +} + +/// Payment-type-specific details carried by a [`DomainPayment`]. +/// +/// Only fields actually read by the UI are modeled here. Additional SDK fields +/// can be added as needs arise. +#[derive(Debug, Clone, PartialEq)] +pub enum DomainPaymentDetails { + /// A Lightning payment (BOLT11 / BOLT12 / LNURL). + Lightning { + description: String, + payer_note: Option, + }, + /// A direct on-chain Liquid payment, possibly for a non-L-BTC asset. + LiquidAsset { + asset_id: String, + asset_info: Option, + description: String, + payer_note: Option, + }, + /// A swap to or from the Bitcoin chain (Liquid backend: boltz-style swap). + OnChainBitcoin { + description: String, + swap_id: Option, + claim_tx_id: Option, + }, +} + +impl DomainPaymentDetails { + /// Best-effort human description for the payment, preferring the payer note + /// over the invoice description. + pub fn description(&self) -> &str { + match self { + Self::Lightning { + description, + payer_note, + } + | Self::LiquidAsset { + description, + payer_note, + .. + } => payer_note + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(description), + Self::OnChainBitcoin { description, .. } => description, + } + } +} + +/// A payment presented to the UI, decoupled from any SDK-specific type. +#[derive(Debug, Clone, PartialEq)] +pub struct DomainPayment { + pub tx_id: Option, + pub destination: Option, + pub timestamp: u32, + pub amount_sat: u64, + pub fees_sat: u64, + pub direction: DomainPaymentDirection, + pub status: DomainPaymentStatus, + pub details: DomainPaymentDetails, +} + +impl DomainPayment { + pub fn is_incoming(&self) -> bool { + matches!(self.direction, DomainPaymentDirection::Receive) + } +} + +/// A refundable swap surfaced by the backend's read API. +#[derive(Debug, Clone, PartialEq)] +pub struct DomainRefundableSwap { + pub swap_address: String, + pub timestamp: u32, + pub amount_sat: u64, +} + +// --------------------------------------------------------------------------- +// Mapping from breez_sdk_liquid types into domain types. +// --------------------------------------------------------------------------- + +impl From for DomainPaymentStatus { + fn from(s: LiquidPaymentState) -> Self { + match s { + LiquidPaymentState::Created => Self::Created, + LiquidPaymentState::Pending => Self::Pending, + LiquidPaymentState::Complete => Self::Complete, + LiquidPaymentState::Failed => Self::Failed, + LiquidPaymentState::TimedOut => Self::TimedOut, + LiquidPaymentState::Refundable => Self::Refundable, + LiquidPaymentState::RefundPending => Self::RefundPending, + LiquidPaymentState::WaitingFeeAcceptance => Self::WaitingFeeAcceptance, + } + } +} + +impl From for DomainPaymentDirection { + fn from(t: LiquidPaymentType) -> Self { + match t { + LiquidPaymentType::Send => Self::Send, + LiquidPaymentType::Receive => Self::Receive, + } + } +} + +fn map_liquid_details(details: LiquidPaymentDetails) -> DomainPaymentDetails { + match details { + LiquidPaymentDetails::Lightning { + description, + payer_note, + .. + } => DomainPaymentDetails::Lightning { + description, + payer_note, + }, + LiquidPaymentDetails::Liquid { + asset_id, + asset_info, + description, + payer_note, + .. + } => { + let asset_info = asset_info.map(|info| { + // The SDK exposes `amount` as an f64 already shifted by the asset + // precision. Convert back to minor units (same formula the UI used + // before the refactor). The UI uses `USDT_PRECISION` for the only + // non-L-BTC asset we currently support. + let precision = USDT_PRECISION; + let scale = 10_f64.powi(precision as i32); + let amount_minor = (info.amount * scale).round() as u64; + DomainLiquidAssetInfo { + amount_minor, + precision, + } + }); + DomainPaymentDetails::LiquidAsset { + asset_id, + asset_info, + description, + payer_note, + } + } + LiquidPaymentDetails::Bitcoin { + description, + swap_id, + claim_tx_id, + .. + } => DomainPaymentDetails::OnChainBitcoin { + description, + swap_id: Some(swap_id), + claim_tx_id, + }, + } +} + +impl From for DomainPayment { + fn from(p: LiquidPayment) -> Self { + Self { + tx_id: p.tx_id, + destination: p.destination, + timestamp: p.timestamp, + amount_sat: p.amount_sat, + fees_sat: p.fees_sat, + direction: p.payment_type.into(), + status: p.status.into(), + details: map_liquid_details(p.details), + } + } +} + +impl From for DomainRefundableSwap { + fn from(r: LiquidRefundableSwap) -> Self { + Self { + swap_address: r.swap_address, + timestamp: r.timestamp, + amount_sat: r.amount_sat, + } + } +} diff --git a/coincube-gui/src/export.rs b/coincube-gui/src/export.rs index 1d8c02a5c..8ed686a54 100644 --- a/coincube-gui/src/export.rs +++ b/coincube-gui/src/export.rs @@ -274,7 +274,7 @@ pub struct Export { pub sender: Option>, pub handle: Option>>>, pub daemon: Option>, - pub breez_client: Option>, + pub breez_client: Option>, pub path: Box, pub export_type: ImportExportType, } @@ -282,7 +282,7 @@ pub struct Export { impl Export { pub fn new( daemon: Option>, - breez_client: Option>, + breez_client: Option>, path: Box, export_type: ImportExportType, ) -> Self { @@ -303,7 +303,7 @@ impl Export { sender: UnboundedSender, daemon: Option>, path: PathBuf, - breez_client: Option>, + breez_client: Option>, ) { if let Err(e) = match export_type { ImportExportType::Transactions => export_transactions(&sender, daemon, path).await, @@ -385,7 +385,7 @@ impl Export { /// Subscription identity is based on `export_type` discriminant and `path`. pub struct ExportSubscriptionData { pub daemon: Option>, - pub breez_client: Option>, + pub breez_client: Option>, pub path: PathBuf, pub export_type: ImportExportType, } @@ -410,7 +410,7 @@ pub fn make_export_stream(data: &ExportSubscriptionData) -> impl Stream>, - breez_client: Option>, + breez_client: Option>, path: PathBuf, export_type: ImportExportType, ) -> impl Stream { @@ -613,14 +613,13 @@ pub async fn export_transactions( pub async fn export_liquid_payments( sender: &UnboundedSender, - breez_client: Arc, + breez_client: Arc, path: PathBuf, ) -> Result<(), Error> { - use breez_sdk_liquid::model::PaymentDetails; - use breez_sdk_liquid::prelude::PaymentType; use chrono::DateTime; - use crate::app::breez::assets::usdt_asset_id; + use crate::app::breez_liquid::assets::usdt_asset_id; + use crate::app::wallets::{DomainPaymentDetails, LiquidBackend}; let usdt_id = usdt_asset_id(breez_client.network()).unwrap_or(""); @@ -630,7 +629,11 @@ pub async fn export_liquid_payments( "Date,PaymentType,Asset,Sending/Receiving Address,Amount,Fees (L-BTC),Net Amount\n"; file.write_all(header.as_bytes())?; - let payments = breez_client + // Upstream callers (Installer, Loader, Login, etc.) still pass raw + // `Arc` through the `Export` struct, so wrap it here into a + // short-lived `LiquidBackend` just to get the domain read API. + let backend = LiquidBackend::new(breez_client); + let payments = backend .list_payments(None) .await .map_err(|e| Error::Daemon(e.to_string()))?; @@ -641,10 +644,8 @@ pub async fn export_liquid_payments( .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_default(); - let payment_type = match payment.payment_type { - PaymentType::Send => "SEND", - PaymentType::Receive => "RECEIVE", - }; + let is_send = !payment.is_incoming(); + let payment_type = if is_send { "SEND" } else { "RECEIVE" }; let address = payment .destination @@ -654,26 +655,28 @@ pub async fn export_liquid_payments( let fees_btc = payment.fees_sat as f64 / 100_000_000.0; - // Detect USDt asset payments — real amount is in asset_info.amount, not amount_sat (which is 0) - let is_usdt = matches!( - &payment.details, - PaymentDetails::Liquid { asset_id, .. } if asset_id == usdt_id - ); - - let line = if is_usdt { - let raw_usdt: Option = if let PaymentDetails::Liquid { - asset_info: Some(ref ai), + // Detect USDt asset payments — real amount is carried in asset_info, not amount_sat. + let usdt_amount_float: Option = match &payment.details { + DomainPaymentDetails::LiquidAsset { + asset_id, + asset_info, .. - } = &payment.details - { - Some(ai.amount) - } else { - None - }; - let usdt_amount = raw_usdt.map(|v| v.to_string()).unwrap_or_default(); - let net = match (raw_usdt, payment.payment_type) { - (Some(v), PaymentType::Send) => format!("-{}", v), - (Some(v), PaymentType::Receive) => v.to_string(), + } if asset_id == usdt_id => asset_info.as_ref().map(|info| { + info.amount_minor as f64 / 10_f64.powi(info.precision as i32) + }), + _ => None, + }; + + let line = if usdt_amount_float.is_some() || matches!( + &payment.details, + DomainPaymentDetails::LiquidAsset { asset_id, .. } if asset_id == usdt_id + ) { + let usdt_amount = usdt_amount_float + .map(|v| v.to_string()) + .unwrap_or_default(); + let net = match (usdt_amount_float, is_send) { + (Some(v), true) => format!("-{}", v), + (Some(v), false) => v.to_string(), (None, _) => String::new(), }; format!( @@ -681,15 +684,15 @@ pub async fn export_liquid_payments( date_time, payment_type, address, usdt_amount, fees_btc, net ) } else { - let amount_btc = match payment.payment_type { - PaymentType::Send => payment.amount_sat as f64 / 100_000_000.0, - PaymentType::Receive => { - (payment.amount_sat + payment.fees_sat) as f64 / 100_000_000.0 - } + let amount_btc = if is_send { + payment.amount_sat as f64 / 100_000_000.0 + } else { + (payment.amount_sat + payment.fees_sat) as f64 / 100_000_000.0 }; - let total = match payment.payment_type { - PaymentType::Send => -(amount_btc + fees_btc), - PaymentType::Receive => amount_btc - fees_btc, + let total = if is_send { + -(amount_btc + fees_btc) + } else { + amount_btc - fees_btc }; format!( "{},{},L-BTC,{},{:.8},{:.8},{:.8}\n", diff --git a/coincube-gui/src/gui/tab.rs b/coincube-gui/src/gui/tab.rs index 16a36cee8..3590942dd 100644 --- a/coincube-gui/src/gui/tab.rs +++ b/coincube-gui/src/gui/tab.rs @@ -11,7 +11,7 @@ use coincubed::commands::ListCoinsResult; use crate::{ app::{ - self, breez, + self, breez_liquid, cache::{Cache, DaemonCache}, settings::{update_settings_file, WalletId, WalletSettings}, wallet::Wallet, @@ -64,10 +64,17 @@ pub enum Message { datadir: CoincubeDirectory, network: bitcoin::Network, config: app::Config, - breez_client: Result, app::breez::BreezError>, + breez_client: Result, app::breez_liquid::BreezError>, }, BreezClientLoadedAfterPin { - breez_client: Result, app::breez::BreezError>, + breez_client: Result, app::breez_liquid::BreezError>, + /// Spark backend loaded in the same task as the Liquid client. + /// `None` if the cube has no Spark signer configured; `Some(Err(..))` + /// if the bridge subprocess failed to spawn or the handshake failed. + /// A failure here is non-fatal — the gui logs and continues with + /// `spark_backend = None`, which surfaces as "Spark unavailable" in + /// the Spark panels. + spark_backend: Option>, config: app::Config, datadir: CoincubeDirectory, network: bitcoin::Network, @@ -268,7 +275,7 @@ impl Tab { datadir: l.datadir.clone(), network: l.network, config, - breez_client: Err(breez::BreezError::SignerError( + breez_client: Err(breez_liquid::BreezError::SignerError( "BreezClient missing - should have been pre-loaded after PIN entry. \ Liquid wallet is encrypted and cannot be loaded without PIN." .to_string(), @@ -339,6 +346,7 @@ impl Tab { Some(*settings), cube.clone(), i.breez_client.clone(), // Pass pre-loaded BreezClient from installer + None, // Installer path doesn't plumb Spark yet — follow-up ); self.state = State::Loader(loader); command.map(Message::Load) @@ -358,6 +366,7 @@ impl Tab { let (app, command) = app::App::new_without_wallet( breez.clone(), + None, // Spark backend — Phase 4 wires the runtime spawn cfg, i.datadir.clone(), network, @@ -438,6 +447,7 @@ impl Tab { // Use pre-loaded BreezClient (came from PIN entry path) return Task::done(Message::Load(loader::Message::BreezLoaded { breez, + spark_backend: loader.spark_backend.clone(), cache, wallet, config: loader.gui_config.clone(), @@ -470,6 +480,7 @@ impl Tab { // Use pre-loaded BreezClient (came from PIN entry path) return Task::done(Message::Load(loader::Message::BreezLoaded { breez, + spark_backend: loader.spark_backend.clone(), cache, wallet, config, @@ -495,6 +506,7 @@ impl Tab { } loader::Message::BreezLoaded { breez, + spark_backend, cache, wallet, config, @@ -508,6 +520,7 @@ impl Tab { cache, wallet, breez, + spark_backend, config, daemon, datadir, @@ -572,11 +585,21 @@ impl Tab { Task::perform( async move { - // Load BreezClient for Liquid wallet with PIN + // Both Breez SDKs (Liquid + Spark) load + // from the same HotSigner fingerprint. + // A single fingerprint is persisted on + // the cube as `breez_wallet_signer_fingerprint`; + // the serde alias on that field accepts + // the old `liquid_wallet_signer_fingerprint` + // name transparently, so cubes written + // before the consolidation still load. + let breez_signer_fingerprint = + cube.breez_wallet_signer_fingerprint; + let breez_result = if let Some(fingerprint) = - cube.liquid_wallet_signer_fingerprint + breez_signer_fingerprint { - breez::load_breez_client( + breez_liquid::load_breez_client( datadir_clone.path(), network_val, fingerprint, @@ -584,17 +607,53 @@ impl Tab { ) .await } else { - Err(breez::BreezError::SignerError( + Err(breez_liquid::BreezError::SignerError( "No Liquid wallet configured".to_string(), )) }; + // Load Spark backend alongside Liquid. Failures + // here are non-fatal — we log + return None so + // the gui can continue with Liquid-only and the + // Spark panels surface a placeholder. The load + // path spawns the bridge subprocess + // (coincube-spark-bridge), performs the init + // handshake with the cube's mnemonic, and + // returns an Arc on success. + let spark_backend = if let Some(fingerprint) = + breez_signer_fingerprint + { + match app::breez_spark::load_spark_client( + datadir_clone.path(), + network_val, + fingerprint, + &pin, + ) + .await + { + Ok(client) => Some(Arc::new( + app::wallets::SparkBackend::new(client), + )), + Err(e) => { + tracing::warn!( + "Spark bridge unavailable, continuing \ + without Spark: {}", + e + ); + None + } + } + } else { + None + }; + ( config_clone, datadir_clone, network_val, cube, breez_result, + spark_backend, wallet_settings_clone, internal_bitcoind_clone, backup_clone, @@ -606,12 +665,14 @@ impl Tab { network, cube, breez_result, + spark_backend, wallet_settings, internal_bitcoind, backup, )| { Message::BreezClientLoadedAfterPin { breez_client: breez_result, + spark_backend, config, datadir, network, @@ -665,7 +726,7 @@ impl Tab { "BreezClient unavailable for remote backend, continuing in disconnected mode: {}", e ); - Arc::new(app::breez::BreezClient::disconnected(network)) + Arc::new(app::breez_liquid::BreezClient::disconnected(network)) } }; match create_app_with_remote_backend( @@ -694,6 +755,7 @@ impl Tab { _, Message::BreezClientLoadedAfterPin { breez_client, + spark_backend, config, datadir, network, @@ -710,19 +772,23 @@ impl Tab { // will surface their own errors on demand. let breez = match breez_client { Ok(breez) => breez, - Err(app::breez::BreezError::NetworkNotSupported(_)) => { - Arc::new(app::breez::BreezClient::disconnected(network)) + Err(app::breez_liquid::BreezError::NetworkNotSupported(_)) => { + Arc::new(app::breez_liquid::BreezClient::disconnected(network)) } Err(e) => { tracing::warn!( "BreezClient unavailable after PIN, continuing in disconnected mode: {}", e ); - Arc::new(app::breez::BreezClient::disconnected(network)) + Arc::new(app::breez_liquid::BreezClient::disconnected(network)) } }; if let Some(wallet_settings) = wallet_settings { if wallet_settings.remote_backend_auth.is_some() { + // Remote-backend login path doesn't plumb Spark yet — + // the remote backend uses `create_app_with_remote_backend` + // which takes its own `None` for Spark. Wiring the + // remote-backend Spark path is a follow-up. let (login, command) = login::CoincubeLiteLogin::new( datadir.clone(), network, @@ -741,13 +807,20 @@ impl Tab { Some(wallet_settings.clone()), cube, Some(breez), + spark_backend, ); self.state = State::Loader(loader); command.map(Message::Load) } } else { - let (app, command) = - App::new_without_wallet(breez, config, datadir, network, cube); + let (app, command) = App::new_without_wallet( + breez, + spark_backend, + config, + datadir, + network, + cube, + ); self.state = State::App(app); command.map(Message::Run) } @@ -907,7 +980,7 @@ pub fn create_app_with_remote_backend( coincube_dir: CoincubeDirectory, network: bitcoin::Network, config: app::Config, - breez_client: Arc, + breez_client: Arc, ) -> Result<(app::App, iced::Task), String> { // If someone modified the wallet_alias on Liana-Connect, // then the new alias is imported and stored in the settings file. @@ -1010,6 +1083,7 @@ pub fn create_app_with_remote_backend( node_bitcoind_ibd: None, node_bitcoind_last_log: None, vault_expanded: false, + spark_expanded: false, liquid_expanded: false, marketplace_expanded: false, marketplace_p2p_expanded: false, @@ -1022,6 +1096,8 @@ pub fn create_app_with_remote_backend( btc_usd_price: None, show_direction_badges: true, lightning_address: None, + cube_id: cube_settings.id.clone(), + default_lightning_backend: cube_settings.default_lightning_backend, }, Arc::new( Wallet::new(wallet.descriptor) @@ -1036,6 +1112,7 @@ pub fn create_app_with_remote_backend( .expect("Datadir should be conform"), ), breez_client, + None, // Spark backend — Phase 4 wires the runtime spawn config, Arc::new(remote_backend), coincube_dir, diff --git a/coincube-gui/src/installer/mod.rs b/coincube-gui/src/installer/mod.rs index fbf71f88d..338132768 100644 --- a/coincube-gui/src/installer/mod.rs +++ b/coincube-gui/src/installer/mod.rs @@ -93,7 +93,7 @@ pub struct Installer { pub cube_settings: Option, /// Pre-loaded BreezClient when launched from app (avoids re-entering PIN) - pub breez_client: Option>, + pub breez_client: Option>, } impl Installer { @@ -134,7 +134,7 @@ impl Installer { user_flow: UserFlow, launched_from_app: bool, cube_settings: Option, - breez_client: Option>, + breez_client: Option>, ) -> (Installer, Task) { let signer = Arc::new(Mutex::new(Signer::generate(network).unwrap())); let context = Context::new( diff --git a/coincube-gui/src/launcher.rs b/coincube-gui/src/launcher.rs index 429e9fa64..f4599fa58 100644 --- a/coincube-gui/src/launcher.rs +++ b/coincube-gui/src/launcher.rs @@ -353,15 +353,15 @@ impl Launcher { let without_recovery = Task::perform( async move { // Generate Liquid wallet HotSigner - let liquid_signer = HotSigner::generate(network).map_err(|e| { - format!("Failed to generate Liquid wallet signer: {}", e) + let breez_signer = HotSigner::generate(network).map_err(|e| { + format!("Failed to generate Breez wallet signer: {}", e) })?; // Create secp context for fingerprint calculation let secp = coincube_core::miniscript::bitcoin::secp256k1::Secp256k1::new(); - let liquid_fingerprint = liquid_signer.fingerprint(&secp); + let breez_key_fingerprint = breez_signer.fingerprint(&secp); - // Store Liquid wallet mnemonic (encrypted with PIN if provided) + // Store Breez wallet mnemonic (shared by Liquid + Spark) (encrypted with PIN if provided) let network_dir = datadir_path.network_directory(network); network_dir .init() @@ -369,26 +369,31 @@ impl Launcher { // Use a timestamp for the Liquid wallet storage let timestamp = chrono::Utc::now().timestamp(); - let liquid_checksum = format!("liquid_{}", timestamp); + let breez_checksum = format!("breez_{}", timestamp); - // Store Liquid wallet mnemonic encrypted with PIN (always required) - liquid_signer + // Store Breez wallet mnemonic (shared by Liquid + Spark) encrypted with PIN (always required) + breez_signer .store_encrypted( datadir_path.path(), network, &secp, - Some((liquid_checksum, timestamp)), + Some((breez_checksum, timestamp)), Some(&pin), ) .map_err(|e| { - format!("Failed to store Liquid wallet mnemonic: {}", e) + format!("Failed to store Breez wallet mnemonic: {}", e) })?; - tracing::info!("Liquid wallet signer created and stored (encrypted with PIN) with fingerprint: {}", liquid_fingerprint); + tracing::info!("Breez wallet signer created and stored (encrypted with PIN) with fingerprint: {}", breez_key_fingerprint); // Build Cube settings using the pre-generated, stable UUID. let cube = CubeSettings::new_with_id(cube_id, cube_name, network) - .with_liquid_signer(liquid_fingerprint) + // One HotSigner drives both Breez SDKs: + // Liquid + Spark derive wallet keys from + // the same seed at different BIP-32 paths, + // so one mnemonic / PIN / backup covers + // everything. + .with_breez_signer(breez_key_fingerprint) .with_pin(&pin) .map_err(|e| format!("Failed to hash PIN: {}", e))?; @@ -953,7 +958,7 @@ impl Launcher { Task::perform( async move { // Restore Liquid wallet HotSigner from mnemonic - let liquid_signer = HotSigner::from_mnemonic(network, mnemonic) + let breez_signer = HotSigner::from_mnemonic(network, mnemonic) .map_err(|e| { format!("Failed to restore from mnemonic: {}", e) })?; @@ -961,9 +966,9 @@ impl Launcher { // Create secp context for fingerprint calculation let secp = coincube_core::miniscript::bitcoin::secp256k1::Secp256k1::new(); - let liquid_fingerprint = liquid_signer.fingerprint(&secp); + let breez_key_fingerprint = breez_signer.fingerprint(&secp); - // Store Liquid wallet mnemonic (encrypted with PIN if provided) + // Store Breez wallet mnemonic (shared by Liquid + Spark) (encrypted with PIN if provided) let network_dir = datadir_path.network_directory(network); network_dir.init().map_err(|e| { format!("Failed to create network directory: {}", e) @@ -971,26 +976,29 @@ impl Launcher { // Use a timestamp for the Liquid wallet storage let timestamp = chrono::Utc::now().timestamp(); - let liquid_checksum = format!("liquid_{}", timestamp); + let breez_checksum = format!("breez_{}", timestamp); - // Store Liquid wallet mnemonic encrypted with PIN (always required) - liquid_signer + // Store Breez wallet mnemonic (shared by Liquid + Spark) encrypted with PIN (always required) + breez_signer .store_encrypted( datadir_path.path(), network, &secp, - Some((liquid_checksum, timestamp)), + Some((breez_checksum, timestamp)), Some(&pin), ) .map_err(|e| { - format!("Failed to store Liquid wallet mnemonic: {}", e) + format!("Failed to store Breez wallet mnemonic: {}", e) })?; - tracing::info!("Liquid wallet signer created and stored (encrypted with PIN) with fingerprint: {}", liquid_fingerprint); + tracing::info!("Breez wallet signer created and stored (encrypted with PIN) with fingerprint: {}", breez_key_fingerprint); // Build Cube settings using the pre-generated, stable UUID. let cube = CubeSettings::new_with_id(cube_id, cube_name, network) - .with_liquid_signer(liquid_fingerprint) + // One HotSigner drives both Breez + // SDKs — see the without_recovery + // path above for the rationale. + .with_breez_signer(breez_key_fingerprint) .with_pin(&pin) .map_err(|e| format!("Failed to hash PIN: {}", e))?; diff --git a/coincube-gui/src/loader.rs b/coincube-gui/src/loader.rs index 331a88490..1a9bbd073 100644 --- a/coincube-gui/src/loader.rs +++ b/coincube-gui/src/loader.rs @@ -29,7 +29,7 @@ use coincubed::{ }; use crate::app; -use crate::app::breez::BreezClient; +use crate::app::breez_liquid::BreezClient; use crate::app::cache::DaemonCache; use crate::app::settings::{CubeSettings, WalletSettings}; use crate::backup::Backup; @@ -72,6 +72,13 @@ pub struct Loader { pub wallet_settings: Option, pub cube_settings: CubeSettings, pub breez_client: Option>, + /// Optional Spark backend loaded alongside `breez_client` in the PIN + /// flow. `None` when the cube has no Spark signer or when the bridge + /// subprocess failed to spawn — panels that depend on Spark surface a + /// "Spark unavailable" placeholder in that case. Propagated unchanged + /// from [`Message::BreezClientLoadedAfterPin`] through + /// [`Message::BreezLoaded`] into [`app::App::new`]. + pub spark_backend: Option>, step: Step, quote_provider: QuoteProvider, current_quote: Quote, @@ -128,6 +135,7 @@ pub enum Message { ), BreezLoaded { breez: std::sync::Arc, + spark_backend: Option>, cache: Cache, wallet: Arc, config: app::Config, @@ -156,6 +164,7 @@ impl Loader { wallet_settings: Option, cube_settings: CubeSettings, breez_client: Option>, + spark_backend: Option>, ) -> (Self, Task) { let task = if let Some(ref wallet) = wallet_settings { let socket_path = datadir_path @@ -185,6 +194,7 @@ impl Loader { cube_settings, backup, breez_client, + spark_backend, quote_provider, current_quote, current_image_handle, @@ -422,6 +432,7 @@ impl Loader { self.wallet_settings.clone(), self.cube_settings.clone(), self.breez_client.clone(), + self.spark_backend.clone(), ); *self = loader; cmd @@ -595,6 +606,7 @@ pub async fn load_application( node_bitcoind_ibd: None, node_bitcoind_last_log: None, vault_expanded: false, + spark_expanded: false, liquid_expanded: false, marketplace_expanded: false, marketplace_p2p_expanded: false, @@ -607,6 +619,8 @@ pub async fn load_application( btc_usd_price: None, show_direction_badges: true, lightning_address: None, + cube_id: config.cube_settings.id.clone(), + default_lightning_backend: config.cube_settings.default_lightning_backend, }; Ok(( diff --git a/coincube-gui/src/services/connect/login.rs b/coincube-gui/src/services/connect/login.rs index a258db98e..8db5432b5 100644 --- a/coincube-gui/src/services/connect/login.rs +++ b/coincube-gui/src/services/connect/login.rs @@ -116,7 +116,7 @@ pub struct CoincubeLiteLogin { pub datadir: CoincubeDirectory, pub network: Network, pub settings: WalletSettings, - pub breez_client: Option>, + pub breez_client: Option>, wallet_id: String, email: String, @@ -146,7 +146,7 @@ impl CoincubeLiteLogin { datadir: CoincubeDirectory, network: Network, settings: WalletSettings, - breez_client: Option>, + breez_client: Option>, ) -> (Self, Task) { let auth = settings.remote_backend_auth.clone().unwrap(); ( diff --git a/coincube-gui/src/services/lnurl/mod.rs b/coincube-gui/src/services/lnurl/mod.rs index 2f4033e16..2568bcbe9 100644 --- a/coincube-gui/src/services/lnurl/mod.rs +++ b/coincube-gui/src/services/lnurl/mod.rs @@ -10,6 +10,14 @@ pub struct InvoiceRequestEvent { pub username: String, pub amount_msats: u64, pub description_hash: String, + /// Raw preimage of `description_hash`: the LNURL metadata JSON for + /// standard requests, or the serialized nostr zap request for + /// NIP-57 zaps. Present on API versions that support Spark routing + /// (Phase 5+) and absent on older servers — the SSE handler falls + /// back to Liquid (which commits to the hash directly) whenever + /// this field is missing. + #[serde(default)] + pub description: Option, } /// Request body for POST /api/v1/lnurl/invoice-response diff --git a/coincube-gui/src/services/lnurl/stream.rs b/coincube-gui/src/services/lnurl/stream.rs index 455044b68..dbcbb2d89 100644 --- a/coincube-gui/src/services/lnurl/stream.rs +++ b/coincube-gui/src/services/lnurl/stream.rs @@ -4,19 +4,35 @@ use std::sync::Arc; use iced::futures::{self, SinkExt, TryStreamExt}; use reqwest_sse::EventSource as _; -use crate::app::breez::BreezClient; +use crate::app::breez_liquid::BreezClient; +use crate::app::wallets::{SparkBackend, WalletKind}; use super::{InvoiceRequestEvent, InvoiceResponse, LnurlMessage}; +/// BOLT11 BOLT-11 encoded description field hard limit: tagged field +/// values are length-prefixed with 10 bits, so the upper bound is +/// 1023 bytes. Most payer wallets reject invoices larger than this +/// anyway, and the LUD-06 spec caps metadata at ~639 bytes for +/// reliable cross-wallet compatibility. We use 639 as the Spark-side +/// max; longer descriptions (commonly NIP-57 zap requests) fall back +/// to Liquid so the invoice still commits via description_hash. +const BOLT11_MAX_DESCRIPTION_BYTES: usize = 639; + /// Wrapper around the data needed for the LNURL SSE subscription. /// Implements `Hash` based only on `token` and `retries` so that /// Iced re-creates the subscription when those change (reconnect on -/// disconnect), while the `breez_client` Arc is passed through without +/// disconnect), while the backend Arcs are passed through without /// affecting identity. +/// +/// Phase 5: both backends are held so `handle_invoice_request` can +/// route per-request based on the cube's `default_lightning_backend` +/// preference and the incoming event's description length. struct LnurlStreamData { token: String, retries: usize, breez_client: Arc, + spark_backend: Option>, + preferred: WalletKind, } impl Hash for LnurlStreamData { @@ -35,12 +51,16 @@ pub fn lnurl_subscription( token: String, retries: usize, breez_client: Arc, + spark_backend: Option>, + preferred: WalletKind, ) -> iced::Subscription { iced::Subscription::run_with( LnurlStreamData { token, retries, breez_client, + spark_backend, + preferred, }, create_stream, ) @@ -54,6 +74,8 @@ fn create_stream( let auth = format!("Bearer {}", data.token); let sse_url = format!("{}/api/v1/lnurl/stream", api_base_url); let breez_client = data.breez_client.clone(); + let spark_backend = data.spark_backend.clone(); + let preferred = data.preferred; let retries = data.retries; // Attempt to parse parameters for the SSE connection @@ -171,6 +193,8 @@ fn create_stream( handle_invoice_request( &mut channel, &breez_client, + spark_backend.as_ref(), + preferred, &http, &api_base_url, &auth_header, @@ -234,11 +258,26 @@ fn create_stream( } /// Handles an incoming LNURL invoice request: -/// 1. Generates a BOLT11 invoice via Breez SDK +/// 1. Generates a BOLT11 invoice via the routed backend /// 2. POSTs the invoice back to the API +/// +/// Routing rules (Phase 5): +/// - `preferred == Spark` AND `spark_backend.is_some()` AND the event +/// carries a `description` AND the description fits in +/// [`BOLT11_MAX_DESCRIPTION_BYTES`] → Spark. The invoice's `d` tag +/// holds the raw metadata string; the payer's wallet must verify +/// that SHA256(description) matches the callback's metadata hash, +/// which it does by construction (the API computes them from the +/// same source). +/// - Otherwise → Liquid, which commits via `description_hash` +/// directly and handles zap requests (NIP-57) that exceed the +/// 639-byte cap. +#[allow(clippy::too_many_arguments)] async fn handle_invoice_request( channel: &mut iced::futures::channel::mpsc::Sender, breez_client: &Arc, + spark_backend: Option<&Arc>, + preferred: WalletKind, http: &reqwest::Client, api_base_url: &str, auth_header: &str, @@ -274,14 +313,45 @@ async fn handle_invoice_request( } let amount_sat = event.amount_msats / 1000; - // Generate BOLT11 invoice via Breez Liquid SDK - let invoice_result = breez_client - .receive_lnurl_invoice(amount_sat, event.description_hash) - .await; + // Decide which backend mints the invoice. Spark only runs when + // all preconditions hold: explicit preference, bridge available, + // API sent a description preimage, and the preimage fits the + // BOLT11 description cap. Any missing precondition drops to + // Liquid, which is always available and commits via hash. + let use_spark = matches!(preferred, WalletKind::Spark) + && spark_backend.is_some() + && event + .description + .as_deref() + .is_some_and(|d| d.len() <= BOLT11_MAX_DESCRIPTION_BYTES); + + let invoice_result = if use_spark { + let spark = spark_backend.expect("checked above"); + let description = event + .description + .clone() + .expect("checked above"); + log::info!( + "[LNURL] Routing request {} via Spark (desc_len={})", + request_id, + description.len() + ); + spark + .receive_bolt11(Some(amount_sat), description, None) + .await + .map(|ok| ok.payment_request) + .map_err(|e| e.to_string()) + } else { + log::info!("[LNURL] Routing request {} via Liquid", request_id); + breez_client + .receive_lnurl_invoice(amount_sat, event.description_hash.clone()) + .await + .map(|resp| resp.destination) + .map_err(|e| e.to_string()) + }; match invoice_result { - Ok(response) => { - let payment_request = response.destination; + Ok(payment_request) => { log::info!( "[LNURL] Invoice generated for request {}: {}...", @@ -336,10 +406,9 @@ async fn handle_invoice_request( } } } - Err(err) => { - let error = err.to_string(); + Err(error) => { log::error!( - "[LNURL] Breez SDK failed to generate invoice for request {}: {}", + "[LNURL] Backend failed to generate invoice for request {}: {}", request_id, error ); diff --git a/coincube-spark-bridge/Cargo.lock b/coincube-spark-bridge/Cargo.lock new file mode 100644 index 000000000..ebd44dfb5 --- /dev/null +++ b/coincube-spark-bridge/Cargo.lock @@ -0,0 +1,4047 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals", + "bitcoin_hashes", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin" +version = "0.32.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" +dependencies = [ + "base58ck", + "bech32", + "bitcoin-internals", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" +dependencies = [ + "serde", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin-units" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" +dependencies = [ + "bitcoin-internals", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bitreq" +version = "0.3.4" +source = "git+https://github.com/breez/corepc?branch=yse-bitreq-rustls#d052f8f0efd05459dd4ae157a2d68d2044ac824c" +dependencies = [ + "rustls", + "rustls-webpki 0.101.7", + "serde", + "serde_json", + "tokio", + "tokio-rustls", + "webpki-roots 0.25.4", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "breez-sdk-common" +version = "0.1.0" +source = "git+https://github.com/breez/spark-sdk?tag=0.13.1#465f1c4f56eec02132d7f2e279cb9f91b2cb0146" +dependencies = [ + "aes", + "anyhow", + "async-trait", + "base64", + "bech32", + "bitcoin", + "bitreq", + "cbc", + "dnssec-prover", + "futures", + "hex", + "http", + "lightning", + "macros", + "platform-utils", + "prost", + "regex-lite", + "reqwest", + "serde", + "serde_json", + "spark", + "spark-wallet", + "thiserror 2.0.18", + "tokio", + "tonic", + "tonic-build", + "tonic-web-wasm-client", + "tower-service", + "tracing", +] + +[[package]] +name = "breez-sdk-spark" +version = "0.1.0" +source = "git+https://github.com/breez/spark-sdk?tag=0.13.1#465f1c4f56eec02132d7f2e279cb9f91b2cb0146" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "bip39", + "bitcoin", + "bitflags", + "breez-sdk-common", + "built", + "chrono", + "flashnet", + "frost-secp256k1-tr-unofficial", + "hex", + "k256", + "lnurl-models", + "macros", + "platform-utils", + "rusqlite", + "rusqlite_migration", + "serde", + "serde_json", + "spark-wallet", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "utils", + "uuid", + "x509-cert", +] + +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" +dependencies = [ + "git2", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "coincube-spark-bridge" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "breez-sdk-spark", + "clap", + "coincube-spark-protocol", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "coincube-spark-protocol" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-crc32-nostd" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808ac43170e95b11dd23d78aa9eaac5bea45776a602955552c4e833f3f0f823d" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "debugless-unwrap" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f400d0750c0c069e8493f2256cb4da6f604b6d2eeb69a0ca8863acde352f8400" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive-getters" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74ef43543e701c01ad77d3a5922755c6a1d71b22d942cb8042be4994b380caff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dnssec-prover" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4f825369fc7134da70ca4040fddc8e03b80a46d249ae38d9c1c39b7b4476bf" +dependencies = [ + "bitcoin_hashes", + "tokio", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flashnet" +version = "0.1.0" +source = "git+https://github.com/breez/spark-sdk?tag=0.13.1#465f1c4f56eec02132d7f2e279cb9f91b2cb0146" +dependencies = [ + "base64", + "bitcoin", + "getrandom 0.2.17", + "hex", + "macros", + "platform-utils", + "rand", + "serde", + "serde_json", + "serde_urlencoded", + "serde_with", + "spark", + "spark-wallet", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "frost-core-unofficial" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579bf9770b82531690aa948a39dcd77f098f86389d5b45b76252257420ff263d" +dependencies = [ + "byteorder", + "const-crc32-nostd", + "debugless-unwrap", + "derive-getters", + "document-features", + "hex", + "itertools", + "postcard", + "rand_core", + "serde", + "serdect", + "thiserror 2.0.18", + "thiserror-nostd-notrait", + "visibility", + "zeroize", +] + +[[package]] +name = "frost-rerandomized-unofficial" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3736da210f3df297cd1c2b58cf2934510f8f39d9ff6248bae8f1f6457fde98" +dependencies = [ + "derive-getters", + "document-features", + "frost-core-unofficial", + "hex", + "rand_core", +] + +[[package]] +name = "frost-secp256k1-tr-unofficial" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5279a0a375d505f5ec711521284d183ab6fea98edfdf798d65434b16c59a251" +dependencies = [ + "document-features", + "frost-core-unofficial", + "frost-rerandomized-unofficial", + "k256", + "rand_core", + "sha2", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "url", +] + +[[package]] +name = "graphql-introspection-query" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2a4732cf5140bd6c082434494f785a19cfb566ab07d1382c3671f5812fed6d" +dependencies = [ + "serde", +] + +[[package]] +name = "graphql-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a818c0d883d7c0801df27be910917750932be279c7bc82dc541b8769425f409" +dependencies = [ + "combine", + "thiserror 1.0.69", +] + +[[package]] +name = "graphql_client" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50cfdc7f34b7f01909d55c2dcb71d4c13cbcbb4a1605d6c8bd760d654c1144b" +dependencies = [ + "graphql_query_derive", + "serde", + "serde_json", +] + +[[package]] +name = "graphql_client_codegen" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e27ed0c2cf0c0cc52c6bcf3b45c907f433015e580879d14005386251842fb0a" +dependencies = [ + "graphql-introspection-query", + "graphql-parser", + "heck 0.4.1", + "lazy_static", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 1.0.109", +] + +[[package]] +name = "graphql_query_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83febfa838f898cfa73dfaa7a8eb69ff3409021ac06ee94cfb3d622f6eeb1a97" +dependencies = [ + "graphql_client_codegen", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", + "signature", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libgit2-sys" +version = "0.18.3+1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lightning" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffe63f56b10d214be1ade8698ee80af82d981237cae3e581e7a631f5f4959f3" +dependencies = [ + "bech32", + "bitcoin", + "dnssec-prover", + "hashbrown 0.13.2", + "libm", + "lightning-invoice", + "lightning-types", + "possiblyrandom", +] + +[[package]] +name = "lightning-invoice" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11209f386879b97198b2bfc9e9c1e5d42870825c6bd4376f17f95357244d6600" +dependencies = [ + "bech32", + "bitcoin", + "lightning-types", +] + +[[package]] +name = "lightning-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cd84d4e71472035903e43caded8ecc123066ce466329ccd5ae537a8d5488c7" +dependencies = [ + "bitcoin", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lnurl-models" +version = "0.1.0" +source = "git+https://github.com/breez/spark-sdk?tag=0.13.1#465f1c4f56eec02132d7f2e279cb9f91b2cb0146" +dependencies = [ + "serde", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "macros" +version = "0.1.0" +source = "git+https://github.com/breez/spark-sdk?tag=0.13.1#465f1c4f56eec02132d7f2e279cb9f91b2cb0146" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "platform-utils" +version = "0.1.0" +source = "git+https://github.com/breez/spark-sdk?tag=0.13.1#465f1c4f56eec02132d7f2e279cb9f91b2cb0146" +dependencies = [ + "async-trait", + "base64", + "bitreq", + "macros", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio_with_wasm", + "tracing", + "web-time", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "possiblyrandom" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b122a615d72104fb3d8b26523fdf9232cd8ee06949fb37e4ce3ff964d15dffd" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck 0.5.0", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.117", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.3", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rusqlite_migration" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923b42e802f7dc20a0a6b5e097ba7c83fe4289da07e49156fecf6af08aa9cd1c" +dependencies = [ + "log", + "rusqlite", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.12", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "rand", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spark" +version = "0.1.0" +source = "git+https://github.com/breez/spark-sdk?tag=0.13.1#465f1c4f56eec02132d7f2e279cb9f91b2cb0146" +dependencies = [ + "async-trait", + "base64", + "bitcoin", + "built", + "bytes", + "chrono", + "frost-core-unofficial", + "frost-secp256k1-tr-unofficial", + "futures", + "graphql_client", + "hex", + "http", + "http-body", + "http-body-util", + "k256", + "lightning-invoice", + "macros", + "platform-utils", + "prost", + "prost-types", + "rand", + "rustls", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tonic", + "tonic-build", + "tonic-web-wasm-client", + "tower-service", + "tracing", + "unicode-normalization", + "utils", + "uuid", +] + +[[package]] +name = "spark-wallet" +version = "0.1.0" +source = "git+https://github.com/breez/spark-sdk?tag=0.13.1#465f1c4f56eec02132d7f2e279cb9f91b2cb0146" +dependencies = [ + "bitcoin", + "frost-secp256k1-tr-unofficial", + "futures", + "hex", + "platform-utils", + "serde", + "spark", + "thiserror 2.0.18", + "tokio", + "tonic", + "tracing", + "uuid", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[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.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-nostd-notrait" +version = "1.0.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8444e638022c44d2a9337031dee8acb732bcc7fbf52ac654edc236b26408b61" +dependencies = [ + "thiserror-nostd-notrait-impl", +] + +[[package]] +name = "thiserror-nostd-notrait-impl" +version = "1.0.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585e5ef40a784ce60b49c67d762110688d211d395d39e096be204535cf64590e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio_with_wasm" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e40fbbbd95441133fe9483f522db15dbfd26dc636164ebd8f2dd28759a6aa6" +dependencies = [ + "js-sys", + "tokio", + "tokio_with_wasm_proc", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "tokio_with_wasm_proc" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01145a2c788d6aae4cd653afec1e8332534d7d783d01897cefcafe4428de992" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "rustls-native-certs", + "rustls-pemfile", + "socket2 0.5.10", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tonic-web-wasm-client" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8957be1a1c7aa12d4c9d67882060dd57aed816bbc553fa60949312e839f4a8ea" +dependencies = [ + "base64", + "byteorder", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "httparse", + "js-sys", + "pin-project", + "thiserror 1.0.69", + "tonic", + "tower-service", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utils" +version = "0.1.0" +source = "git+https://github.com/breez/spark-sdk?tag=0.13.1#465f1c4f56eec02132d7f2e279cb9f91b2cb0146" +dependencies = [ + "aes-gcm", + "getrandom 0.2.17", + "hkdf", + "rand", + "secp256k1", + "sha2", + "thiserror 2.0.18", +] + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/coincube-spark-bridge/Cargo.toml b/coincube-spark-bridge/Cargo.toml new file mode 100644 index 000000000..b73dfae3e --- /dev/null +++ b/coincube-spark-bridge/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "coincube-spark-bridge" +version = "0.1.0" +edition = "2021" +license-file = "../LICENCE" +description = "Sibling process that hosts breez-sdk-spark and exposes it to coincube-gui over stdin/stdout JSON-RPC" + +# Standalone single-member workspace with its own Cargo.lock — deliberately +# isolated from the main coincube workspace so that breez-sdk-spark's dep +# graph (tokio_with_wasm ^0.8.7, rusqlite 0.32.1 + libsqlite3-sys) doesn't +# have to be unified with breez-sdk-liquid's. See the main Cargo.toml +# `exclude = ["coincube-spark-bridge"]` entry for the paired side of this +# setup. +[workspace] +members = ["."] +resolver = "2" + +[[bin]] +name = "coincube-spark-bridge" +path = "src/main.rs" + +[lints.rust] +mismatched_lifetime_syntaxes = "allow" + +[dependencies] +coincube-spark-protocol = { path = "../coincube-spark-protocol" } +breez-sdk-spark = { git = "https://github.com/breez/spark-sdk", tag = "0.13.1" } +async-trait = "0.1" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1", features = [ + "rt-multi-thread", + "macros", + "io-std", + "io-util", + "sync", + "signal", + "time", +] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1.0" +clap = { version = "4", features = ["derive"] } +uuid = { version = "1", features = ["v4"] } diff --git a/coincube-spark-bridge/src/main.rs b/coincube-spark-bridge/src/main.rs new file mode 100644 index 000000000..98a02e31e --- /dev/null +++ b/coincube-spark-bridge/src/main.rs @@ -0,0 +1,118 @@ +//! Sibling process that hosts the Breez Spark SDK and exposes it to +//! `coincube-gui` over a stdin/stdout JSON-RPC stream. +//! +//! Why a separate process: the Liquid and Spark SDKs can't coexist in a +//! single Rust binary today (incompatible `rusqlite`/`libsqlite3-sys` +//! graphs, conflicting `links = "sqlite3"` attribute). The gui links +//! Liquid; this bridge links Spark; they talk via line-delimited JSON. +//! +//! Modes of operation: +//! - Default: run as a subprocess speaking [`coincube_spark_protocol::Frame`] +//! messages on stdin/stdout. Parent process drives the lifecycle. +//! - `--smoke-test`: connect to Spark mainnet using env vars +//! (`BREEZ_SPARK_API_KEY`, `COINCUBE_SPARK_MNEMONIC`, +//! `COINCUBE_SPARK_STORAGE_DIR`), fetch info + list payments, print to +//! stdout, and exit. Intended as the Phase 2 standalone harness. + +mod sdk_adapter; +mod server; + +use clap::Parser; +use tracing_subscriber::EnvFilter; + +/// CLI options for the bridge binary. +#[derive(Debug, Parser)] +#[command( + name = "coincube-spark-bridge", + about = "Sibling process hosting breez-sdk-spark for coincube-gui" +)] +struct Cli { + /// Run a one-shot connect + info + list-payments round trip against + /// mainnet using env configuration, print the result, and exit. + /// + /// Required env vars: `BREEZ_SPARK_API_KEY`, `COINCUBE_SPARK_MNEMONIC`, + /// `COINCUBE_SPARK_STORAGE_DIR`. + #[arg(long)] + smoke_test: bool, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Send all tracing output to stderr so the stdout channel stays clean + // for JSON frames. + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,breez_sdk_spark=warn")), + ) + .with_writer(std::io::stderr) + .init(); + + let cli = Cli::parse(); + if cli.smoke_test { + smoke_test::run().await + } else { + server::run().await + } +} + +mod smoke_test { + //! Standalone harness. Not used when the bridge runs under a parent process. + + use std::env; + + use breez_sdk_spark::{GetInfoRequest, ListPaymentsRequest}; + + use crate::sdk_adapter::{self, SdkHandle}; + + pub async fn run() -> anyhow::Result<()> { + let api_key = env::var("BREEZ_SPARK_API_KEY") + .map_err(|_| anyhow::anyhow!("BREEZ_SPARK_API_KEY must be set for --smoke-test"))?; + let mnemonic = env::var("COINCUBE_SPARK_MNEMONIC").map_err(|_| { + anyhow::anyhow!("COINCUBE_SPARK_MNEMONIC must be set for --smoke-test") + })?; + let storage_dir = env::var("COINCUBE_SPARK_STORAGE_DIR").map_err(|_| { + anyhow::anyhow!("COINCUBE_SPARK_STORAGE_DIR must be set for --smoke-test") + })?; + + eprintln!("connecting to Spark mainnet with storage at {storage_dir}"); + let handle: SdkHandle = sdk_adapter::connect_mainnet( + api_key, + mnemonic, + None, // passphrase + storage_dir, + ) + .await?; + + let info = handle + .sdk + .get_info(GetInfoRequest { + ensure_synced: Some(true), + }) + .await?; + eprintln!( + "connected — identity {}, balance {} sats", + info.identity_pubkey, info.balance_sats + ); + println!("{}", serde_json::to_string_pretty(&info)?); + + let payments = handle + .sdk + .list_payments(ListPaymentsRequest { + limit: Some(20), + offset: Some(0), + sort_ascending: Some(false), + type_filter: None, + status_filter: None, + asset_filter: None, + payment_details_filter: None, + from_timestamp: None, + to_timestamp: None, + }) + .await?; + eprintln!("fetched {} recent payments", payments.payments.len()); + println!("{}", serde_json::to_string_pretty(&payments.payments)?); + + Ok(()) + } +} diff --git a/coincube-spark-bridge/src/sdk_adapter.rs b/coincube-spark-bridge/src/sdk_adapter.rs new file mode 100644 index 000000000..1c1286696 --- /dev/null +++ b/coincube-spark-bridge/src/sdk_adapter.rs @@ -0,0 +1,85 @@ +//! Thin wrapper around [`breez_sdk_spark::BreezSdk`] with a coincube-friendly +//! constructor and an `Arc`-friendly handle. +//! +//! Everything the bridge's server loop (or the smoke-test harness) needs +//! from the SDK goes through here so the SDK's type surface doesn't leak +//! into the JSON-RPC layer. + +use std::sync::Arc; + +use breez_sdk_spark::{ + connect, default_config, BreezSdk, ConnectRequest, Network as SparkNetwork, Seed, + StableBalanceConfig, StableBalanceToken, +}; + +/// Mainnet USDB token identifier. Published by Breez in the Stable +/// Balance guide at +/// . +/// Using a hardcoded constant rather than an env var is deliberate: +/// the identifier is stable across deployments and leaking the +/// knob to ops would just mean a way to silently misconfigure a +/// production wallet. +const USDB_MAINNET_TOKEN_IDENTIFIER: &str = + "btkn1xgrvjwey5ngcagvap2dzzvsy4uk8ua9x69k82dwvt5e7ef9drm9qztux87"; + +/// Integrator-defined label used to reference the USDB token in +/// [`breez_sdk_spark::UpdateUserSettingsRequest`]. This string is +/// plumbing, not user copy — the gui always renders the feature as +/// "Stable Balance" without leaking this label. +pub const STABLE_BALANCE_LABEL: &str = "USDB"; + +/// Cloneable SDK handle. The inner [`BreezSdk`] is `Send + Sync`, so the +/// bridge can freely share it across async tasks serving different +/// JSON-RPC requests concurrently. +#[derive(Clone)] +pub struct SdkHandle { + pub sdk: Arc, +} + +/// Build a mainnet Spark SDK config with the given API key. +/// +/// Phase 6: always wires up [`StableBalanceConfig`] with the single +/// USDB token. `default_active_label` is `None` so Stable Balance +/// starts deactivated — the user opts in explicitly via the Spark +/// Settings toggle, which then calls `update_user_settings`. +/// Omitting `default_active_label` also means existing users keep +/// their previous state (persisted by the SDK locally) across +/// restarts. +pub fn mainnet_config(api_key: String) -> breez_sdk_spark::Config { + let mut config = default_config(SparkNetwork::Mainnet); + config.api_key = Some(api_key); + config.stable_balance_config = Some(StableBalanceConfig { + tokens: vec![StableBalanceToken { + label: STABLE_BALANCE_LABEL.to_string(), + token_identifier: USDB_MAINNET_TOKEN_IDENTIFIER.to_string(), + }], + default_active_label: None, + threshold_sats: None, + max_slippage_bps: None, + }); + config +} + +/// Connect to Spark mainnet with the given mnemonic. +/// +/// `storage_dir` must be a writable directory — the SDK uses it for its +/// internal sqlite database, so picking the same dir twice from two +/// processes will collide. +pub async fn connect_mainnet( + api_key: String, + mnemonic: String, + passphrase: Option, + storage_dir: String, +) -> anyhow::Result { + let config = mainnet_config(api_key); + let request = ConnectRequest { + config, + seed: Seed::Mnemonic { + mnemonic, + passphrase, + }, + storage_dir, + }; + let sdk = connect(request).await?; + Ok(SdkHandle { sdk: Arc::new(sdk) }) +} diff --git a/coincube-spark-bridge/src/server.rs b/coincube-spark-bridge/src/server.rs new file mode 100644 index 000000000..c645537c5 --- /dev/null +++ b/coincube-spark-bridge/src/server.rs @@ -0,0 +1,1085 @@ +//! JSON-RPC server that reads [`Request`] frames from stdin, dispatches +//! them to the Spark SDK, and writes [`Response`]/[`Event`] frames to +//! stdout. +//! +//! Framing: line-delimited JSON. Each line is exactly one +//! [`coincube_spark_protocol::Frame`]. Errors while parsing a line produce +//! a [`Response`] with [`ErrorKind::BadRequest`] if the envelope has an +//! id, otherwise they're logged to stderr and the line is dropped. +//! +//! Concurrency: the server owns the SDK behind a [`tokio::sync::RwLock`] +//! so that `init` can mutate it exclusively while other requests read it +//! concurrently. A shutdown flag short-circuits new work while in-flight +//! requests drain. +//! +//! Scope: Phase 2 only implements Init / GetInfo / ListPayments / Shutdown. +//! Send/receive methods arrive when the UI starts consuming them. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use breez_sdk_spark::{ + ClaimDepositRequest, EventListener, GetInfoRequest, InputType, ListPaymentsRequest, + ListUnclaimedDepositsRequest, LnurlPayRequest, PaymentDetails, PrepareLnurlPayRequest, + PrepareLnurlPayResponse, PrepareSendPaymentRequest, PrepareSendPaymentResponse, + ReceivePaymentMethod, ReceivePaymentRequest, SdkEvent, SendPaymentMethod, SendPaymentRequest, + StableBalanceActiveLabel, UpdateUserSettingsRequest, +}; +use coincube_spark_protocol::{ + ClaimDepositOk, DepositInfo, ErrorKind, Event as ProtocolEvent, Frame, GetInfoOk, + GetUserSettingsOk, ListPaymentsOk, ListUnclaimedDepositsOk, Method, OkPayload, ParseInputKind, + ParseInputOk, PaymentSummary, PrepareSendOk, ReceivePaymentOk, Request, Response, + SendPaymentOk, SetStableBalanceParams, +}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::{Mutex, RwLock}; +use uuid::Uuid; + +use crate::sdk_adapter::{self, SdkHandle}; + +/// How long a pending prepare lives before the background sweep evicts +/// it. Picked at 5 minutes — long enough to cover human dwell time on +/// a Confirm screen (re-reading the fee, switching focus to confirm an +/// invoice on a phone, etc.) but short enough that a forgotten prepare +/// doesn't leak forever. The SDK's prepare responses are tied to +/// short-lived fee quotes anyway; sending after the quote expires +/// would fail at the SDK layer. +const PREPARE_TTL: Duration = Duration::from_secs(300); + +/// How often the sweep task wakes up to evict expired prepares. +const PREPARE_SWEEP_INTERVAL: Duration = Duration::from_secs(60); + +/// Run the stdin/stdout server until EOF on stdin or a `shutdown` RPC. +pub async fn run() -> anyhow::Result<()> { + // Single writer task: serializes all stdout writes so responses and + // events never interleave mid-line. We talk to it over an unbounded + // channel so request handlers never block on IO. + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + // `ServerState` holds a clone of the same sender so the event + // listener registered in `handle_init` can push `Frame::Event`s + // onto the same stdout stream the response handlers use. + let state = Arc::new(ServerState::new(tx.clone())); + + // Phase 4f: background sweep that evicts pending-prepare entries + // older than `PREPARE_TTL`. Both pending maps share the same + // policy. The task lives for the bridge process lifetime; it + // exits when the ServerState's strong count drops to 1 (i.e. + // only the sweep task itself holds it, which only happens after + // `run()` returns and the dispatcher state has been dropped). + let sweep_state = Arc::clone(&state); + tokio::spawn(async move { + let mut tick = tokio::time::interval(PREPARE_SWEEP_INTERVAL); + // Skip the immediate first tick — wait one full interval so + // the sweep doesn't fire during the init handshake. + tick.tick().await; + loop { + tick.tick().await; + // Stop sweeping once the dispatcher state is gone. + if Arc::strong_count(&sweep_state) <= 1 { + break; + } + sweep_expired_prepares(&sweep_state).await; + } + }); + let writer_task = tokio::spawn(async move { + let mut stdout = tokio::io::stdout(); + while let Some(frame) = rx.recv().await { + match serde_json::to_string(&frame) { + Ok(line) => { + if stdout.write_all(line.as_bytes()).await.is_err() + || stdout.write_all(b"\n").await.is_err() + || stdout.flush().await.is_err() + { + // Parent hung up; nothing left to do. + break; + } + } + Err(e) => { + tracing::error!("failed to serialize outbound frame: {e}"); + } + } + } + }); + + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin).lines(); + + while let Ok(Some(line)) = reader.next_line().await { + if line.trim().is_empty() { + continue; + } + + let frame: Frame = match serde_json::from_str(&line) { + Ok(f) => f, + Err(e) => { + tracing::warn!("dropping unparseable line: {e}"); + continue; + } + }; + + let request = match frame { + Frame::Request(r) => r, + Frame::Response(_) | Frame::Event(_) => { + tracing::warn!("ignoring unexpected response/event frame from parent"); + continue; + } + }; + + let id = request.id; + // Shutdown is handled inline so we can exit the read loop after + // the response is flushed. Everything else is spawned so slow + // SDK calls don't block subsequent requests. + if matches!(request.method, Method::Shutdown) { + let _ = tx.send(Frame::Response(Response::ok(id, OkPayload::Shutdown {}))); + break; + } + + let state_clone = Arc::clone(&state); + let tx_clone = tx.clone(); + tokio::spawn(async move { + let response = handle_request(request, state_clone).await; + let _ = tx_clone.send(Frame::Response(response)); + }); + } + + // Drop the writer sender so the writer task drains and exits. + drop(tx); + let _ = writer_task.await; + Ok(()) +} + +struct ServerState { + /// `None` until `init` succeeds, then `Some` for the process lifetime. + sdk: RwLock>, + /// Guards the init path so two concurrent `init` requests can't + /// both try to build an SDK at the same time. + init_lock: Mutex<()>, + /// Pending `prepare_send_payment` responses keyed by the opaque + /// handle the gui receives. The gui echoes the handle back on + /// `send_payment`; the bridge looks it up here and removes the + /// entry (single-use). Storing the full SDK struct bridge-side + /// means the gui doesn't have to round-trip a complex nested + /// response over JSON-RPC. + /// + /// Phase 4f adds an `Instant` alongside each entry so a background + /// sweep task can evict prepares older than [`PREPARE_TTL`] (5 + /// minutes) — a gui that prepares without sending no longer + /// leaks for the process lifetime. + pending_prepares: Mutex>, + /// Pending `prepare_lnurl_pay` responses. Separate from + /// `pending_prepares` because the SDK's `lnurl_pay(...)` call + /// takes a different request struct than `send_payment(...)`. + /// [`handle_send_payment`] checks both maps and dispatches to the + /// right SDK method based on which one contains the handle. Same + /// TTL eviction policy as `pending_prepares`. + pending_lnurl_prepares: Mutex>, + /// Clone of the outbound frame sender. Stored here so `handle_init` + /// can hand a copy to the Spark SDK event listener — the listener + /// pushes `Frame::Event`s on this channel the same way request + /// handlers push `Frame::Response`s, so stdout stays interleave-safe. + event_tx: tokio::sync::mpsc::UnboundedSender, +} + +impl ServerState { + fn new(event_tx: tokio::sync::mpsc::UnboundedSender) -> Self { + Self { + sdk: RwLock::new(None), + init_lock: Mutex::new(()), + pending_prepares: Mutex::new(HashMap::new()), + pending_lnurl_prepares: Mutex::new(HashMap::new()), + event_tx, + } + } +} + +async fn handle_request(request: Request, state: Arc) -> Response { + let id = request.id; + match request.method { + Method::Init(params) => handle_init(id, params, state).await, + Method::GetInfo(params) => handle_get_info(id, params, state).await, + Method::ListPayments(params) => handle_list_payments(id, params, state).await, + Method::ParseInput(params) => handle_parse_input(id, params, state).await, + Method::PrepareSend(params) => handle_prepare_send(id, params, state).await, + Method::PrepareLnurlPay(params) => handle_prepare_lnurl_pay(id, params, state).await, + Method::SendPayment(params) => handle_send_payment(id, params, state).await, + Method::ReceiveBolt11(params) => handle_receive_bolt11(id, params, state).await, + Method::ReceiveOnchain(params) => handle_receive_onchain(id, params, state).await, + Method::ListUnclaimedDeposits => handle_list_unclaimed_deposits(id, state).await, + Method::ClaimDeposit(params) => handle_claim_deposit(id, params, state).await, + Method::GetUserSettings => handle_get_user_settings(id, state).await, + Method::SetStableBalance(params) => handle_set_stable_balance(id, params, state).await, + Method::Shutdown => { + // Handled inline in the read loop — this branch exists so the + // match is exhaustive. + Response::ok(id, OkPayload::Shutdown {}) + } + } +} + +async fn handle_init( + id: u64, + params: coincube_spark_protocol::InitParams, + state: Arc, +) -> Response { + let _guard = state.init_lock.lock().await; + if state.sdk.read().await.is_some() { + return Response::err(id, ErrorKind::AlreadyConnected, "init already succeeded"); + } + + // Phase 2 skeleton only supports Mainnet; Regtest requires extra Spark + // config we're not threading yet. Error out cleanly so the caller + // knows the knob exists. + if !matches!(params.network, coincube_spark_protocol::Network::Mainnet) { + return Response::err( + id, + ErrorKind::BadRequest, + "only mainnet is supported in the Phase 2 bridge skeleton", + ); + } + + match sdk_adapter::connect_mainnet( + params.api_key, + params.mnemonic, + params.mnemonic_passphrase, + params.storage_dir, + ) + .await + { + Ok(handle) => { + // Register an event listener before making the handle + // visible to other request handlers. `add_event_listener` + // returns a listener id string (not a Result) that we could + // use for `remove_event_listener` if we wanted to rotate + // listeners — Phase 4d just holds it for the process + // lifetime, so we drop the id. + let listener = BridgeEventListener { + tx: state.event_tx.clone(), + }; + let _listener_id = handle.sdk.add_event_listener(Box::new(listener)).await; + *state.sdk.write().await = Some(handle); + Response::ok(id, OkPayload::Init {}) + } + Err(e) => Response::err(id, ErrorKind::Sdk, format!("spark connect failed: {e}")), + } +} + +/// Spark SDK → protocol event adapter. +/// +/// Registered on the SDK via `add_event_listener` once `handle_init` +/// has successfully connected. Every `SdkEvent` fires `on_event`, which +/// translates to a `ProtocolEvent` and pushes it into the shared frame +/// writer. The writer task serializes the `Frame::Event` to a single +/// line on stdout so the gui's reader picks it up alongside +/// `Frame::Response`s without framing ambiguity. +struct BridgeEventListener { + tx: tokio::sync::mpsc::UnboundedSender, +} + +#[async_trait] +impl EventListener for BridgeEventListener { + async fn on_event(&self, event: SdkEvent) { + let protocol_event = match event { + SdkEvent::Synced => Some(ProtocolEvent::Synced), + SdkEvent::PaymentSucceeded { payment } => Some(ProtocolEvent::PaymentSucceeded { + amount_sat: payment.amount as i64, + bolt11: extract_bolt11(&payment), + id: payment.id, + }), + SdkEvent::PaymentPending { payment } => Some(ProtocolEvent::PaymentPending { + id: payment.id, + amount_sat: payment.amount as i64, + }), + SdkEvent::PaymentFailed { payment } => Some(ProtocolEvent::PaymentFailed { + id: payment.id, + amount_sat: payment.amount as i64, + }), + // All three deposit-related SDK events collapse to a + // single `DepositsChanged` signal — the gui's Receive + // panel responds by re-running `list_unclaimed_deposits` + // regardless of which of the three triggered the + // refresh. + SdkEvent::UnclaimedDeposits { .. } + | SdkEvent::ClaimedDeposits { .. } + | SdkEvent::NewDeposits { .. } => Some(ProtocolEvent::DepositsChanged), + // Optimization + lightning-address-changed remain + // swallowed until a panel needs them. + SdkEvent::Optimization { .. } | SdkEvent::LightningAddressChanged { .. } => None, + }; + + if let Some(ev) = protocol_event { + let _ = self.tx.send(Frame::Event(ev)); + } + } +} + +/// Phase 4f: extract the BOLT11 invoice from a Spark `Payment` if it +/// was a Lightning payment. Returned in the `PaymentSucceeded` event +/// so the gui's Receive panel can correlate against a specific +/// generated invoice instead of advancing on any incoming payment. +/// +/// For non-Lightning payments (Spark transfers, on-chain, token), or +/// for Lightning payments where the SDK didn't populate `details`, +/// returns `None` and the gui falls back to the Phase 4d behavior of +/// advancing on any incoming payment. +fn extract_bolt11(payment: &breez_sdk_spark::Payment) -> Option { + match payment.details.as_ref()? { + PaymentDetails::Lightning { invoice, .. } => Some(invoice.clone()), + _ => None, + } +} + +async fn handle_get_info( + id: u64, + params: coincube_spark_protocol::GetInfoParams, + state: Arc, +) -> Response { + let sdk = match state.sdk.read().await.clone() { + Some(s) => s, + None => { + return Response::err( + id, + ErrorKind::NotConnected, + "init must succeed before get_info", + ); + } + }; + + match sdk + .sdk + .get_info(GetInfoRequest { + ensure_synced: params.ensure_synced, + }) + .await + { + Ok(info) => Response::ok( + id, + OkPayload::GetInfo(GetInfoOk { + balance_sats: info.balance_sats, + identity_pubkey: info.identity_pubkey, + }), + ), + Err(e) => Response::err(id, ErrorKind::Sdk, format!("get_info failed: {e}")), + } +} + +async fn handle_list_payments( + id: u64, + params: coincube_spark_protocol::ListPaymentsParams, + state: Arc, +) -> Response { + let sdk = match state.sdk.read().await.clone() { + Some(s) => s, + None => { + return Response::err( + id, + ErrorKind::NotConnected, + "init must succeed before list_payments", + ); + } + }; + + match sdk + .sdk + .list_payments(ListPaymentsRequest { + limit: params.limit, + offset: params.offset, + sort_ascending: Some(false), + type_filter: None, + status_filter: None, + asset_filter: None, + payment_details_filter: None, + from_timestamp: None, + to_timestamp: None, + }) + .await + { + Ok(resp) => { + let payments = resp + .payments + .into_iter() + .map(payment_to_summary) + .collect::>(); + Response::ok(id, OkPayload::ListPayments(ListPaymentsOk { payments })) + } + Err(e) => Response::err(id, ErrorKind::Sdk, format!("list_payments failed: {e}")), + } +} + +/// Collapse a Spark SDK `Payment` into the compact [`PaymentSummary`] the +/// Phase 2 protocol carries. We intentionally stringify the status / +/// direction so the protocol crate doesn't need to mirror Spark's enums +/// yet — later phases can replace these with typed variants as the UI +/// starts branching on them. +fn payment_to_summary(p: breez_sdk_spark::Payment) -> PaymentSummary { + PaymentSummary { + id: p.id, + amount_sat: p.amount as i64, + timestamp: p.timestamp, + status: format!("{:?}", p.status), + direction: format!("{:?}", p.payment_type), + } +} + +// --------------------------------------------------------------------------- +// Phase 4c write-path handlers +// --------------------------------------------------------------------------- + +async fn handle_prepare_send( + id: u64, + params: coincube_spark_protocol::PrepareSendParams, + state: Arc, +) -> Response { + let sdk = match state.sdk.read().await.clone() { + Some(s) => s, + None => { + return Response::err( + id, + ErrorKind::NotConnected, + "init must succeed before prepare_send", + ); + } + }; + + let request = PrepareSendPaymentRequest { + payment_request: params.input, + amount: params.amount_sat.map(|a| a as u128), + token_identifier: None, + conversion_options: None, + fee_policy: None, + }; + + match sdk.sdk.prepare_send_payment(request).await { + Ok(prepare) => { + // Extract display-friendly fields before stashing the full + // struct. `amount` + method-specific fees are u128 in the + // SDK (Spark tokens can exceed sat precision); we saturate + // to u64 for display. Bitcoin-side sends are well within + // u64::MAX. + let amount_sat = clamp_u128_to_u64(prepare.amount); + let (fee_sat, method_tag) = match &prepare.payment_method { + SendPaymentMethod::BitcoinAddress { fee_quote, .. } => { + // Default to the medium-speed quote for display — + // the gui can surface all three tiers in Phase 4d. + let fee = fee_quote.speed_medium.user_fee_sat + + fee_quote.speed_medium.l1_broadcast_fee_sat; + (fee, "BitcoinAddress") + } + SendPaymentMethod::Bolt11Invoice { + spark_transfer_fee_sats, + lightning_fee_sats, + .. + } => ( + spark_transfer_fee_sats.unwrap_or(0) + lightning_fee_sats, + "Bolt11Invoice", + ), + SendPaymentMethod::SparkAddress { fee, .. } => { + (clamp_u128_to_u64(*fee), "SparkAddress") + } + SendPaymentMethod::SparkInvoice { fee, .. } => { + (clamp_u128_to_u64(*fee), "SparkInvoice") + } + }; + + let handle = Uuid::new_v4().to_string(); + state + .pending_prepares + .lock() + .await + .insert(handle.clone(), (Instant::now(), prepare)); + + Response::ok( + id, + OkPayload::PrepareSend(PrepareSendOk { + handle, + amount_sat, + fee_sat, + method: method_tag.to_string(), + }), + ) + } + Err(e) => Response::err(id, ErrorKind::Sdk, format!("prepare_send failed: {e}")), + } +} + +async fn handle_send_payment( + id: u64, + params: coincube_spark_protocol::SendPaymentParams, + state: Arc, +) -> Response { + let sdk = match state.sdk.read().await.clone() { + Some(s) => s, + None => { + return Response::err( + id, + ErrorKind::NotConnected, + "init must succeed before send_payment", + ); + } + }; + + // Phase 4e: the same `SendPayment` RPC handles both regular sends + // and LNURL-pay sends. We look up the handle in `pending_prepares` + // first; if it's not there, fall through to `pending_lnurl_prepares` + // and dispatch to `sdk.lnurl_pay` instead of `sdk.send_payment`. + let handle = params.prepare_handle; + + if let Some((_inserted_at, prepare)) = + state.pending_prepares.lock().await.remove(&handle) + { + return execute_regular_send(id, sdk, prepare).await; + } + + if let Some((_inserted_at, prepare)) = + state.pending_lnurl_prepares.lock().await.remove(&handle) + { + return execute_lnurl_send(id, sdk, prepare).await; + } + + Response::err( + id, + ErrorKind::BadRequest, + format!( + "no pending prepare for handle {} (consumed, expired, or never existed)", + handle + ), + ) +} + +async fn execute_regular_send( + id: u64, + sdk: SdkHandle, + prepare: PrepareSendPaymentResponse, +) -> Response { + // Snapshot for the response so we can surface the final amount/fee + // even after the SDK consumes the prepare response. + let amount_sat = clamp_u128_to_u64(prepare.amount); + let fee_sat = match &prepare.payment_method { + SendPaymentMethod::BitcoinAddress { fee_quote, .. } => { + fee_quote.speed_medium.user_fee_sat + fee_quote.speed_medium.l1_broadcast_fee_sat + } + SendPaymentMethod::Bolt11Invoice { + spark_transfer_fee_sats, + lightning_fee_sats, + .. + } => spark_transfer_fee_sats.unwrap_or(0) + lightning_fee_sats, + SendPaymentMethod::SparkAddress { fee, .. } => clamp_u128_to_u64(*fee), + SendPaymentMethod::SparkInvoice { fee, .. } => clamp_u128_to_u64(*fee), + }; + + // Phase 4c ships the default send options (Medium speed for + // on-chain, Spark-preferred routing for Bolt11 without a completion + // timeout). User-configurable options (fee tier picker) land in + // Phase 4f when the UI has the real controls to expose them. + let request = SendPaymentRequest { + prepare_response: prepare, + options: None, + idempotency_key: None, + }; + + match sdk.sdk.send_payment(request).await { + Ok(resp) => Response::ok( + id, + OkPayload::SendPayment(SendPaymentOk { + payment_id: resp.payment.id, + amount_sat, + fee_sat, + }), + ), + Err(e) => Response::err(id, ErrorKind::Sdk, format!("send_payment failed: {e}")), + } +} + +async fn execute_lnurl_send( + id: u64, + sdk: SdkHandle, + prepare: PrepareLnurlPayResponse, +) -> Response { + // The LNURL prepare response carries its own top-level + // `amount_sats` / `fee_sats` fields (u64, already in sats — no + // u128 clamping needed here). Snapshot them for the send response. + let amount_sat = prepare.amount_sats; + let fee_sat = prepare.fee_sats; + + let request = LnurlPayRequest { + prepare_response: prepare, + idempotency_key: None, + }; + + match sdk.sdk.lnurl_pay(request).await { + Ok(resp) => Response::ok( + id, + OkPayload::SendPayment(SendPaymentOk { + payment_id: resp.payment.id, + amount_sat, + fee_sat, + }), + ), + Err(e) => Response::err(id, ErrorKind::Sdk, format!("lnurl_pay failed: {e}")), + } +} + +async fn handle_receive_bolt11( + id: u64, + params: coincube_spark_protocol::ReceiveBolt11Params, + state: Arc, +) -> Response { + let sdk = match state.sdk.read().await.clone() { + Some(s) => s, + None => { + return Response::err( + id, + ErrorKind::NotConnected, + "init must succeed before receive_bolt11", + ); + } + }; + + let request = ReceivePaymentRequest { + payment_method: ReceivePaymentMethod::Bolt11Invoice { + description: params.description, + amount_sats: params.amount_sat, + expiry_secs: params.expiry_secs, + payment_hash: None, + }, + }; + + match sdk.sdk.receive_payment(request).await { + Ok(resp) => Response::ok( + id, + OkPayload::ReceivePayment(ReceivePaymentOk { + payment_request: resp.payment_request, + fee_sat: clamp_u128_to_u64(resp.fee), + }), + ), + Err(e) => Response::err(id, ErrorKind::Sdk, format!("receive_bolt11 failed: {e}")), + } +} + +async fn handle_receive_onchain( + id: u64, + params: coincube_spark_protocol::ReceiveOnchainParams, + state: Arc, +) -> Response { + let sdk = match state.sdk.read().await.clone() { + Some(s) => s, + None => { + return Response::err( + id, + ErrorKind::NotConnected, + "init must succeed before receive_onchain", + ); + } + }; + + let request = ReceivePaymentRequest { + payment_method: ReceivePaymentMethod::BitcoinAddress { + new_address: params.new_address, + }, + }; + + match sdk.sdk.receive_payment(request).await { + Ok(resp) => Response::ok( + id, + OkPayload::ReceivePayment(ReceivePaymentOk { + payment_request: resp.payment_request, + fee_sat: clamp_u128_to_u64(resp.fee), + }), + ), + Err(e) => Response::err(id, ErrorKind::Sdk, format!("receive_onchain failed: {e}")), + } +} + +// --------------------------------------------------------------------------- +// Phase 4e: LNURL-pay support +// --------------------------------------------------------------------------- + +async fn handle_parse_input( + id: u64, + params: coincube_spark_protocol::ParseInputParams, + state: Arc, +) -> Response { + let sdk = match state.sdk.read().await.clone() { + Some(s) => s, + None => { + return Response::err( + id, + ErrorKind::NotConnected, + "init must succeed before parse_input", + ); + } + }; + + match sdk.sdk.parse(¶ms.input).await { + Ok(input_type) => Response::ok(id, OkPayload::ParseInput(input_type_to_ok(input_type))), + Err(e) => Response::err(id, ErrorKind::Sdk, format!("parse_input failed: {e}")), + } +} + +/// Translate a [`breez_sdk_spark::InputType`] into the protocol's +/// [`ParseInputOk`] shape. Only the fields the gui actually branches +/// on are surfaced — everything else stays inside the SDK type tree +/// and the bridge re-parses on `prepare_lnurl_pay` / `prepare_send`. +fn input_type_to_ok(input: InputType) -> ParseInputOk { + // Sats-from-millisats helper — BOLT11 invoices carry + // `amount_msat`, LNURL declares min/max in msats, etc. + fn msat_to_sat(msat: u64) -> u64 { + msat / 1000 + } + + match input { + InputType::Bolt11Invoice(details) => ParseInputOk { + kind: ParseInputKind::Bolt11Invoice, + amount_sat: details.amount_msat.map(msat_to_sat), + lnurl_min_sendable_sat: None, + lnurl_max_sendable_sat: None, + lnurl_comment_allowed: 0, + lnurl_address: None, + }, + InputType::BitcoinAddress(_details) => ParseInputOk { + // Plain on-chain addresses don't carry an amount — only + // BIP21 URIs do. The user must supply one in the Send + // panel's amount field for the prepare to succeed. + kind: ParseInputKind::BitcoinAddress, + amount_sat: None, + lnurl_min_sendable_sat: None, + lnurl_max_sendable_sat: None, + lnurl_comment_allowed: 0, + lnurl_address: None, + }, + InputType::Bip21(details) => ParseInputOk { + kind: ParseInputKind::BitcoinAddress, + amount_sat: details.amount_sat, + lnurl_min_sendable_sat: None, + lnurl_max_sendable_sat: None, + lnurl_comment_allowed: 0, + lnurl_address: None, + }, + InputType::LnurlPay(pay) => ParseInputOk { + kind: ParseInputKind::LnurlPay, + amount_sat: None, + lnurl_min_sendable_sat: Some(msat_to_sat(pay.min_sendable)), + lnurl_max_sendable_sat: Some(msat_to_sat(pay.max_sendable)), + lnurl_comment_allowed: pay.comment_allowed, + lnurl_address: pay.address, + }, + InputType::LightningAddress(addr) => ParseInputOk { + kind: ParseInputKind::LightningAddress, + amount_sat: None, + lnurl_min_sendable_sat: Some(msat_to_sat(addr.pay_request.min_sendable)), + lnurl_max_sendable_sat: Some(msat_to_sat(addr.pay_request.max_sendable)), + lnurl_comment_allowed: addr.pay_request.comment_allowed, + lnurl_address: Some(addr.address), + }, + // Everything else — BOLT12 invoices/offers, LNURL-auth, + // LNURL-withdraw, silent payment, Spark-native types, bare + // URLs — falls through to `Other`. The gui shows a "not + // supported yet" error; future phases can break each one out + // as demand appears. + _ => ParseInputOk { + kind: ParseInputKind::Other, + amount_sat: None, + lnurl_min_sendable_sat: None, + lnurl_max_sendable_sat: None, + lnurl_comment_allowed: 0, + lnurl_address: None, + }, + } +} + +async fn handle_prepare_lnurl_pay( + id: u64, + params: coincube_spark_protocol::PrepareLnurlPayParams, + state: Arc, +) -> Response { + let sdk = match state.sdk.read().await.clone() { + Some(s) => s, + None => { + return Response::err( + id, + ErrorKind::NotConnected, + "init must succeed before prepare_lnurl_pay", + ); + } + }; + + // Re-parse the input to recover the SDK's `LnurlPayRequestDetails`. + // We could stash the parse result from the earlier `parse_input` + // call and pass it back, but that would tie the protocol to the + // SDK's internal types. Re-parsing is cheap — it's a local + // regex/bech32 decode on a string we already know to be valid. + let pay_request = match sdk.sdk.parse(¶ms.input).await { + Ok(InputType::LnurlPay(details)) => details, + Ok(InputType::LightningAddress(addr)) => addr.pay_request, + Ok(other) => { + return Response::err( + id, + ErrorKind::BadRequest, + format!( + "prepare_lnurl_pay called with non-LNURL input (parsed as {:?})", + std::mem::discriminant(&other) + ), + ); + } + Err(e) => { + return Response::err(id, ErrorKind::Sdk, format!("parse_input failed: {e}")); + } + }; + + let request = PrepareLnurlPayRequest { + amount: params.amount_sat as u128, + pay_request, + comment: params.comment, + validate_success_action_url: None, + token_identifier: None, + conversion_options: None, + fee_policy: None, + }; + + match sdk.sdk.prepare_lnurl_pay(request).await { + Ok(prepare) => { + // Preview fields come straight out of the SDK's + // `PrepareLnurlPayResponse` — it already exposes top-level + // `amount_sats` and `fee_sats` in u64, so no u128 clamping + // is needed on this path. + let amount_sat = prepare.amount_sats; + let fee_sat = prepare.fee_sats; + let method = "LnurlPay".to_string(); + + let handle = Uuid::new_v4().to_string(); + state + .pending_lnurl_prepares + .lock() + .await + .insert(handle.clone(), (Instant::now(), prepare)); + + Response::ok( + id, + OkPayload::PrepareSend(PrepareSendOk { + handle, + amount_sat, + fee_sat, + method, + }), + ) + } + Err(e) => Response::err( + id, + ErrorKind::Sdk, + format!("prepare_lnurl_pay failed: {e}"), + ), + } +} + +// --------------------------------------------------------------------------- +// Phase 4f: on-chain claim lifecycle +// --------------------------------------------------------------------------- + +async fn handle_list_unclaimed_deposits(id: u64, state: Arc) -> Response { + let sdk = match state.sdk.read().await.clone() { + Some(s) => s, + None => { + return Response::err( + id, + ErrorKind::NotConnected, + "init must succeed before list_unclaimed_deposits", + ); + } + }; + + match sdk + .sdk + .list_unclaimed_deposits(ListUnclaimedDepositsRequest {}) + .await + { + Ok(resp) => { + let deposits: Vec = resp + .deposits + .into_iter() + .map(|d| DepositInfo { + txid: d.txid, + vout: d.vout, + amount_sat: d.amount_sats, + is_mature: d.is_mature, + // Stringify the SDK's `DepositClaimError` enum + // for display. Phase 4g+ can promote to a typed + // protocol enum if the gui needs to branch on + // specific error reasons. + claim_error: d.claim_error.map(|e| format!("{:?}", e)), + }) + .collect(); + Response::ok( + id, + OkPayload::ListUnclaimedDeposits(ListUnclaimedDepositsOk { deposits }), + ) + } + Err(e) => Response::err( + id, + ErrorKind::Sdk, + format!("list_unclaimed_deposits failed: {e}"), + ), + } +} + +async fn handle_claim_deposit( + id: u64, + params: coincube_spark_protocol::ClaimDepositParams, + state: Arc, +) -> Response { + let sdk = match state.sdk.read().await.clone() { + Some(s) => s, + None => { + return Response::err( + id, + ErrorKind::NotConnected, + "init must succeed before claim_deposit", + ); + } + }; + + let request = ClaimDepositRequest { + txid: params.txid, + vout: params.vout, + // Phase 4f uses the SDK default fee policy (None → SDK picks + // a network-recommended rate). A user-facing fee tier picker + // for claims is a Phase 4g+ polish item. + max_fee: None, + }; + + match sdk.sdk.claim_deposit(request).await { + Ok(resp) => { + // The SDK's claim returns a Payment whose `amount` reflects + // the post-fee deposited value. Surface that to the gui so + // the success toast can show the actual claimed amount. + let amount_sat = clamp_u128_to_u64(resp.payment.amount); + Response::ok( + id, + OkPayload::ClaimDeposit(ClaimDepositOk { + payment_id: resp.payment.id, + amount_sat, + }), + ) + } + Err(e) => Response::err(id, ErrorKind::Sdk, format!("claim_deposit failed: {e}")), + } +} + +async fn handle_get_user_settings(id: u64, state: Arc) -> Response { + let sdk = match state.sdk.read().await.clone() { + Some(s) => s, + None => { + return Response::err( + id, + ErrorKind::NotConnected, + "init must succeed before get_user_settings", + ); + } + }; + + match sdk.sdk.get_user_settings().await { + Ok(settings) => Response::ok( + id, + OkPayload::GetUserSettings(GetUserSettingsOk { + // An active label of `Some(_)` means Stable Balance is + // currently on. We don't surface the label itself — + // the gui only cares about the boolean. + stable_balance_active: settings.stable_balance_active_label.is_some(), + private_mode_enabled: settings.spark_private_mode_enabled, + }), + ), + Err(e) => Response::err(id, ErrorKind::Sdk, format!("get_user_settings failed: {e}")), + } +} + +async fn handle_set_stable_balance( + id: u64, + params: SetStableBalanceParams, + state: Arc, +) -> Response { + let sdk = match state.sdk.read().await.clone() { + Some(s) => s, + None => { + return Response::err( + id, + ErrorKind::NotConnected, + "init must succeed before set_stable_balance", + ); + } + }; + + let active_label = if params.enabled { + StableBalanceActiveLabel::Set { + label: crate::sdk_adapter::STABLE_BALANCE_LABEL.to_string(), + } + } else { + StableBalanceActiveLabel::Unset + }; + + let request = UpdateUserSettingsRequest { + spark_private_mode_enabled: None, + stable_balance_active_label: Some(active_label), + }; + + match sdk.sdk.update_user_settings(request).await { + Ok(()) => Response::ok(id, OkPayload::SetStableBalance {}), + Err(e) => Response::err( + id, + ErrorKind::Sdk, + format!("update_user_settings failed: {e}"), + ), + } +} + +/// Saturating cast. Spark token amounts are u128 in the SDK (room for +/// arbitrary precision tokens); sat-denominated amounts fit well within +/// u64 in practice but we clamp defensively so an overflow doesn't +/// panic the bridge mid-request. +fn clamp_u128_to_u64(v: u128) -> u64 { + if v > u64::MAX as u128 { + u64::MAX + } else { + v as u64 + } +} + +/// Phase 4f: walk both pending-prepare maps and drop entries whose +/// insertion timestamp is older than [`PREPARE_TTL`]. Called from the +/// background sweep task in `run()`. +/// +/// Logged at debug level when entries are evicted so manual smoke +/// testing can observe the eviction without noise on a quiet bridge. +async fn sweep_expired_prepares(state: &Arc) { + let now = Instant::now(); + let mut evicted_regular = 0usize; + let mut evicted_lnurl = 0usize; + + { + let mut guard = state.pending_prepares.lock().await; + guard.retain(|_handle, (inserted_at, _prepare)| { + let keep = now.duration_since(*inserted_at) < PREPARE_TTL; + if !keep { + evicted_regular += 1; + } + keep + }); + } + { + let mut guard = state.pending_lnurl_prepares.lock().await; + guard.retain(|_handle, (inserted_at, _prepare)| { + let keep = now.duration_since(*inserted_at) < PREPARE_TTL; + if !keep { + evicted_lnurl += 1; + } + keep + }); + } + + if evicted_regular > 0 || evicted_lnurl > 0 { + tracing::debug!( + "evicted {} expired prepare(s), {} expired lnurl prepare(s)", + evicted_regular, + evicted_lnurl + ); + } +} diff --git a/coincube-spark-protocol/Cargo.toml b/coincube-spark-protocol/Cargo.toml new file mode 100644 index 000000000..ec252af7d --- /dev/null +++ b/coincube-spark-protocol/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "coincube-spark-protocol" +version = "0.1.0" +edition = "2021" +license-file = "../LICENCE" +description = "JSON-RPC protocol types shared between coincube-gui and coincube-spark-bridge" + +[lints.rust] +mismatched_lifetime_syntaxes = "allow" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/coincube-spark-protocol/src/lib.rs b/coincube-spark-protocol/src/lib.rs new file mode 100644 index 000000000..9147aa6f9 --- /dev/null +++ b/coincube-spark-protocol/src/lib.rs @@ -0,0 +1,568 @@ +//! JSON-RPC-ish protocol spoken between `coincube-gui` and `coincube-spark-bridge`. +//! +//! Both sides link this crate; the bridge binary depends on +//! `breez-sdk-spark`, `coincube-gui` does not. The separation exists because +//! the Liquid SDK's dependency graph (`rusqlite`/`libsqlite3-sys`, +//! `tokio_with_wasm`, `reqwest`) is incompatible with the Spark SDK's graph +//! at the `links = "sqlite3"` level, so the two SDKs can't live in the same +//! binary. Running Spark in a sibling process isolates them permanently. +//! +//! The wire format is one JSON object per line (newline-delimited JSON): +//! +//! - [`Request`] messages flow gui → bridge. +//! - [`Response`] messages flow bridge → gui, correlated by `id`. +//! - [`Event`] messages flow bridge → gui unsolicited (SDK event stream). +//! +//! Framing choice: line-delimited JSON is cheap, easy to debug by hand, and +//! works over anonymous stdio pipes without needing length prefixes or +//! framing protocols. The envelope enums are tagged with `serde(tag = ...)` +//! so `{"kind": "get_info", ...}` round-trips through `serde_json::from_str`. + +use serde::{Deserialize, Serialize}; + +/// A request from the gui to the bridge. `id` is echoed back in the matching +/// [`Response`] so the client can correlate concurrent in-flight calls. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Request { + pub id: u64, + #[serde(flatten)] + pub method: Method, +} + +/// The RPC methods the bridge understands. +/// +/// Phase 2 shipped Init / GetInfo / ListPayments / Shutdown. Phase 4c +/// added the Send + Receive write path (`prepare_send_payment`, +/// `send_payment`, `receive_payment`). Phase 4e adds: +/// - [`Method::ParseInput`] — generic input classifier so the gui can +/// route BOLT11/on-chain inputs to [`Method::PrepareSend`] and +/// LNURL/Lightning-address inputs to [`Method::PrepareLnurlPay`]. +/// - [`Method::PrepareLnurlPay`] — the LNURL analog of `PrepareSend`. +/// Internally the bridge parses, fetches the invoice from the LNURL +/// callback, and wraps the resulting BOLT11 prepare response. The +/// returned handle is routed by [`Method::SendPayment`] — same RPC +/// name as the regular send, handled transparently based on which +/// pending map the handle lives in. +/// +/// On-chain claim lifecycle, fee tier picker, and pending-prepare TTL +/// cleanup remain deferred to Phase 4f. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", content = "params", rename_all = "snake_case")] +pub enum Method { + /// Connect to Spark mainnet with the given credentials and storage + /// directory. Must be the first request — all others return + /// [`ErrorKind::NotConnected`] until init succeeds. + Init(InitParams), + /// Fetch wallet info (balance, pubkey). + GetInfo(GetInfoParams), + /// Fetch recent payments with optional limit/offset pagination. + ListPayments(ListPaymentsParams), + /// Classify an arbitrary destination string — BOLT11, BIP21, + /// on-chain address, LNURL, Lightning Address, etc. Used by the + /// Send panel to decide between [`Method::PrepareSend`] (regular + /// SDK prepare) and [`Method::PrepareLnurlPay`] (LNURL code path). + ParseInput(ParseInputParams), + /// Parse a BOLT11 invoice / BIP21 URI / on-chain address and return + /// a [`PrepareSendOk`] preview + opaque `handle`. The bridge stores + /// the full SDK prepare response keyed by that handle so the gui + /// can echo it back in [`Method::SendPayment`] without re-sending + /// the whole complex prepare struct over JSON-RPC. + PrepareSend(PrepareSendParams), + /// Prepare an LNURL-pay / Lightning-address send. The bridge + /// internally parses the input, fetches the invoice from the LNURL + /// callback, and wraps the resulting SDK `PrepareLnurlPayResponse` + /// in an opaque handle keyed into a separate pending map. The + /// returned [`PrepareSendOk`] is shape-compatible with regular + /// prepares so the gui's state machine stays uniform — the only + /// difference is the `method` string, which comes back as + /// `"LnurlPay"` / `"LightningAddress"`. + PrepareLnurlPay(PrepareLnurlPayParams), + /// Execute a previously-prepared send, identified by the opaque + /// handle returned by either [`Method::PrepareSend`] or + /// [`Method::PrepareLnurlPay`]. The bridge routes the handle to + /// `sdk.send_payment` or `sdk.lnurl_pay` based on which pending + /// map contains it. Handles that have already been consumed or + /// have expired return [`ErrorKind::BadRequest`]. + SendPayment(SendPaymentParams), + /// Generate a BOLT11 Lightning invoice to receive funds. + ReceiveBolt11(ReceiveBolt11Params), + /// Generate an on-chain Bitcoin deposit address. Spark's on-chain + /// receive model requires a separate `claim_deposit` call once the + /// incoming tx has confirmed — that's the Phase 4f + /// [`Method::ListUnclaimedDeposits`] / [`Method::ClaimDeposit`] flow + /// below. The `receive_onchain` RPC just generates the address; + /// the gui watches for incoming deposits via + /// [`Event::DepositsChanged`] and lets the user claim mature ones. + ReceiveOnchain(ReceiveOnchainParams), + /// Phase 4f: enumerate on-chain deposits the SDK has noticed but + /// not yet claimed. Returns a [`Vec`] the gui renders + /// as a "pending deposits" card in the Receive panel. + ListUnclaimedDeposits, + /// Phase 4f: claim a specific deposit (txid + vout) into the + /// Spark wallet. The SDK's `claim_deposit` succeeds only if the + /// deposit is `is_mature == true`; immature claims return + /// [`ErrorKind::Sdk`] with the SDK's error string. + ClaimDeposit(ClaimDepositParams), + /// Phase 6: fetch the runtime user settings (Stable Balance + /// state, private mode). Boolean-only view of the SDK's + /// `UserSettings` struct; the panel exposes Stable Balance to + /// the user as an on/off toggle without mentioning USDB. + GetUserSettings, + /// Phase 6: enable or disable Stable Balance. The bridge + /// translates this into the SDK's + /// `UpdateUserSettingsRequest { stable_balance_active_label: + /// Some(Set { label: "USDB" }) | Some(Unset) }` call. The + /// `"USDB"` label is the integrator-defined handle configured + /// at init time in [`coincube-spark-bridge`]; it's internal + /// plumbing and never surfaces in the gui. + SetStableBalance(SetStableBalanceParams), + /// Gracefully disconnect and exit the bridge. + Shutdown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitParams { + pub api_key: String, + pub network: Network, + /// BIP-39 mnemonic (space-separated words). + pub mnemonic: String, + pub mnemonic_passphrase: Option, + pub storage_dir: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Network { + Mainnet, + Regtest, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GetInfoParams { + #[serde(default)] + pub ensure_synced: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ListPaymentsParams { + #[serde(default)] + pub limit: Option, + #[serde(default)] + pub offset: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrepareSendParams { + /// User-supplied destination — a BOLT11 invoice, BIP21 URI, or + /// on-chain Bitcoin address. LNURL / Lightning Address destinations + /// are NOT supported in Phase 4c — they go through a different SDK + /// code path (`prepare_lnurl_pay`) that lands in Phase 4d. + pub input: String, + /// Amount override in sats. Required for amountless BOLT11 invoices + /// and for on-chain sends; ignored otherwise. The SDK's underlying + /// field is `u128` (tokens can have larger precision than sats); + /// the bridge up-casts and validates. + #[serde(default)] + pub amount_sat: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SendPaymentParams { + /// Opaque handle returned from a prior [`Method::PrepareSend`]. The + /// bridge looks this up in its pending-prepare map to recover the + /// full `breez_sdk_spark::PrepareSendPaymentResponse`. Single-use — + /// a successful or failed send consumes the entry. + pub prepare_handle: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ReceiveBolt11Params { + /// Amount in sats. `None` generates an amountless invoice. + #[serde(default)] + pub amount_sat: Option, + /// Invoice description shown to the payer. + #[serde(default)] + pub description: String, + /// Invoice expiry in seconds. `None` uses the SDK default. + #[serde(default)] + pub expiry_secs: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ReceiveOnchainParams { + /// If `true`, force the bridge to return a fresh address instead + /// of a cached one. `None` defers to the SDK default (reuse last). + #[serde(default)] + pub new_address: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParseInputParams { + /// The raw destination string to classify. + pub input: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaimDepositParams { + /// Deposit transaction id to claim. + pub txid: String, + /// Output index of the deposit within `txid`. + pub vout: u32, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct SetStableBalanceParams { + /// `true` to activate Stable Balance, `false` to deactivate. + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrepareLnurlPayParams { + /// The LNURL-pay or Lightning-address destination. The bridge + /// re-parses this internally via `BreezSdk::parse` to recover the + /// `LnurlPayRequestDetails` — we don't serialize the full details + /// struct over JSON-RPC, the bridge just re-does the work (cheap, + /// and keeps the protocol surface tiny). + pub input: String, + /// Amount in sats. LNURL-pay always requires an explicit amount + /// between the server's `min_sendable` and `max_sendable`. The + /// gui validates against the range it got from an earlier + /// [`Method::ParseInput`] call. + pub amount_sat: u64, + /// Optional comment to attach to the payment. Only surfaces to + /// the payee if the LNURL server declared `comment_allowed > 0` + /// in its metadata. + #[serde(default)] + pub comment: Option, +} + +/// A response envelope. Exactly one of `ok` / `err` is populated. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + pub id: u64, + #[serde(flatten)] + pub result: ResponseResult, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponseResult { + Ok(OkPayload), + Err(ErrorPayload), +} + +/// The success payload shape for each method. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", content = "data", rename_all = "snake_case")] +pub enum OkPayload { + Init {}, + GetInfo(GetInfoOk), + ListPayments(ListPaymentsOk), + ParseInput(ParseInputOk), + /// Shared between [`Method::PrepareSend`] and + /// [`Method::PrepareLnurlPay`] — both bridge paths return + /// shape-compatible previews so the gui state machine can treat + /// them uniformly. + PrepareSend(PrepareSendOk), + SendPayment(SendPaymentOk), + ReceivePayment(ReceivePaymentOk), + ListUnclaimedDeposits(ListUnclaimedDepositsOk), + ClaimDeposit(ClaimDepositOk), + GetUserSettings(GetUserSettingsOk), + SetStableBalance {}, + Shutdown {}, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetInfoOk { + pub balance_sats: u64, + pub identity_pubkey: String, +} + +/// Compact payment summary used by Phase 2. The full Spark `Payment` shape is +/// not mirrored here yet — only the fields we need to prove end-to-end +/// connectivity and render a minimum list. Later phases add the richer +/// domain mapping when the UI actually consumes the data. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListPaymentsOk { + pub payments: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PaymentSummary { + /// Payment id / tx id as reported by the Spark SDK. + pub id: String, + /// Amount in satoshis (direction-signed). + pub amount_sat: i64, + /// Unix timestamp in seconds. + pub timestamp: u64, + pub status: String, + pub direction: String, +} + +/// High-level classification of a user-supplied destination string, +/// returned by [`Method::ParseInput`]. The Send panel branches on +/// [`ParseInputKind`] to decide whether to route to +/// [`Method::PrepareSend`] (regular SDK prepare) or +/// [`Method::PrepareLnurlPay`] (LNURL code path). +/// +/// The SDK's `InputType` enum is much richer than this; Phase 4e only +/// surfaces the fields the gui actually branches on, which keeps the +/// protocol crate decoupled from the SDK's extensive type tree. Later +/// phases can extend the payload as panels start needing more detail. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParseInputKind { + /// A BOLT11 Lightning invoice. Use [`Method::PrepareSend`]. + Bolt11Invoice, + /// A plain Bitcoin on-chain address or BIP21 URI. Use + /// [`Method::PrepareSend`]. + BitcoinAddress, + /// A raw LNURL-pay string (typically `lnurl1...`). Use + /// [`Method::PrepareLnurlPay`]. + LnurlPay, + /// A Lightning address in the form `user@domain`. Resolves to an + /// LNURL-pay flow internally. Use [`Method::PrepareLnurlPay`]. + LightningAddress, + /// Anything else the SDK could parse (BOLT12, Bolt12 offer, + /// silent payment, Spark-native types, etc.). Phase 4e falls + /// through to [`Method::PrepareSend`] which handles the ones the + /// SDK supports; the gui shows a "not supported" error for the + /// rest. + Other, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParseInputOk { + /// Which high-level category the input parsed into. + pub kind: ParseInputKind, + /// Pre-specified amount if the input carries one (BOLT11 with + /// amount, BIP21 with amount). `None` for amountless invoices, + /// plain on-chain addresses, and LNURL/Lightning-address inputs + /// (which use `lnurl_min_sendable_sat` / `lnurl_max_sendable_sat` + /// as a range instead). + #[serde(default)] + pub amount_sat: Option, + /// For LNURL / Lightning Address inputs: the server's minimum + /// acceptable payment in sats. `None` for non-LNURL inputs. + #[serde(default)] + pub lnurl_min_sendable_sat: Option, + /// For LNURL / Lightning Address inputs: the server's maximum + /// acceptable payment in sats. `None` for non-LNURL inputs. + #[serde(default)] + pub lnurl_max_sendable_sat: Option, + /// For LNURL / Lightning Address inputs: max comment length + /// the server accepts. `0` means the server doesn't accept a + /// comment. Defaults to `0` for non-LNURL inputs. + #[serde(default)] + pub lnurl_comment_allowed: u16, + /// For Lightning Address inputs: the `user@domain` string the + /// user typed. Surfaced for display; the bridge re-parses on + /// prepare so the gui doesn't need to round-trip it. + #[serde(default)] + pub lnurl_address: Option, +} + +/// Preview returned by [`Method::PrepareSend`]. The `handle` is a bridge +/// session token that the gui echoes back on the subsequent +/// [`Method::SendPayment`] call — the bridge stores the full SDK +/// `PrepareSendPaymentResponse` (which is a complex nested struct) +/// internally under that key so it doesn't have to round-trip over +/// JSON-RPC. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrepareSendOk { + /// Opaque handle to pass into `send_payment`. Single-use. + pub handle: String, + /// Display amount in sats. The SDK's underlying field is `u128` + /// (Spark tokens can exceed sat precision); for Bitcoin sends the + /// value fits in u64. The bridge saturates at `u64::MAX` if the + /// amount somehow exceeds that. + pub amount_sat: u64, + /// Estimated fee in sats (also saturating u64). + pub fee_sat: u64, + /// High-level send-method tag for display. Mirrors the variant + /// names of `breez_sdk_spark::SendPaymentMethod` — one of + /// "BitcoinAddress", "Bolt11Invoice", "SparkAddress", + /// "SparkInvoice". Stringified to keep the protocol crate free of + /// SDK type deps. + pub method: String, +} + +/// Result of a successful [`Method::SendPayment`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SendPaymentOk { + /// Payment id from `breez_sdk_spark::Payment::id` — the caller + /// can feed this into a follow-up [`Method::ListPayments`] to + /// display the new row. + pub payment_id: String, + /// Final amount sent (sats). + pub amount_sat: u64, + /// Final fee paid (sats). + pub fee_sat: u64, +} + +/// Result of a successful [`Method::ReceiveBolt11`] or +/// [`Method::ReceiveOnchain`]. For Lightning the `payment_request` is +/// a BOLT11 invoice string; for on-chain it's a BIP21 URI or bare +/// Bitcoin address (whatever the SDK returns — the gui treats it as +/// an opaque string to copy / render as a QR code). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReceivePaymentOk { + pub payment_request: String, + /// Fee the SDK expects the receiver to pay to settle this + /// incoming payment. 0 for most Bitcoin on-chain addresses. + pub fee_sat: u64, +} + +/// One entry in the unclaimed-deposits list returned by +/// [`Method::ListUnclaimedDeposits`]. +/// +/// Mirrors a subset of `breez_sdk_spark::DepositInfo` — the gui only +/// needs the txid/vout (to call [`Method::ClaimDeposit`]), the amount +/// (for display), the maturity flag (to gate the Claim button), and +/// any claim error string (to surface failures from previous attempts). +/// `refund_tx` / `refund_tx_id` are deferred until a Phase 4g+ adds +/// the refund flow. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DepositInfo { + pub txid: String, + pub vout: u32, + pub amount_sat: u64, + /// `true` once the deposit has enough confirmations to be claimed. + /// The gui shows "Pending confirmation" when `false` and a "Claim" + /// button when `true`. + pub is_mature: bool, + /// If the SDK has previously tried to claim this deposit and + /// failed, this carries the SDK's error string. Surfaced in the + /// UI as a per-row warning. + pub claim_error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListUnclaimedDepositsOk { + pub deposits: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaimDepositOk { + /// Payment id of the resulting Spark wallet transfer. + pub payment_id: String, + /// Amount claimed in sats. Mirrors the deposit's `amount_sat` + /// minus any internal fees the SDK deducted. + pub amount_sat: u64, +} + +/// Phase 6: boolean-flattened view of the SDK's `UserSettings`. The +/// gui only ever cares whether Stable Balance is active — it never +/// renders the USDB label. Private mode is surfaced here as a +/// preview field for a future "privacy" toggle but is not yet wired +/// into the UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserSettingsOk { + /// `true` when the SDK reports an active Stable Balance token. + pub stable_balance_active: bool, + /// Mirrors `UserSettings::spark_private_mode_enabled`. + pub private_mode_enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorPayload { + pub kind: ErrorKind, + pub message: String, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorKind { + /// The bridge has not received an `init` yet. + NotConnected, + /// `init` was called after a previous successful init. + AlreadyConnected, + /// The underlying Spark SDK returned an error. + Sdk, + /// The request envelope failed to parse. + BadRequest, + /// The bridge is shutting down and cannot accept new work. + ShuttingDown, +} + +impl Response { + pub fn ok(id: u64, payload: OkPayload) -> Self { + Self { + id, + result: ResponseResult::Ok(payload), + } + } + + pub fn err(id: u64, kind: ErrorKind, message: impl Into) -> Self { + Self { + id, + result: ResponseResult::Err(ErrorPayload { + kind, + message: message.into(), + }), + } + } +} + +/// Unsolicited bridge → gui event. +/// +/// The bridge subscribes to the Spark SDK's `EventListener` stream via +/// `add_event_listener` and translates each `SdkEvent` variant into one +/// of these envelopes before writing it to stdout as a [`Frame::Event`]. +/// The gui's [`crate::Frame`] reader task forwards received events into +/// an in-process broadcast channel; panels subscribe via +/// `SparkBackend::event_subscription()` and react in `update()`. +/// +/// Phase 4d shipped `Synced` + the three `Payment*` variants. Phase 4f +/// adds: +/// - `bolt11: Option` on `PaymentSucceeded` so the Receive +/// panel can correlate against a specific generated invoice instead +/// of advancing on any incoming payment. +/// - [`Event::DepositsChanged`] — fires when the SDK detects new, +/// newly-mature, or claimed on-chain deposits. The gui's Receive +/// panel reloads its `list_unclaimed_deposits` view in response. +/// +/// Optimization and lightning-address-changed events from the SDK +/// remain deferred until a panel needs them. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event", content = "payload", rename_all = "snake_case")] +pub enum Event { + /// SDK completed an internal sync tick. The gui uses this as a + /// cheap "refresh balance" trigger. + Synced, + /// A payment finalized successfully. `amount_sat` is positive for + /// incoming, negative for outgoing (mirrors the [`PaymentSummary`] + /// convention). `bolt11` is the BOLT11 invoice string for + /// Lightning payments — `None` for on-chain / Spark-native / + /// non-Lightning payments. + PaymentSucceeded { + id: String, + amount_sat: i64, + #[serde(default)] + bolt11: Option, + }, + /// A payment entered the pending state (broadcast but not yet + /// confirmed, or lightning htlc in flight). + PaymentPending { id: String, amount_sat: i64 }, + /// A payment failed permanently. + PaymentFailed { id: String, amount_sat: i64 }, + /// One or more on-chain deposits changed state — newly observed, + /// newly mature, or claimed. The gui reloads its + /// [`Method::ListUnclaimedDeposits`] result in response. We + /// collapse the SDK's three event types (`UnclaimedDeposits`, + /// `NewDeposits`, `ClaimedDeposits`) into a single coalesced + /// signal because the gui treats them all as "refresh the list." + DepositsChanged, +} + +/// The top-level message envelope written/read on the wire. We use a single +/// outer discriminator so one `serde_json::from_str` call works for all +/// three directions. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Frame { + Request(Request), + Response(Response), + Event(Event), +} diff --git a/docs/SPARK_WALLET.md b/docs/SPARK_WALLET.md new file mode 100644 index 000000000..9e78c267f --- /dev/null +++ b/docs/SPARK_WALLET.md @@ -0,0 +1,105 @@ +# Spark Wallet + +The Spark wallet is COINCUBE's default backend for everyday Lightning UX. It runs alongside the existing Liquid wallet: Spark handles BOLT11, LNURL-pay, and incoming Lightning Address invoices; Liquid remains the advanced wallet for L-BTC, USDt, and Liquid-specific flows. + +## Networks + +Spark ships **mainnet only**. The Breez Spark SDK (v0.13.1) does not publish a regtest flow, and COINCUBE uses real mainnet sats in small amounts for end-to-end testing. Liquid's regtest flow (`docs/BREEZ_SDK_REGTEST.md`) is untouched and still works. + +## Architecture + +The Spark SDK's dependency graph (`rusqlite`, `tokio_with_wasm`, `frost_secp256k1_tr`, Spark tree primitives) is incompatible with the Breez Liquid SDK's dep graph at the `links = "sqlite3"` level. Instead of forking and patching the two SDKs to coexist in a single binary, COINCUBE runs Spark in a sibling process and talks to it over a minimal JSON-RPC protocol. + +``` +┌──────────────┐ stdin/stdout ┌──────────────────────┐ +│ coincube-gui │ ◄──────── JSON-RPC ──────────► │ coincube-spark-bridge │ +│ (iced UI) │ │ (breez-sdk-spark) │ +└──────────────┘ └──────────────────────┘ + │ │ + │ links: breez-sdk-liquid │ links: spark-sdk + │ │ + ▼ ▼ +┌──────────────┐ ┌──────────────────┐ +│ Liquid SDK │ │ Spark SDK │ +└──────────────┘ └──────────────────┘ +``` + +### Components + +- **`coincube-spark-protocol`** — shared crate defining the wire types (`Method`, `Response`, `Event`, `OkPayload`, error envelope). Both the gui and bridge link this crate. Adding a new RPC means adding a variant here first. +- **`coincube-spark-bridge`** — standalone binary (own Cargo workspace, excluded from the main workspace via `[workspace.exclude]`). Owns the `breez_sdk_spark::BreezSdk` handle, exposes handlers for every protocol method, and forwards `SdkEvent` frames as `Event::*` messages on an async broadcast channel. +- **`coincube-gui/src/app/breez_spark/`** — gui-side subprocess client. `SparkClient` spawns the bridge binary, frames JSON-RPC requests/responses over line-delimited JSON on stdio, and surfaces an iced `Subscription` over the bridge's event stream. +- **`coincube-gui/src/app/wallets/spark.rs`** — `SparkBackend` wrapper that exposes the panel-facing read/write surface (`get_info`, `list_payments`, `prepare_send`, `send_payment`, `receive_bolt11`, `receive_onchain`, `list_unclaimed_deposits`, `claim_deposit`, `get_user_settings`, `set_stable_balance`, …). Panels never talk to `SparkClient` directly. +- **`coincube-gui/src/app/state/spark/`** and **`coincube-gui/src/app/view/spark/`** — panel state machines and renderers for Overview, Send, Receive, Transactions, and Settings. Structure mirrors `state/liquid/` and `view/liquid/` to keep both wallets navigable at a glance. + +### Bridge lifecycle + +- The gui spawns the bridge lazily when the cube has a `spark_wallet_signer_fingerprint` set. If the spawn fails (binary missing, handshake error) the `WalletRegistry` holds `spark = None`, and all Spark panels render an "unavailable" stub. +- The bridge binary is located via the `COINCUBE_SPARK_BRIDGE_PATH` env var, or falls back to `coincube-spark-bridge` sitting alongside the main `coincube` binary in the same directory. +- Shutdown is cooperative: the gui sends a `Shutdown` method on app exit; the bridge drops its SDK handle and closes stdio. + +### Seed handoff (Phase 3 compromise) + +The gui decrypts the `HotSigner` mnemonic on PIN entry and hands it to the bridge as part of the `Init` request. Seed stays in memory for the session lifetime inside the bridge process. The architectural win is that the mnemonic lives in a separate address space from the gui's HotSigner on Unix, but on a single-user desktop a local attacker who can read one process's memory can usually read the other too. + +A cleaner alternative — Breez Spark's `ExternalSigner` trait, which lets the SDK call back into the gui for every signing operation — exists as of spark-sdk 0.13.1. We're not using it yet because 11 of the 20 trait methods are Spark-specific (FROST round-2/aggregate, Shamir secret splitting, Spark tree node derivation) that `HotSigner` can't answer today without pulling `spark_wallet` / `frost_secp256k1_tr` directly into the gui crate — exactly the dep-graph collision the subprocess architecture exists to avoid. Migrating to `ExternalSigner` is tracked as a deferred follow-up; see the integration plan at `/.claude/plans/crystalline-booping-wadler.md` §"Deferred follow-up: external signer migration". + +## Setting up a Spark wallet + +1. **Create a cube** the usual way. +2. **Configure a Spark signer** on that cube. The signer is an independent slot from the Liquid signer in `CubeSettings` (`spark_wallet_signer_fingerprint`, parallel to `liquid_wallet_signer_fingerprint`) — they can point at different HotSigners or the same one. +3. **Set `BREEZ_API_KEY`** in the environment (or `.env`). A single Breez API key currently covers both the Liquid and Spark SDKs. If that assumption breaks at integration time, split into `BREEZ_LIQUID_API_KEY` / `BREEZ_SPARK_API_KEY` in the config layer. +4. **Restart the cube.** The first launch spawns the bridge subprocess, runs `init`, and unlocks the Spark wallet for the session. + +When you open the cube, the sidebar shows the **Spark** submenu above the **Liquid** submenu. Spark hosts Overview, Send, Receive, Transactions, and Settings panels. + +## Features + +### Lightning (BOLT11 + LNURL-pay) + +- **Receive**: amountless or fixed-amount BOLT11 invoices with a user-supplied description. QR code is rendered once per invoice to avoid re-encoding on every frame. +- **Send**: pastes a BOLT11 invoice, BIP21 URI, or Lightning Address into the Send panel. The bridge classifies the input via `parse_input` and routes to either `prepare_send` (BOLT11 / BIP21 / on-chain address) or `prepare_lnurl_pay` (LNURL / Lightning Address). Prepare responses are held in a bridge-side pending map keyed by an opaque UUID handle, so the gui never has to round-trip complex SDK types over JSON; the handle is consumed on `send_payment`. +- **Pending-prepare TTL**: prepared sends expire after 5 minutes if not consumed. A background task in the bridge sweeps both pending maps every 60 seconds to cap memory growth. + +### On-chain (deposit address + claim lifecycle) + +Spark uses a deposit-address model: the user sends BTC to a static address, the bridge notices incoming transactions, and the funds become spendable once the SDK's `claim_deposit` call succeeds. + +- The Receive panel shows a "Pending deposits" card below the main body, listing every unclaimed deposit the SDK is aware of. +- Each row renders one of four actions: **Claim** (mature), **Claiming…** (in-flight), **Waiting for confirmation** (immature), or **Retry** + error hint (previous claim failed). +- The list refreshes automatically on `Event::DepositsChanged` events from the bridge (emitted for `SdkEvent::UnclaimedDeposits`, `ClaimedDeposits`, and `NewDeposits`), so the card appears the moment the SDK observes an incoming deposit without manual refresh. + +### Lightning Address routing (Phase 5) + +`@coincube.io` Lightning Addresses are fulfilled by whichever backend the cube's `default_lightning_backend` setting prefers. New cubes default to Spark; users can flip to Liquid in **Spark → Settings → Default Lightning backend**. + +Because Spark SDK 0.13.1's `ReceivePaymentMethod::Bolt11Invoice` only accepts a plain `description` (not a `description_hash`), the coincube-api sends the raw LNURL metadata preimage in each invoice-request event. Spark mints the invoice with that preimage as its `d` tag — the payer's wallet recomputes `SHA256(description)` and compares it against the `descriptionHash` the callback advertised. They match by construction because the API produces both from the same source. + +NIP-57 zap requests frequently exceed BOLT11's 639-byte description cap. The stream handler detects this and falls back to Liquid, which commits via `description_hash` directly. Falling back is silent — no user-visible error. + +### Stable Balance (Phase 6) + +The Spark SDK's built-in Stable Balance feature is exposed as a single **Stable Balance** toggle in Spark → Settings. Turning it on activates the SDK's automatic conversion of excess sats into a USD-pegged token internally; the user continues to see a single Bitcoin balance that stays stable against fiat. Turning it off unpegs and returns to a pure BTC balance. + +Implementation detail hidden from the UI: the SDK uses the USDB stable token (`btkn1xgrvjwey5ngcagvap2dzzvsy4uk8ua9x69k82dwvt5e7ef9drm9qztux87` on mainnet) under the label `"USDB"`. This label never surfaces in the gui — the panel always calls it "Stable Balance". The Overview panel shows a small "Stable" badge next to the balance line when the feature is active. + +## Troubleshooting + +- **"Spark bridge unavailable" in every Spark panel** — the bridge subprocess either failed to spawn (binary not found at `COINCUBE_SPARK_BRIDGE_PATH` or next to `coincube`) or crashed during handshake. Check stderr from `coincube-spark-bridge` for the root cause. +- **"Stable Balance is not configured" errors from `set_stable_balance`** — the bridge's `mainnet_config` always wires up `stable_balance_config` with the USDB token, so this should never fire in a release build. If it does, the bridge got started without the token constant — rebuild and re-deploy. +- **Incoming Lightning Address payments land on Liquid, not Spark** — either the cube's `default_lightning_backend` is still set to Liquid (check `Spark → Settings`), the bridge is down for this cube (check for an "unavailable" badge), the API hasn't been updated to send the `description` field over SSE yet, or the payer is sending a NIP-57 zap (description exceeds 639 bytes → automatic fallback). +- **Lost Spark balance after restarting the cube** — `storage_dir` may have moved between restarts. The SDK stores its local database there; pointing at a different directory on the next launch looks like an empty wallet until the SDK re-syncs. Ensure the `datadir` passed to `load_spark_client` is stable per cube. + +## Testing + +- **Mainnet-only**: per user direction, Spark development and testing happen on mainnet with small amounts. There is no regtest harness for Spark. +- **Smoke-test script**: `cargo run -p coincube-spark-bridge --bin coincube-spark-bridge-smoketest` (Phase 2 test harness) connects to Spark mainnet, runs `init` + `get_info`, and prints the balance/pubkey. Use it to verify the bridge binary + API key + mnemonic independently of the gui. +- **End-to-end journey**: create a cube → load Spark and Liquid → receive sats via `@coincube.io` (should land on Spark) → send a BOLT11 payment → send L-BTC from Liquid → toggle Stable Balance on and off → verify everything in Transactions. + +## References + +- Breez Spark SDK rustdoc: +- Stable Balance guide: +- External signer guide (deferred follow-up): +- Integration plan: `/.claude/plans/crystalline-booping-wadler.md` +- Wallet abstraction engineering note: [WALLETS.md](./WALLETS.md) diff --git a/docs/WALLETS.md b/docs/WALLETS.md new file mode 100644 index 000000000..0effbf8be --- /dev/null +++ b/docs/WALLETS.md @@ -0,0 +1,121 @@ +# Wallets + +COINCUBE ships three wallets today: a multisig Bitcoin **Vault**, a **Liquid** wallet (`breez-sdk-liquid`), and a **Spark** wallet (`breez-sdk-spark`). This note explains how the three fit together and how to add a fourth. + +## Layout + +``` +coincube-gui/src/app/ +├── wallets/ ← domain types + registry +│ ├── mod.rs pub-use surface +│ ├── types.rs WalletKind, DomainPayment*, DomainRefundableSwap +│ ├── registry.rs WalletRegistry + LightningRoute +│ ├── liquid.rs LiquidBackend (wraps BreezClient) +│ └── spark.rs SparkBackend (wraps SparkClient) +├── breez_liquid/ ← Liquid SDK wrapper (in-process) +│ ├── mod.rs loader (load_breez_client) +│ ├── config.rs BreezConfig::from_env +│ ├── client.rs BreezClient — full Liquid read/write API +│ └── assets.rs L-BTC / USDt descriptors +├── breez_spark/ ← Spark SDK wrapper (subprocess) +│ ├── mod.rs loader (load_spark_client) +│ ├── config.rs SparkConfig::from_env +│ ├── client.rs SparkClient — spawns + owns the bridge subprocess +│ └── assets.rs Spark asset registry (BTC-only today) +├── state/ +│ ├── liquid/ LiquidOverview, LiquidSend, LiquidReceive, … +│ └── spark/ SparkOverview, SparkSend, SparkReceive, … +└── view/ + ├── liquid/ view renderers + └── spark/ view renderers +``` + +Separately: `coincube-spark-bridge/` (sibling binary, own Cargo workspace) and `coincube-spark-protocol/` (shared wire types). + +## The abstraction layer + +`wallets/types.rs` defines SDK-agnostic domain types: + +- **`WalletKind`** — `Spark` (default) or `Liquid`. +- **`DomainPayment`** — the shape the UI consumes. Backends map SDK-native payment types (`breez_sdk_liquid::Payment`, `coincube_spark_protocol::PaymentSummary`, …) into `DomainPayment` at the boundary. +- **`DomainPaymentDetails`** — enum of `Lightning`, `LiquidAsset`, `OnChainBitcoin` (add variants as new payment shapes land). +- **`DomainPaymentStatus`** / **`DomainPaymentDirection`** — composite status + direction normalized across backends. +- **`DomainRefundableSwap`** — Liquid-specific refundable-swap summary (Spark has no equivalent today). + +Panels **never** import `breez_sdk_liquid::*` or `coincube_spark_protocol::*` types for display — they go through the domain layer. The backend crates own the mapping functions (`impl From for DomainPayment`, etc.). + +## WalletRegistry + +`wallets/registry.rs` owns the per-cube wallet backends and exposes routing hooks: + +```rust +pub struct WalletRegistry { + liquid: Arc, + spark: Option>, +} +``` + +- `liquid` is always present — the Liquid SDK is in-process and initialized at cube unlock. +- `spark` is `Some` only when the cube has a `spark_wallet_signer_fingerprint` **and** the bridge subprocess spawned successfully. Panels that need Spark gate their UI on `WalletRegistry::spark().is_some()`. + +The registry is also the single place the app decides which backend handles which payment type. Today it exposes one routing method: + +```rust +pub fn route_lightning_address(&self, preferred: WalletKind) -> LightningRoute +``` + +Returns `LightningRoute::Spark(Arc)` when the cube prefers Spark and the bridge is up, otherwise `LightningRoute::Liquid(Arc)`. The LNURL subscription site consults this on every incoming invoice request; `default_lightning_backend` flips are a one-cube-setting change that takes effect on the next event, no code change needed. + +When future routing decisions arise (BOLT12 → Liquid, cross-chain → SideShift via Liquid, etc.), add them as `route_*` methods on `WalletRegistry` so the policy stays in one file. + +## Two wallet wrapper shapes + +The Liquid and Spark wrapper crates deliberately don't share a `WalletBackend` trait: + +- **Liquid** (`breez_liquid/`) is sync/local — `BreezClient` holds `Arc` directly, implements the `breez_sdk_liquid::Signer` trait through a `HotSignerAdapter` so the mnemonic never leaves the HotSigner, and exposes a rich set of methods including swap refunds, L-BTC/USDt asset handling, and LNURL fulfillment via `receive_lnurl_invoice(amount_sat, description_hash)`. +- **Spark** (`breez_spark/`) is async/IPC — `SparkClient` spawns a sibling binary and JSON-RPCs over stdio. Cheap operations round-trip in a few ms; expensive ones live in the bridge. The bridge holds the mnemonic in its own address space. + +A premature trait would paper over those differences. Instead, `WalletRegistry` is the enum-dispatch site: callers that need "a backend" branch on `WalletKind` and pick a concrete handle, and the domain types in `wallets/types.rs` carry the shared UI-facing shape. Extract a trait only when a **third** backend appears and you can see the common surface empirically — not before. + +## Settings plumbing + +Per-cube settings live in `coincube-gui/src/app/settings/mod.rs::CubeSettings`. Spark-relevant fields: + +- `liquid_wallet_signer_fingerprint: Option` — identifies the HotSigner that drives the Liquid wallet. +- `spark_wallet_signer_fingerprint: Option` — independent slot for the Spark wallet. Can point at the same HotSigner as Liquid or a different one. +- `default_lightning_backend: WalletKind` — cube-level override for which backend fulfills incoming Lightning Address invoices. Serde default is `Spark` post-Phase-5. + +The `Cache` struct mirrors `default_lightning_backend` and `cube_id` so panels can read them without threading `CubeSettings` through the `State::update(daemon, cache, message)` signature. The authoritative copy lives on `App::cube_settings` and is re-read from disk on `Message::SettingsSaved`. + +## Events + +Each backend has its own iced subscription + message variant: + +- `Message::LiquidEvent(BreezClientEvent)` — in-process Liquid SDK events. +- `Message::SparkEvent(SparkClientEvent)` — bridge events (`Synced`, `PaymentSucceeded { id, amount_sat, bolt11 }`, `DepositsChanged`, …). + +Liquid and Spark events are **not** unified into a generic `WalletEvent`. Unification makes sense only once a third backend arrives and shared handlers emerge empirically. + +## Adding a third wallet + +If you're wiring up e.g. `breez-sdk-greenlight` or a Nostr Wallet Connect client: + +1. **Create a protocol crate** if the new SDK can't live in-process (dep-graph conflicts, WASM/non-WASM split, etc.). Mirror `coincube-spark-protocol` + `coincube-spark-bridge`. +2. **Add a new module** under `coincube-gui/src/app/breez_/` (or `nwc/`, etc.) that wraps the client and handles config / load / shutdown. +3. **Add a new backend** under `coincube-gui/src/app/wallets/.rs` that maps the SDK's payment types to `DomainPayment` and exposes the panel-facing read/write surface. +4. **Extend `WalletKind`** in `wallets/types.rs` and make sure `Default` still points at the right wallet for new cubes. +5. **Extend `WalletRegistry`** with a new field + getter + routing-method updates. +6. **Add a Menu variant** in `app/menu.rs` (`Menu::(SubMenu)`) and sidebar buttons in `app/view/mod.rs`. +7. **Create parallel `state//` and `view//`** trees with Overview / Send / Receive / Transactions / Settings panels. Copy the Spark panels as a starting point — they're the most abstracted of the three today. +8. **Add config fields** to `CubeSettings` (`_wallet_signer_fingerprint`) and the corresponding `Cache` mirror if panels need it. +9. **Wire events** into `App::subscription` and `App::update` under a new `Message::Event` variant. +10. **Update routing rules** in `WalletRegistry::route_*` methods so the new backend participates where appropriate. +11. **Update docs** — add a `docs/_WALLET.md` mirroring [SPARK_WALLET.md](./SPARK_WALLET.md), and extend this file's Layout section. + +Resist the temptation to extract a shared panel component on the first pass. Wait until the third wallet is working and the genuine duplication is visible, then extract. Extracting prematurely couples multiple still-moving targets. + +## References + +- [SPARK_WALLET.md](./SPARK_WALLET.md) — Spark-specific setup, architecture, and feature notes. +- [BREEZ_SDK_REGTEST.md](./BREEZ_SDK_REGTEST.md) — Liquid regtest harness. +- `/.claude/plans/crystalline-booping-wadler.md` — full Spark integration plan with phase-by-phase rollout.