From 671c691a131e04955e35ce8cd461310c9a2be803 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sat, 16 Aug 2025 21:38:16 +0100 Subject: [PATCH 01/13] feat: bolt12 --- Cargo.lock | 88 ++++++++++++--- harbor-client/Cargo.toml | 8 +- harbor-client/src/cashu_client.rs | 163 +++++++++++++++++++++++++- harbor-client/src/lib.rs | 182 ++++++++++++++++++++++++++++-- harbor-ui/src/bridge.rs | 28 ++++- harbor-ui/src/main.rs | 93 ++++++++++++++- harbor-ui/src/routes/receive.rs | 136 +++++++++++++++++----- harbor-ui/src/routes/send.rs | 4 +- 8 files changed, 634 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d742cc7..9e455305 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1153,14 +1153,14 @@ source = "git+https://github.com/benthecarman/arti.git?rev=e0f1f7a9a44ae0543c0b6 [[package]] name = "cashu" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c95072ffe3442625d7589faeeabab4c739a4b8a79c11d826f295606dad7a26" +version = "0.11.0" +source = "git+https://github.com/thesimplekid/cdk?branch=bip353_examples#2a2a1de998ca100bdb765de850afe687880795ae" dependencies = [ "bitcoin", "cbor-diag", "ciborium", "instant", + "lightning 0.1.5", "lightning-invoice 0.33.2", "once_cell", "regex", @@ -1216,9 +1216,8 @@ dependencies = [ [[package]] name = "cdk" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b26c214b8cce92097d4c9c72081eceb6ba52f55e98f0d2f2987d862f07b0fd6" +version = "0.11.0" +source = "git+https://github.com/thesimplekid/cdk?branch=bip353_examples#2a2a1de998ca100bdb765de850afe687880795ae" dependencies = [ "anyhow", "arc-swap", @@ -1230,6 +1229,7 @@ dependencies = [ "cdk-signatory", "ciborium", "getrandom 0.2.15", + "lightning 0.1.5", "lightning-invoice 0.33.2", "regex", "reqwest 0.12.12", @@ -1249,9 +1249,8 @@ dependencies = [ [[package]] name = "cdk-common" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "448d373b82da04b4bdea60f28ae5b08f057888e1bd96cc3c68270c9fb4b3fbfc" +version = "0.11.0" +source = "git+https://github.com/thesimplekid/cdk?branch=bip353_examples#2a2a1de998ca100bdb765de850afe687880795ae" dependencies = [ "anyhow", "async-trait", @@ -1261,6 +1260,7 @@ dependencies = [ "ciborium", "futures", "instant", + "lightning 0.1.5", "lightning-invoice 0.33.2", "serde", "serde_json", @@ -1273,9 +1273,8 @@ dependencies = [ [[package]] name = "cdk-signatory" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a43766d6de33b9ac2976ed06c571e6c5b566ee75222f48373964289b604bf4" +version = "0.11.0" +source = "git+https://github.com/thesimplekid/cdk?branch=bip353_examples#2a2a1de998ca100bdb765de850afe687880795ae" dependencies = [ "anyhow", "async-trait", @@ -1293,15 +1292,33 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "cdk-sql-common" +version = "0.11.0" +source = "git+https://github.com/thesimplekid/cdk?branch=bip353_examples#2a2a1de998ca100bdb765de850afe687880795ae" +dependencies = [ + "async-trait", + "bitcoin", + "cdk-common", + "lightning-invoice 0.33.2", + "once_cell", + "serde", + "serde_json", + "thiserror 2.0.12", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "cdk-sqlite" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "560aded37253bde9778f86592cfd5c8efb3c67a54891c3ecfad8a43d2b1e255d" +version = "0.11.0" +source = "git+https://github.com/thesimplekid/cdk?branch=bip353_examples#2a2a1de998ca100bdb765de850afe687880795ae" dependencies = [ "async-trait", "bitcoin", "cdk-common", + "cdk-sql-common", "lightning-invoice 0.33.2", "rusqlite", "serde", @@ -2286,6 +2303,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "dnssec-prover" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4f825369fc7134da70ca4040fddc8e03b80a46d249ae38d9c1c39b7b4476bf" + [[package]] name = "document-features" version = "0.2.10" @@ -2936,7 +2959,7 @@ dependencies = [ "itertools 0.13.0", "js-sys", "jsonrpsee-core", - "lightning", + "lightning 0.0.125", "lightning-invoice 0.32.0", "lightning-types 0.1.0", "macro_rules_attribute", @@ -3055,7 +3078,7 @@ dependencies = [ "bitcoin", "fedimint-core", "fedimint-threshold-crypto", - "lightning", + "lightning 0.0.125", "lightning-invoice 0.32.0", "serde", "serde-big-array", @@ -4085,6 +4108,12 @@ 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" @@ -5577,6 +5606,22 @@ dependencies = [ "lightning-types 0.1.0", ] +[[package]] +name = "lightning" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e540fcb289a76826c9c0b078d3dd1f05691972c5a53fb4d3120540862040a147" +dependencies = [ + "bech32 0.11.0", + "bitcoin", + "dnssec-prover", + "hashbrown 0.13.2", + "libm", + "lightning-invoice 0.33.2", + "lightning-types 0.2.0", + "possiblyrandom", +] + [[package]] name = "lightning-invoice" version = "0.32.0" @@ -7261,6 +7306,15 @@ dependencies = [ "url", ] +[[package]] +name = "possiblyrandom" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b122a615d72104fb3d8b26523fdf9232cd8ee06949fb37e4ce3ff964d15dffd" +dependencies = [ + "getrandom 0.2.15", +] + [[package]] name = "postage" version = "0.5.0" diff --git a/harbor-client/Cargo.toml b/harbor-client/Cargo.toml index 059b805a..01b7ecd3 100644 --- a/harbor-client/Cargo.toml +++ b/harbor-client/Cargo.toml @@ -28,8 +28,12 @@ once_cell = "1.20.2" httparse = "1.8.0" url = "2.5.0" -cdk = { version = "0.11.1", default-features = false, features = ["wallet"] } -cdk-sqlite = { version = "0.11.1", default-features = false, features = ["wallet", "sqlcipher"] } +# cdk = { version = "0.11.1", default-features = false, features = ["wallet"] } +# cdk-sqlite = { version = "0.11.1", default-features = false, features = ["wallet", "sqlcipher"] } + +cdk = { git = "https://github.com/thesimplekid/cdk", branch = "bip353_examples", default-features = false, features = ["wallet"] } +cdk-sqlite = { git = "https://github.com/thesimplekid/cdk/", branch = "bip353_examples", default-features = false, features = ["wallet", "sqlcipher"] } + bitcoin = { version = "0.32.4", features = ["base64"] } bip39 = "2.0.0" diff --git a/harbor-client/src/cashu_client.rs b/harbor-client/src/cashu_client.rs index 101d87ab..b496e1a4 100644 --- a/harbor-client/src/cashu_client.rs +++ b/harbor-client/src/cashu_client.rs @@ -10,8 +10,9 @@ use cdk::amount::SplitTarget; use cdk::mint_url::MintUrl; use cdk::nuts::{ CheckStateRequest, CheckStateResponse, Id, KeySet, KeysResponse, KeysetResponse, - MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltRequest, MintInfo, MintQuoteBolt11Request, - MintQuoteBolt11Response, MintQuoteState, MintRequest, MintResponse, RestoreRequest, + MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltQuoteBolt12Request, MeltRequest, MintInfo, + MintQuoteBolt11Request, MintQuoteBolt11Response, MintQuoteBolt12Request, + MintQuoteBolt12Response, MintQuoteState, MintRequest, MintResponse, RestoreRequest, RestoreResponse, SwapRequest, SwapResponse, }; use cdk::util::unix_time; @@ -152,6 +153,62 @@ impl MintConnector for TorMintConnector { self.http_post(url, &request).await } + /// Mint Quote Bolt12 [NUT-04] + async fn post_mint_bolt12_quote( + &self, + request: MintQuoteBolt12Request, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "mint", "quote", "bolt12"])?; + self.http_post(url, &request).await + } + + /// Mint Quote Bolt12 status + async fn get_mint_quote_bolt12_status( + &self, + quote_id: &str, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "mint", "quote", "bolt12", quote_id])?; + + self.http_get(url).await + } + + /// Melt Quote Bolt12 [NUT-05] + async fn post_melt_bolt12_quote( + &self, + request: MeltQuoteBolt12Request, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "melt", "quote", "bolt12"])?; + self.http_post(url, &request).await + } + + /// Melt Quote Bolt12 Status + async fn get_melt_bolt12_quote_status( + &self, + quote_id: &str, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "melt", "quote", "bolt12", quote_id])?; + + self.http_get(url).await + } + + /// Melt Bolt12 [NUT-05] + /// [Nut-08] Lightning fee return if outputs defined + async fn post_melt_bolt12( + &self, + request: MeltRequest, + ) -> Result, Error> { + let url = self.mint_url.join_paths(&["v1", "melt", "bolt12"])?; + self.http_post(url, &request).await + } + /// Swap Token [NUT-03] async fn post_swap(&self, swap_request: SwapRequest) -> Result { let url = self.mint_url.join_paths(&["v1", "swap"])?; @@ -321,3 +378,105 @@ pub fn spawn_lightning_receive_thread( } }); } + +pub fn spawn_bolt12_receive_thread( + mut sender: Sender, + client: Wallet, + storage: Arc, + quote: MintQuote, + msg_id: Uuid, + is_transfer: bool, +) { + spawn(async move { + let mut error_counter = 0; + loop { + // For bolt12, we'll check using the regular mint quote state method + // The wallet should handle bolt12 quotes the same way as bolt11 quotes + let mint_quote_response = match client.mint_bolt12_quote_state("e.id).await { + Ok(response) => response, + Err(e) => { + error!("Error getting mint quote state for bolt12: {e}"); + tokio::time::sleep(Duration::from_secs(1)).await; + error_counter += 1; + if error_counter > 5 { + log::error!("Too many errors checking bolt12 quote state, giving up"); + return; + } + continue; + } + }; + + let amount_mintable = + mint_quote_response.amount_paid - mint_quote_response.amount_issued; + + if amount_mintable > 0.into() { + log::info!("Bolt12 quote {} has been paid, minting tokens", quote.id); + + match client + .mint_bolt12( + "e.id, + Some(amount_mintable), + SplitTarget::default(), + None, + ) + .await + { + Ok(_) => { + log::info!("Successfully minted tokens for bolt12 quote {}", quote.id); + + let params = if is_transfer { + ReceiveSuccessMsg::Transfer + } else { + ReceiveSuccessMsg::Lightning + }; + HarborCore::send_msg( + &mut sender, + Some(msg_id), + CoreUIMsg::ReceiveSuccess(params), + ) + .await; + + // Note: For now we're using the bolt11 database methods since bolt12 quotes + // are compatible with the same structure. In a future version, the database + // schema should be updated to properly handle bolt12 quotes. + if let Err(e) = storage.mark_ln_receive_as_success(quote.id) { + error!("Could not mark bolt12 receive as success: {e}"); + } + + let new_balance = + client.total_balance().await.expect("Failed to get balance"); + HarborCore::send_msg( + &mut sender, + Some(msg_id), + CoreUIMsg::MintBalanceUpdated { + id: MintIdentifier::Cashu(client.mint_url.clone()), + balance: Amount::from_sats(new_balance.into()), + }, + ) + .await; + + update_history(storage, msg_id, &mut sender).await; + + break; + } + Err(e) => { + error!( + "Failed to mint receive tokens for bolt12 quote {}: {e}", + quote.id + ); + HarborCore::send_msg( + &mut sender, + Some(msg_id), + CoreUIMsg::ReceiveFailed(e.to_string()), + ) + .await; + break; + } + } + } + + // Check every second for payment + tokio::time::sleep(Duration::from_secs(1)).await; + } + }); +} diff --git a/harbor-client/src/lib.rs b/harbor-client/src/lib.rs index 8e99dc7c..f4c7ff40 100644 --- a/harbor-client/src/lib.rs +++ b/harbor-client/src/lib.rs @@ -23,7 +23,8 @@ )] use crate::cashu_client::{ - TorMintConnector, spawn_lightning_payment_thread, spawn_lightning_receive_thread, + TorMintConnector, spawn_bolt12_receive_thread, spawn_lightning_payment_thread, + spawn_lightning_receive_thread, }; use crate::db::DBConnection; use crate::db_models::MintItem; @@ -139,6 +140,15 @@ pub enum UICoreMsg { mint: MintIdentifier, invoice: Bolt11Invoice, }, + SendBolt12 { + mint: MintIdentifier, + offer: String, + amount_msats: Option, + }, + ReceiveBolt12 { + mint: MintIdentifier, + amount: Option, + }, SendLnurlPay { mint: MintIdentifier, lnurl: LnUrl, @@ -206,6 +216,7 @@ pub enum CoreUIMsg { SendFailure(String), ReceiveGenerating, ReceiveInvoiceGenerated(Bolt11Invoice), + ReceiveBolt12OfferGenerated(String), ReceiveAddressGenerated(Address), ReceiveSuccess(ReceiveSuccessMsg), ReceiveFailed(String), @@ -372,14 +383,28 @@ impl HarborCore { if let Ok(Some(quote)) = client.localstore.get_mint_quote(&item.operation_id).await { - spawn_lightning_receive_thread( - tx.clone(), - client.clone(), - storage.clone(), - quote, - Uuid::nil(), - false, - ); + // Check if this might be a bolt12 quote by examining the request + if quote.request.starts_with("lno") { + // This is likely a bolt12 quote + spawn_bolt12_receive_thread( + tx.clone(), + client.clone(), + storage.clone(), + quote, + Uuid::nil(), + false, + ); + } else { + // This is a bolt11 quote + spawn_lightning_receive_thread( + tx.clone(), + client.clone(), + storage.clone(), + quote, + Uuid::nil(), + false, + ); + } } else { storage.mark_ln_receive_as_failed(item.operation_id)?; } @@ -790,6 +815,141 @@ impl HarborCore { Ok(()) } + pub async fn send_bolt12( + &self, + msg_id: Uuid, + from: MintIdentifier, + offer: String, + amount_msats: Option, + is_transfer: bool, + ) -> anyhow::Result<()> { + self.status_update(msg_id, "Preparing to send bolt12 payment") + .await; + + match from { + MintIdentifier::Cashu(mint_url) => { + self.send_bolt12_from_cashu(msg_id, mint_url, offer, amount_msats, is_transfer) + .await + } + MintIdentifier::Fedimint(_id) => { + // Fedimint doesn't support bolt12 yet + Err(anyhow!("Bolt12 payments are not supported on Fedimint")) + } + } + } + + pub async fn send_bolt12_from_cashu( + &self, + msg_id: Uuid, + mint_url: MintUrl, + offer: String, + amount_msats: Option, + is_transfer: bool, + ) -> anyhow::Result<()> { + log::info!("Paying bolt12 offer: {offer} from cashu mint: {mint_url}"); + + let client = self.get_cashu_client(&mint_url).await; + + self.status_update(msg_id, "Getting bolt12 quote").await; + + let melt_options = amount_msats.map(|amt| cdk::nuts::MeltOptions::Amountless { + amountless: cdk::nuts::nut23::Amountless { + amount_msat: cdk::Amount::from(amt), + }, + }); + + let quote = client + .melt_bolt12_quote(offer.clone(), melt_options) + .await?; + + log::info!("Sending bolt12 payment"); + + self.status_update(msg_id, "Creating payment transaction") + .await; + + spawn_lightning_payment_thread( + self.tx.clone(), + client, + self.storage.clone(), + quote, + msg_id, + is_transfer, + ); + + self.status_update(msg_id, "Waiting for payment confirmation") + .await; + + log::info!("Bolt12 payment sent"); + + Ok(()) + } + + pub async fn receive_bolt12( + &self, + msg_id: Uuid, + mint_identifier: MintIdentifier, + amount: Option, + is_transfer: bool, + ) -> anyhow::Result { + match mint_identifier { + MintIdentifier::Cashu(mint_url) => { + self.receive_bolt12_from_cashu(msg_id, mint_url, amount, is_transfer) + .await + } + MintIdentifier::Fedimint(_id) => { + // Fedimint doesn't support bolt12 yet + Err(anyhow!("Bolt12 offers are not supported on Fedimint")) + } + } + } + + pub async fn receive_bolt12_from_cashu( + &self, + msg_id: Uuid, + mint: MintUrl, + amount: Option, + is_transfer: bool, + ) -> anyhow::Result { + let tor_enabled = self.tor_enabled.load(Ordering::Relaxed); + log::info!( + "Creating bolt12 offer, amount: {:?} for mint: {mint}. Tor enabled: {tor_enabled}", + amount + ); + + self.status_update(msg_id, "Connecting to mint").await; + + let client = self.get_cashu_client(&mint).await; + + self.status_update(msg_id, "Generating bolt12 offer").await; + + let cdk_amount = amount.map(|a| cdk::Amount::from(a.msats / 1000)); + + let quote = client.mint_bolt12_quote(cdk_amount, None).await?; + + let offer = quote.request.clone(); + + log::info!("Bolt12 offer created: {offer}"); + + // Spawn the bolt12 receive thread to monitor for payment + spawn_bolt12_receive_thread( + self.tx.clone(), + client, + self.storage.clone(), + quote, + msg_id, + is_transfer, + ); + + // Send the offer generation message to the UI + self.msg( + msg_id, + CoreUIMsg::ReceiveBolt12OfferGenerated(offer.clone()), + ) + .await; + + Ok(offer) + } + pub async fn send_lnurl_pay( &self, msg_id: Uuid, @@ -1219,7 +1379,7 @@ impl HarborCore { .mint_url(mint_url.clone()) .unit(CurrencyUnit::Sat) .localstore(self.cashu_storage.clone()) - .seed(&seed); + .seed(seed); let builder = if self.tor_enabled.load(Ordering::Relaxed) { builder.client(TorMintConnector::new( @@ -1380,7 +1540,7 @@ impl HarborCore { .mint_url(mint_url.clone()) .unit(CurrencyUnit::Sat) .localstore(self.cashu_storage.clone()) - .seed(&seed); + .seed(seed); let builder = if self.tor_enabled.load(Ordering::Relaxed) { builder.client(TorMintConnector::new( diff --git a/harbor-ui/src/bridge.rs b/harbor-ui/src/bridge.rs index 389e535e..fe2a443a 100644 --- a/harbor-ui/src/bridge.rs +++ b/harbor-ui/src/bridge.rs @@ -130,7 +130,7 @@ async fn setup_harbor_core( let cashu_db_path = data_dir.join("cashu.sqlite"); let cashu_db = Arc::new( - WalletSqliteDatabase::new(&cashu_db_path, password.to_string()) + WalletSqliteDatabase::new((cashu_db_path, password.to_string())) .await .expect("Could not create cashu WalletRedbDatabase"), ); @@ -149,7 +149,7 @@ async fn setup_harbor_core( .mint_url(mint_url.clone()) .unit(CurrencyUnit::Sat) .localstore(cashu_db.clone()) - .seed(&seed); + .seed(seed); let builder = if profile.tor_enabled() { builder.client(TorMintConnector::new( @@ -435,7 +435,7 @@ pub fn run_core() -> impl Stream { let cashu_db_path = path.join("cashu.sqlite"); let cashu_db = Arc::new( - WalletSqliteDatabase::new(&cashu_db_path, password) + WalletSqliteDatabase::new((cashu_db_path, password)) .await .expect("Could not create cashu WalletRedbDatabase"), ); @@ -508,6 +508,28 @@ async fn process_core(core_handle: &mut CoreHandle, core: &HarborCore) { .await; } } + UICoreMsg::SendBolt12 { mint, offer, amount_msats } => { + log::info!("Got UICoreMsg::SendBolt12"); + core.msg(msg.id, CoreUIMsg::Sending).await; + if let Err(e) = core.send_bolt12(msg.id, mint, offer, amount_msats, false).await { + error!("Error sending bolt12: {e}"); + core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) + .await; + } + } + UICoreMsg::ReceiveBolt12 { mint, amount } => { + core.msg(msg.id, CoreUIMsg::ReceiveGenerating).await; + match core.receive_bolt12(msg.id, mint, amount, false).await { + Err(e) => { + core.msg(msg.id, CoreUIMsg::ReceiveFailed(e.to_string())) + .await; + } + Ok(offer) => { + core.msg(msg.id, CoreUIMsg::ReceiveBolt12OfferGenerated(offer)) + .await; + } + } + } UICoreMsg::ReceiveLightning { mint, amount } => { core.msg(msg.id, CoreUIMsg::ReceiveGenerating).await; match core.receive_lightning(msg.id, mint, amount, false).await { diff --git a/harbor-ui/src/main.rs b/harbor-ui/src/main.rs index 0addd90d..98bf6005 100644 --- a/harbor-ui/src/main.rs +++ b/harbor-ui/src/main.rs @@ -144,6 +144,7 @@ enum UnlockStatus { pub enum ReceiveMethod { #[default] Lightning, + Bolt12, OnChain, } @@ -206,6 +207,7 @@ pub enum Message { Send(String), Transfer, GenerateInvoice, + GenerateBolt12Offer, GenerateAddress, Unlock(String), Init { @@ -277,6 +279,7 @@ pub struct HarborWallet { receive_amount_str: String, receive_invoice: Option, receive_address: Option
, + receive_bolt12_offer: Option, receive_qr_data: Option, receive_method: ReceiveMethod, // Mints @@ -362,6 +365,7 @@ impl HarborWallet { self.receive_amount_str = String::new(); self.receive_invoice = None; self.receive_address = None; + self.receive_bolt12_offer = None; self.receive_qr_data = None; self.receive_method = ReceiveMethod::Lightning; // We dont' clear the success msg so the history screen can show the most recent @@ -636,6 +640,36 @@ impl HarborWallet { self.send_from_ui(UICoreMsg::SendLightning { mint, invoice }); self.current_send_id = Some(id); task + } else if invoice_str.starts_with("lno") { + // This looks like a Bolt12 offer + let amount_msats = if self.is_max { + return Task::perform(async {}, |_| { + Message::AddToast(Toast { + title: "Cannot send max with Bolt12 offer".to_string(), + body: Some("Please enter a specific amount".to_string()), + status: ToastStatus::Bad, + }) + }); + } else if !self.send_amount_input_str.is_empty() { + match self.send_amount_input_str.parse::() { + Ok(amount) => Some(amount * 1000), // Convert sats to msats + Err(e) => { + error!("Error parsing amount: {e}"); + self.send_failure_reason = Some(e.to_string()); + return Task::none(); + } + } + } else { + None // Amount-less Bolt12 offer + }; + + let (id, task) = self.send_from_ui(UICoreMsg::SendBolt12 { + mint, + offer: invoice_str, + amount_msats, + }); + self.current_send_id = Some(id); + task } else { match parse_lnurl(&invoice_str) { Ok(lnurl) => { @@ -686,7 +720,7 @@ impl HarborWallet { self.current_send_id = Some(id); task } else { - error!("Invalid invoice or address"); + error!("Invalid invoice, address, or Bolt12 offer"); self.current_send_id = None; Task::done(Message::AddToast(Toast { title: "Failed to send".to_string(), @@ -785,6 +819,48 @@ impl HarborWallet { } } }, + Message::GenerateBolt12Offer => match self.receive_status { + ReceiveStatus::Generating => Task::none(), + _ => { + let mint = match self.active_mint.clone() { + Some(f) => f, + None => { + error!("No active mint"); + return Task::perform(async {}, |_| { + Message::AddToast(Toast { + title: "Cannot generate Bolt12 offer".to_string(), + body: Some("No active mint selected".to_string()), + status: ToastStatus::Bad, + }) + }); + } + }; + + // For Bolt12, amount can be optional + let amount = if self.receive_amount_str.is_empty() { + None + } else { + match self.receive_amount_str.parse::() { + Ok(amount) => Some(Amount::from_sats(amount)), + Err(e) => { + error!("Error parsing amount: {e}"); + return Task::perform(async {}, move |_| { + Message::AddToast(Toast { + title: "Failed to generate Bolt12 offer".to_string(), + body: Some(e.to_string()), + status: ToastStatus::Bad, + }) + }); + } + } + }; + + let (id, task) = self.send_from_ui(UICoreMsg::ReceiveBolt12 { mint, amount }); + self.current_receive_id = Some(id); + self.receive_failure_reason = None; + task + } + }, Message::GenerateAddress => match self.receive_status { ReceiveStatus::Generating => Task::none(), _ => { @@ -1154,6 +1230,21 @@ impl HarborWallet { self.receive_invoice = Some(invoice); Task::none() } + CoreUIMsg::ReceiveBolt12OfferGenerated(offer) => { + self.receive_status = ReceiveStatus::WaitingToReceive; + debug!("Received bolt12 offer: {offer}"); + self.receive_qr_data = Some( + Data::with_error_correction( + offer.clone(), + iced::widget::qr_code::ErrorCorrection::Low, + ) + .unwrap(), + ); + // Store the bolt12 offer separately and clear the lightning invoice + self.receive_bolt12_offer = Some(offer); + self.receive_invoice = None; + Task::none() + } CoreUIMsg::AddMintFailed(reason) => { self.clear_add_federation_state(); Task::done(Message::AddToast(Toast { diff --git a/harbor-ui/src/routes/receive.rs b/harbor-ui/src/routes/receive.rs index 0063a60c..3d34c358 100644 --- a/harbor-ui/src/routes/receive.rs +++ b/harbor-ui/src/routes/receive.rs @@ -10,12 +10,18 @@ use iced::{Color, Length}; /// Main view function. pub fn receive(harbor: &HarborWallet) -> Element { - if let Some(receive_string) = harbor - .receive_invoice - .as_ref() - .map(|s| s.to_string()) - .or_else(|| harbor.receive_address.as_ref().map(|a| a.to_string())) - { + // First check if we have a regular invoice or address + let receive_string = if let Some(invoice) = &harbor.receive_invoice { + Some(invoice.to_string()) + } else if let Some(address) = &harbor.receive_address { + Some(address.to_string()) + } else if let Some(offer) = &harbor.receive_bolt12_offer { + Some(offer.clone()) + } else { + None + }; + + if let Some(receive_string) = receive_string { render_generated_view(receive_string, harbor) } else { render_receive_form(harbor) @@ -35,18 +41,27 @@ fn render_receive_form(harbor: &HarborWallet) -> Element { .active_federation() .is_some_and(|x| x.on_chain_supported)); - let header = if on_chain_enabled { - h_header("Deposit", "Receive on-chain or via lightning.") + // Bolt12 is only available for Cashu mints + let bolt12_enabled = harbor + .active_mint + .as_ref() + .is_some_and(|a| a.mint_url().is_some()); + + let header = if on_chain_enabled || bolt12_enabled { + h_header("Deposit", "Receive via lightning, Bolt12, or on-chain.") } else { h_header("Deposit", "Receive via lightning.") }; - let content = if on_chain_enabled { - let method_choice = render_method_choice(harbor); + let content = if on_chain_enabled || bolt12_enabled { + let method_choice = render_method_choice(harbor, on_chain_enabled, bolt12_enabled); match harbor.receive_method { ReceiveMethod::Lightning => { column![header, method_choice, render_lightning_view(harbor)] } + ReceiveMethod::Bolt12 => { + column![header, method_choice, render_bolt12_view(harbor)] + } ReceiveMethod::OnChain => { column![header, method_choice, render_onchain_view(harbor)] } @@ -103,6 +118,45 @@ fn render_lightning_view(harbor: &HarborWallet) -> Element { column![amount_input, buttons].spacing(48).into() } +/// Renders the Bolt12 view including the optional amount input. +fn render_bolt12_view(harbor: &HarborWallet) -> Element { + let generating = harbor.receive_status == ReceiveStatus::Generating; + + let amount_input = h_input(InputArgs { + label: "Amount (optional)", + placeholder: "420", + value: &harbor.receive_amount_str, + on_input: Message::ReceiveAmountChanged, + numeric: true, + suffix: Some("sats"), + disabled: generating, + ..InputArgs::default() + }); + + // Create the "Generate Bolt12 Offer" button. + let generate_offer_button = + h_button("Generate Bolt12 Offer", SvgIcon::Qr, generating).on_press(Message::GenerateBolt12Offer); + + let buttons = if generating { + // When generating, include a "Start Over" next to the generate button. + let start_over_button = h_button("Start Over", SvgIcon::Restart, false) + .on_press(Message::CancelReceiveGeneration); + let mut button_group = column![row![start_over_button, generate_offer_button].spacing(8)]; + + if let Some(status) = harbor + .current_receive_id + .and_then(|id| operation_status_for_id(harbor, Some(id))) + { + button_group = button_group.push(status).spacing(16); + } + button_group + } else { + column![generate_offer_button] + }; + + column![amount_input, buttons].spacing(48).into() +} + /// Renders the on-chain view. fn render_onchain_view(harbor: &HarborWallet) -> Element { let generating = harbor.receive_status == ReceiveStatus::Generating; @@ -130,8 +184,8 @@ fn render_onchain_view(harbor: &HarborWallet) -> Element { buttons.into() } -/// Renders the method selector for on-chain enabled wallets. -fn render_method_choice(harbor: &HarborWallet) -> Element { +/// Renders the method selector for enabled payment methods. +fn render_method_choice(harbor: &HarborWallet, on_chain_enabled: bool, bolt12_enabled: bool) -> Element { let lightning_choice = radio( "Lightning", ReceiveMethod::Lightning, @@ -143,34 +197,56 @@ fn render_method_choice(harbor: &HarborWallet) -> Element { let lightning_caption = h_caption_text("Good for small amounts. Instant settlement, low fees."); let lightning = column![lightning_choice, lightning_caption].spacing(8); - let onchain_choice = radio( - "On-chain", - ReceiveMethod::OnChain, - Some(harbor.receive_method), - Message::ReceiveMethodChanged, - ) - .text_size(18); + let mut choices = vec![lightning]; + + if bolt12_enabled { + let bolt12_choice = radio( + "Bolt12", + ReceiveMethod::Bolt12, + Some(harbor.receive_method), + Message::ReceiveMethodChanged, + ) + .text_size(18); - let onchain_caption = h_caption_text( - "Good for large amounts. Requires on-chain fees and 10 block confirmations.", - ); - let onchain = column![onchain_choice, onchain_caption].spacing(8); + let bolt12_caption = h_caption_text("Reusable offers. Good for donations and recurring payments."); + let bolt12 = column![bolt12_choice, bolt12_caption].spacing(8); + choices.push(bolt12); + } + + if on_chain_enabled { + let onchain_choice = radio( + "On-chain", + ReceiveMethod::OnChain, + Some(harbor.receive_method), + Message::ReceiveMethodChanged, + ) + .text_size(18); + + let onchain_caption = h_caption_text( + "Good for large amounts. Requires on-chain fees and 10 block confirmations.", + ); + let onchain = column![onchain_choice, onchain_caption].spacing(8); + choices.push(onchain); + } let method_choice_label = text("Method").size(24); + let mut column = column![method_choice_label]; - column![method_choice_label, lightning, onchain] - .spacing(16) - .into() + for choice in choices { + column = column.push(choice); + } + + column.spacing(16).into() } /// Renders the view for a generated invoice/address. fn render_generated_view(receive_string: String, harbor: &HarborWallet) -> Element { let header = h_header("Receive", "Scan this QR or copy the string."); - let qr_title = if harbor.receive_method == ReceiveMethod::Lightning { - "Lightning Invoice" - } else { - "On-chain Address" + let qr_title = match harbor.receive_method { + ReceiveMethod::Lightning => "Lightning Invoice", + ReceiveMethod::Bolt12 => "Bolt12 Offer", + ReceiveMethod::OnChain => "On-chain Address", }; let data = harbor diff --git a/harbor-ui/src/routes/send.rs b/harbor-ui/src/routes/send.rs index c9f3b924..b5dcaa32 100644 --- a/harbor-ui/src/routes/send.rs +++ b/harbor-ui/src/routes/send.rs @@ -8,11 +8,11 @@ use crate::components::{ use crate::{HarborWallet, Message, SendStatus}; pub fn send(harbor: &HarborWallet) -> Element { - let header = h_header("Send", "Send to an on-chain address or lightning invoice."); + let header = h_header("Send", "Send to an on-chain address, lightning invoice, or Bolt12 offer."); let dest_input = h_input(InputArgs { label: "Destination", - placeholder: "lnbc1...", + placeholder: "lnbc1... or lno... or bc1...", value: &harbor.send_dest_input_str, on_input: Message::SendDestInputChanged, ..InputArgs::default() From ebeb61395d62cb90f16bc2efe45507f8434c8ca8 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Tue, 26 Aug 2025 21:51:12 +0100 Subject: [PATCH 02/13] feat: cdk v0.12 --- Cargo.lock | 72 ++++++++++++++++++++++++---------------- harbor-client/Cargo.toml | 8 ++--- harbor-client/src/lib.rs | 2 +- 3 files changed, 49 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e455305..09fa9580 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1153,8 +1153,9 @@ source = "git+https://github.com/benthecarman/arti.git?rev=e0f1f7a9a44ae0543c0b6 [[package]] name = "cashu" -version = "0.11.0" -source = "git+https://github.com/thesimplekid/cdk?branch=bip353_examples#2a2a1de998ca100bdb765de850afe687880795ae" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2b5eada71b5675fea6ae15f5d0aeb76d6d0c8ae057fed2f9afc491b3d2e741c" dependencies = [ "bitcoin", "cbor-diag", @@ -1173,6 +1174,7 @@ dependencies = [ "tracing", "url", "uuid", + "zeroize", ] [[package]] @@ -1216,8 +1218,9 @@ dependencies = [ [[package]] name = "cdk" -version = "0.11.0" -source = "git+https://github.com/thesimplekid/cdk?branch=bip353_examples#2a2a1de998ca100bdb765de850afe687880795ae" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3f3b7e0485c9d1f5b9ae28096ec8945a636a8f4d1f69a6ba9baa57d76e25b4" dependencies = [ "anyhow", "arc-swap", @@ -1228,6 +1231,7 @@ dependencies = [ "cdk-common", "cdk-signatory", "ciborium", + "futures", "getrandom 0.2.15", "lightning 0.1.5", "lightning-invoice 0.33.2", @@ -1242,15 +1246,18 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tokio-tungstenite 0.26.2", + "tokio-util", "tracing", "url", "uuid", + "zeroize", ] [[package]] name = "cdk-common" -version = "0.11.0" -source = "git+https://github.com/thesimplekid/cdk?branch=bip353_examples#2a2a1de998ca100bdb765de850afe687880795ae" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51417f6e891a83a6093065ca52d17ea2e1371d6952f2a209b76e7ecd45977622" dependencies = [ "anyhow", "async-trait", @@ -1273,8 +1280,9 @@ dependencies = [ [[package]] name = "cdk-signatory" -version = "0.11.0" -source = "git+https://github.com/thesimplekid/cdk?branch=bip353_examples#2a2a1de998ca100bdb765de850afe687880795ae" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f5a3079d8c86ad41961d7910eff164731952ebb9abd30add3c7548af9c1c6f1" dependencies = [ "anyhow", "async-trait", @@ -1294,8 +1302,9 @@ dependencies = [ [[package]] name = "cdk-sql-common" -version = "0.11.0" -source = "git+https://github.com/thesimplekid/cdk?branch=bip353_examples#2a2a1de998ca100bdb765de850afe687880795ae" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05c7ae82839052fe425347f05ad0f110b99a253af205975ce8ee185026b4aea" dependencies = [ "async-trait", "bitcoin", @@ -1312,8 +1321,9 @@ dependencies = [ [[package]] name = "cdk-sqlite" -version = "0.11.0" -source = "git+https://github.com/thesimplekid/cdk?branch=bip353_examples#2a2a1de998ca100bdb765de850afe687880795ae" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a62363768b26cbebb7bd7b731dbb0287f195bd16068abf486c50c0b2f208eacc" dependencies = [ "async-trait", "bitcoin", @@ -5345,10 +5355,11 @@ checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" [[package]] name = "js-sys" -version = "0.3.72" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -10847,12 +10858,14 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.12.1" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.3.2", + "js-sys", "serde", + "wasm-bindgen", ] [[package]] @@ -10941,24 +10954,24 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.95" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.95" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", "syn 2.0.95", @@ -10979,9 +10992,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.95" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -10989,9 +11002,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.95" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", @@ -11002,9 +11015,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.95" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] [[package]] name = "wasm-streams" diff --git a/harbor-client/Cargo.toml b/harbor-client/Cargo.toml index 01b7ecd3..418b3804 100644 --- a/harbor-client/Cargo.toml +++ b/harbor-client/Cargo.toml @@ -28,11 +28,11 @@ once_cell = "1.20.2" httparse = "1.8.0" url = "2.5.0" -# cdk = { version = "0.11.1", default-features = false, features = ["wallet"] } -# cdk-sqlite = { version = "0.11.1", default-features = false, features = ["wallet", "sqlcipher"] } +cdk = { version = "0.12.0", default-features = false, features = ["wallet"] } +cdk-sqlite = { version = "0.12.0", default-features = false, features = ["wallet", "sqlcipher"] } -cdk = { git = "https://github.com/thesimplekid/cdk", branch = "bip353_examples", default-features = false, features = ["wallet"] } -cdk-sqlite = { git = "https://github.com/thesimplekid/cdk/", branch = "bip353_examples", default-features = false, features = ["wallet", "sqlcipher"] } +# cdk = { git = "https://github.com/thesimplekid/cdk", branch = "bip353_examples", default-features = false, features = ["wallet"] } +# cdk-sqlite = { git = "https://github.com/thesimplekid/cdk/", branch = "bip353_examples", default-features = false, features = ["wallet", "sqlcipher"] } bitcoin = { version = "0.32.4", features = ["base64"] } diff --git a/harbor-client/src/lib.rs b/harbor-client/src/lib.rs index f4c7ff40..47390645 100644 --- a/harbor-client/src/lib.rs +++ b/harbor-client/src/lib.rs @@ -1394,7 +1394,7 @@ impl HarborCore { self.status_update(msg_id, "Retrieving mint metadata").await; - let info = wallet.get_mint_info().await?; + let info = wallet.fetch_mint_info().await?; self.status_update(msg_id, "Checking mint network").await; From cef7e2eb950a11698e6769d602d1b4abcaabdaf6 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Tue, 26 Aug 2025 22:45:45 +0100 Subject: [PATCH 03/13] feat: bolt12 db --- .../2025-08-26-213617_bolt12/down.sql | 79 +++++++++++++++++++ .../2025-08-26-213617_bolt12/up.sql | 79 +++++++++++++++++++ harbor-client/src/db.rs | 66 +++++++++++++++- .../src/db_models/lightning_payment.rs | 53 +++++++++++-- .../src/db_models/lightning_receive.rs | 54 +++++++++++-- harbor-client/src/db_models/schema.rs | 10 ++- harbor-client/src/lib.rs | 22 ++++++ 7 files changed, 342 insertions(+), 21 deletions(-) create mode 100644 harbor-client/migrations/2025-08-26-213617_bolt12/down.sql create mode 100644 harbor-client/migrations/2025-08-26-213617_bolt12/up.sql diff --git a/harbor-client/migrations/2025-08-26-213617_bolt12/down.sql b/harbor-client/migrations/2025-08-26-213617_bolt12/down.sql new file mode 100644 index 00000000..272a9359 --- /dev/null +++ b/harbor-client/migrations/2025-08-26-213617_bolt12/down.sql @@ -0,0 +1,79 @@ +-- This file should undo anything in `up.sql` +-- Down migration: revert Bolt12 support changes +-- Recreate the original tables with NOT NULL constraints and without bolt12_offer + +-- Drop triggers first +DROP TRIGGER IF EXISTS update_timestamp_lightning_payments; +DROP TRIGGER IF EXISTS update_timestamp_lightning_receives; + +-- Create original tables +CREATE TABLE lightning_payments_old +( + operation_id TEXT PRIMARY KEY NOT NULL, + fedimint_id TEXT REFERENCES fedimint (id), + cashu_mint_url TEXT REFERENCES cashu_mint (mint_url), + payment_hash TEXT NOT NULL, + bolt11 TEXT NOT NULL, + amount_msats BIGINT NOT NULL, + fee_msats BIGINT NOT NULL, + preimage TEXT, + status INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE lightning_receives_old +( + operation_id TEXT PRIMARY KEY NOT NULL, + fedimint_id TEXT REFERENCES fedimint (id), + cashu_mint_url TEXT REFERENCES cashu_mint (mint_url), + payment_hash TEXT NOT NULL, + bolt11 TEXT NOT NULL, + amount_msats BIGINT NOT NULL, + fee_msats BIGINT NOT NULL, + status INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Copy data back from current tables to old schema dropping bolt12 columns +-- Rows that relied on bolt12_offer only will be dropped because NOT NULL columns cannot be populated +INSERT INTO lightning_payments_old ( + operation_id, fedimint_id, cashu_mint_url, payment_hash, bolt11, amount_msats, fee_msats, preimage, status, created_at, updated_at +) +SELECT operation_id, fedimint_id, cashu_mint_url, payment_hash, bolt11, amount_msats, fee_msats, preimage, status, created_at, updated_at +FROM lightning_payments +WHERE payment_hash IS NOT NULL AND bolt11 IS NOT NULL; + +INSERT INTO lightning_receives_old ( + operation_id, fedimint_id, cashu_mint_url, payment_hash, bolt11, amount_msats, fee_msats, status, created_at, updated_at +) +SELECT operation_id, fedimint_id, cashu_mint_url, payment_hash, bolt11, amount_msats, fee_msats, status, created_at, updated_at +FROM lightning_receives +WHERE payment_hash IS NOT NULL AND bolt11 IS NOT NULL; + +-- Replace tables +DROP TABLE lightning_payments; +ALTER TABLE lightning_payments_old RENAME TO lightning_payments; + +DROP TABLE lightning_receives; +ALTER TABLE lightning_receives_old RENAME TO lightning_receives; + +-- Recreate triggers +CREATE TRIGGER update_timestamp_lightning_payments + AFTER UPDATE ON lightning_payments + FOR EACH ROW +BEGIN + UPDATE lightning_payments + SET updated_at = CURRENT_TIMESTAMP + WHERE operation_id = OLD.operation_id; +END; + +CREATE TRIGGER update_timestamp_lightning_receives + AFTER UPDATE ON lightning_receives + FOR EACH ROW +BEGIN + UPDATE lightning_receives + SET updated_at = CURRENT_TIMESTAMP + WHERE operation_id = OLD.operation_id; +END; diff --git a/harbor-client/migrations/2025-08-26-213617_bolt12/up.sql b/harbor-client/migrations/2025-08-26-213617_bolt12/up.sql new file mode 100644 index 00000000..9621af56 --- /dev/null +++ b/harbor-client/migrations/2025-08-26-213617_bolt12/up.sql @@ -0,0 +1,79 @@ +-- Your SQL goes here +-- Add Bolt12 support by allowing nullable bolt11/payment_hash and adding bolt12_offer columns +-- We need to recreate the lightning_payments and lightning_receives tables because SQLite +-- cannot drop NOT NULL constraints directly. + +-- 1) Drop triggers so we can replace the tables +DROP TRIGGER IF EXISTS update_timestamp_lightning_payments; +DROP TRIGGER IF EXISTS update_timestamp_lightning_receives; + +-- 2) Create new tables with the updated schema +CREATE TABLE lightning_payments_new +( + operation_id TEXT PRIMARY KEY NOT NULL, + fedimint_id TEXT REFERENCES fedimint (id), + cashu_mint_url TEXT REFERENCES cashu_mint (mint_url), + payment_hash TEXT, -- now nullable to support bolt12 + bolt11 TEXT, -- now nullable to support bolt12 + bolt12_offer TEXT, -- new column for bolt12 offers ("lno...") + amount_msats BIGINT NOT NULL, + fee_msats BIGINT NOT NULL, + preimage TEXT, + status INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE lightning_receives_new +( + operation_id TEXT PRIMARY KEY NOT NULL, + fedimint_id TEXT REFERENCES fedimint (id), + cashu_mint_url TEXT REFERENCES cashu_mint (mint_url), + payment_hash TEXT, -- now nullable to support bolt12 + bolt11 TEXT, -- now nullable to support bolt12 + bolt12_offer TEXT, -- new column for bolt12 offers ("lno...") + amount_msats BIGINT NOT NULL, + fee_msats BIGINT NOT NULL, + status INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 3) Copy over existing data +INSERT INTO lightning_payments_new ( + operation_id, fedimint_id, cashu_mint_url, payment_hash, bolt11, amount_msats, fee_msats, preimage, status, created_at, updated_at +) +SELECT operation_id, fedimint_id, cashu_mint_url, payment_hash, bolt11, amount_msats, fee_msats, preimage, status, created_at, updated_at +FROM lightning_payments; + +INSERT INTO lightning_receives_new ( + operation_id, fedimint_id, cashu_mint_url, payment_hash, bolt11, amount_msats, fee_msats, status, created_at, updated_at +) +SELECT operation_id, fedimint_id, cashu_mint_url, payment_hash, bolt11, amount_msats, fee_msats, status, created_at, updated_at +FROM lightning_receives; + +-- 4) Replace old tables +DROP TABLE lightning_payments; +ALTER TABLE lightning_payments_new RENAME TO lightning_payments; + +DROP TABLE lightning_receives; +ALTER TABLE lightning_receives_new RENAME TO lightning_receives; + +-- 5) Recreate triggers +CREATE TRIGGER update_timestamp_lightning_payments + AFTER UPDATE ON lightning_payments + FOR EACH ROW +BEGIN + UPDATE lightning_payments + SET updated_at = CURRENT_TIMESTAMP + WHERE operation_id = OLD.operation_id; +END; + +CREATE TRIGGER update_timestamp_lightning_receives + AFTER UPDATE ON lightning_receives + FOR EACH ROW +BEGIN + UPDATE lightning_receives + SET updated_at = CURRENT_TIMESTAMP + WHERE operation_id = OLD.operation_id; +END; diff --git a/harbor-client/src/db.rs b/harbor-client/src/db.rs index b459b1a1..e83bfcae 100644 --- a/harbor-client/src/db.rs +++ b/harbor-client/src/db.rs @@ -135,6 +135,16 @@ pub trait DBConnection { fee: Amount, ) -> anyhow::Result<()>; + fn create_bolt12_receive( + &self, + operation_id: String, + fedimint_id: Option, + cashu_mint_url: Option, + offer: String, + amount: Amount, + fee: Amount, + ) -> anyhow::Result<()>; + fn mark_ln_receive_as_success(&self, operation_id: String) -> anyhow::Result<()>; fn mark_ln_receive_as_failed(&self, operation_id: String) -> anyhow::Result<()>; @@ -149,6 +159,16 @@ pub trait DBConnection { fee: Amount, ) -> anyhow::Result<()>; + fn create_bolt12_payment( + &self, + operation_id: String, + fedimint_id: Option, + cashu_mint_url: Option, + offer: String, + amount: Amount, + fee: Amount, + ) -> anyhow::Result<()>; + fn set_lightning_as_complete( &self, operation_id: String, @@ -226,7 +246,7 @@ pub trait DBConnection { } pub struct SQLConnection { - db: Pool>, + db: Pool>, } impl DBConnection for SQLConnection { @@ -320,6 +340,28 @@ impl DBConnection for SQLConnection { Ok(()) } + fn create_bolt12_receive( + &self, + operation_id: String, + fedimint_id: Option, + cashu_mint_url: Option, + offer: String, + amount: Amount, + fee: Amount, + ) -> anyhow::Result<()> { + let conn = &mut self.db.get()?; + LightningReceive::create_bolt12( + conn, + operation_id, + fedimint_id, + cashu_mint_url, + offer, + amount, + fee, + )?; + Ok(()) + } + fn mark_ln_receive_as_success(&self, operation_id: String) -> anyhow::Result<()> { let conn = &mut self.db.get()?; @@ -360,6 +402,28 @@ impl DBConnection for SQLConnection { Ok(()) } + fn create_bolt12_payment( + &self, + operation_id: String, + fedimint_id: Option, + cashu_mint_url: Option, + offer: String, + amount: Amount, + fee: Amount, + ) -> anyhow::Result<()> { + let conn = &mut self.db.get()?; + LightningPayment::create_bolt12( + conn, + operation_id, + fedimint_id, + cashu_mint_url, + offer, + amount, + fee, + )?; + Ok(()) + } + fn set_lightning_as_complete( &self, operation_id: String, diff --git a/harbor-client/src/db_models/lightning_payment.rs b/harbor-client/src/db_models/lightning_payment.rs index 43cbdba8..5ea174ec 100644 --- a/harbor-client/src/db_models/lightning_payment.rs +++ b/harbor-client/src/db_models/lightning_payment.rs @@ -19,8 +19,9 @@ pub struct LightningPayment { pub operation_id: String, fedimint_id: Option, cashu_mint_url: Option, - payment_hash: String, - bolt11: String, + payment_hash: Option, + bolt11: Option, + bolt12_offer: Option, amount_msats: i64, fee_msats: i64, preimage: Option, @@ -35,8 +36,9 @@ struct NewLightningPayment { operation_id: String, fedimint_id: Option, cashu_mint_url: Option, - payment_hash: String, - bolt11: String, + payment_hash: Option, + bolt11: Option, + bolt12_offer: Option, amount_msats: i64, fee_msats: i64, status: i32, @@ -67,11 +69,17 @@ impl LightningPayment { } pub fn payment_hash(&self) -> [u8; 32] { - FromHex::from_hex(&self.payment_hash).expect("invalid payment hash") + FromHex::from_hex(self.payment_hash.as_ref().expect("missing payment hash")) + .expect("invalid payment hash") } pub fn bolt11(&self) -> Bolt11Invoice { - Bolt11Invoice::from_str(&self.bolt11).expect("invalid bolt11") + Bolt11Invoice::from_str(self.bolt11.as_ref().expect("missing bolt11")) + .expect("invalid bolt11") + } + + pub fn bolt12_offer(&self) -> Option<&str> { + self.bolt12_offer.as_deref() } pub fn amount(&self) -> Amount { @@ -114,8 +122,37 @@ impl LightningPayment { operation_id, fedimint_id: fedimint_id.map(|f| f.to_string()), cashu_mint_url: cashu_mint_url.map(|f| f.to_string()), - payment_hash, - bolt11: bolt11.to_string(), + payment_hash: Some(payment_hash), + bolt11: Some(bolt11.to_string()), + bolt12_offer: None, + amount_msats: amount.msats as i64, + fee_msats: fee.msats as i64, + status: PaymentStatus::Pending as i32, + }; + + diesel::insert_into(lightning_payments::table) + .values(new) + .execute(conn)?; + + Ok(()) + } + + pub fn create_bolt12( + conn: &mut SqliteConnection, + operation_id: String, + fedimint_id: Option, + cashu_mint_url: Option, + offer: String, + amount: Amount, + fee: Amount, + ) -> anyhow::Result<()> { + let new = NewLightningPayment { + operation_id, + fedimint_id: fedimint_id.map(|f| f.to_string()), + cashu_mint_url: cashu_mint_url.map(|f| f.to_string()), + payment_hash: None, + bolt11: None, + bolt12_offer: Some(offer), amount_msats: amount.msats as i64, fee_msats: fee.msats as i64, status: PaymentStatus::Pending as i32, diff --git a/harbor-client/src/db_models/lightning_receive.rs b/harbor-client/src/db_models/lightning_receive.rs index 58ce25e0..b474def4 100644 --- a/harbor-client/src/db_models/lightning_receive.rs +++ b/harbor-client/src/db_models/lightning_receive.rs @@ -19,8 +19,9 @@ pub struct LightningReceive { pub operation_id: String, fedimint_id: Option, cashu_mint_url: Option, - payment_hash: String, - bolt11: String, + payment_hash: Option, + bolt11: Option, + bolt12_offer: Option, amount_msats: i64, fee_msats: i64, status: i32, @@ -34,8 +35,9 @@ struct NewLightningReceive { operation_id: String, fedimint_id: Option, cashu_mint_url: Option, - payment_hash: String, - bolt11: String, + payment_hash: Option, + bolt11: Option, + bolt12_offer: Option, amount_msats: i64, fee_msats: i64, status: i32, @@ -66,11 +68,17 @@ impl LightningReceive { } pub fn payment_hash(&self) -> [u8; 32] { - FromHex::from_hex(&self.payment_hash).expect("invalid payment hash") + FromHex::from_hex(self.payment_hash.as_ref().expect("missing payment hash")) + .expect("invalid payment hash") } pub fn bolt11(&self) -> Bolt11Invoice { - Bolt11Invoice::from_str(&self.bolt11).expect("invalid bolt11") + Bolt11Invoice::from_str(self.bolt11.as_ref().expect("missing bolt11")) + .expect("invalid bolt11") + } + + pub fn bolt12_offer(&self) -> Option<&str> { + self.bolt12_offer.as_deref() } pub fn amount(&self) -> Amount { @@ -108,8 +116,38 @@ impl LightningReceive { operation_id, fedimint_id: fedimint_id.map(|f| f.to_string()), cashu_mint_url: cashu_mint_url.map(|f| f.to_string()), - payment_hash, - bolt11: bolt11.to_string(), + payment_hash: Some(payment_hash), + bolt11: Some(bolt11.to_string()), + bolt12_offer: None, + amount_msats: amount.msats as i64, + fee_msats: fee.msats as i64, + status: PaymentStatus::Pending as i32, + }; + + diesel::insert_into(lightning_receives::table) + .values(new) + .execute(conn)?; + + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub fn create_bolt12( + conn: &mut SqliteConnection, + operation_id: String, + fedimint_id: Option, + cashu_mint_url: Option, + offer: String, + amount: Amount, + fee: Amount, + ) -> anyhow::Result<()> { + let new = NewLightningReceive { + operation_id, + fedimint_id: fedimint_id.map(|f| f.to_string()), + cashu_mint_url: cashu_mint_url.map(|f| f.to_string()), + payment_hash: None, + bolt11: None, + bolt12_offer: Some(offer), amount_msats: amount.msats as i64, fee_msats: fee.msats as i64, status: PaymentStatus::Pending as i32, diff --git a/harbor-client/src/db_models/schema.rs b/harbor-client/src/db_models/schema.rs index a7706dc8..eb5548b3 100644 --- a/harbor-client/src/db_models/schema.rs +++ b/harbor-client/src/db_models/schema.rs @@ -21,8 +21,9 @@ diesel::table! { operation_id -> Text, fedimint_id -> Nullable, cashu_mint_url -> Nullable, - payment_hash -> Text, - bolt11 -> Text, + payment_hash -> Nullable, + bolt11 -> Nullable, + bolt12_offer -> Nullable, amount_msats -> BigInt, fee_msats -> BigInt, preimage -> Nullable, @@ -37,8 +38,9 @@ diesel::table! { operation_id -> Text, fedimint_id -> Nullable, cashu_mint_url -> Nullable, - payment_hash -> Text, - bolt11 -> Text, + payment_hash -> Nullable, + bolt11 -> Nullable, + bolt12_offer -> Nullable, amount_msats -> BigInt, fee_msats -> BigInt, status -> Integer, diff --git a/harbor-client/src/lib.rs b/harbor-client/src/lib.rs index 47390645..9ffe5eed 100644 --- a/harbor-client/src/lib.rs +++ b/harbor-client/src/lib.rs @@ -867,6 +867,17 @@ impl HarborCore { self.status_update(msg_id, "Creating payment transaction") .await; + // Persist the outgoing bolt12 payment so completion can update it later + let amount = Amount::from_msats(amount_msats.unwrap_or(0)); + self.storage.create_bolt12_payment( + quote.id.clone(), + None, + Some(mint_url.clone()), + offer.clone(), + amount, + Amount::from_msats(quote.fee_reserve.into()), + )?; + spawn_lightning_payment_thread( self.tx.clone(), client, @@ -930,6 +941,17 @@ impl HarborCore { log::info!("Bolt12 offer created: {offer}"); + // Save receive record in DB for bolt12 + let amt = amount.unwrap_or(Amount::ZERO); + self.storage.create_bolt12_receive( + quote.id.clone(), + None, + Some(mint.clone()), + offer.clone(), + amt, + Amount::ZERO, + )?; + // Spawn the bolt12 receive thread to monitor for payment spawn_bolt12_receive_thread( self.tx.clone(), From 68d992c386179a91a3228f7373b7ec8a25f99f75 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Tue, 26 Aug 2025 22:45:58 +0100 Subject: [PATCH 04/13] chore: fmt --- harbor-client/src/db.rs | 2 +- harbor-ui/src/bridge.rs | 11 +++++++++-- harbor-ui/src/routes/receive.rs | 13 +++++++++---- harbor-ui/src/routes/send.rs | 5 ++++- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/harbor-client/src/db.rs b/harbor-client/src/db.rs index e83bfcae..2ed16d5a 100644 --- a/harbor-client/src/db.rs +++ b/harbor-client/src/db.rs @@ -246,7 +246,7 @@ pub trait DBConnection { } pub struct SQLConnection { - db: Pool>, + db: Pool>, } impl DBConnection for SQLConnection { diff --git a/harbor-ui/src/bridge.rs b/harbor-ui/src/bridge.rs index fe2a443a..7bf4e86d 100644 --- a/harbor-ui/src/bridge.rs +++ b/harbor-ui/src/bridge.rs @@ -508,10 +508,17 @@ async fn process_core(core_handle: &mut CoreHandle, core: &HarborCore) { .await; } } - UICoreMsg::SendBolt12 { mint, offer, amount_msats } => { + UICoreMsg::SendBolt12 { + mint, + offer, + amount_msats, + } => { log::info!("Got UICoreMsg::SendBolt12"); core.msg(msg.id, CoreUIMsg::Sending).await; - if let Err(e) = core.send_bolt12(msg.id, mint, offer, amount_msats, false).await { + if let Err(e) = core + .send_bolt12(msg.id, mint, offer, amount_msats, false) + .await + { error!("Error sending bolt12: {e}"); core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) .await; diff --git a/harbor-ui/src/routes/receive.rs b/harbor-ui/src/routes/receive.rs index 3d34c358..b47008d6 100644 --- a/harbor-ui/src/routes/receive.rs +++ b/harbor-ui/src/routes/receive.rs @@ -134,8 +134,8 @@ fn render_bolt12_view(harbor: &HarborWallet) -> Element { }); // Create the "Generate Bolt12 Offer" button. - let generate_offer_button = - h_button("Generate Bolt12 Offer", SvgIcon::Qr, generating).on_press(Message::GenerateBolt12Offer); + let generate_offer_button = h_button("Generate Bolt12 Offer", SvgIcon::Qr, generating) + .on_press(Message::GenerateBolt12Offer); let buttons = if generating { // When generating, include a "Start Over" next to the generate button. @@ -185,7 +185,11 @@ fn render_onchain_view(harbor: &HarborWallet) -> Element { } /// Renders the method selector for enabled payment methods. -fn render_method_choice(harbor: &HarborWallet, on_chain_enabled: bool, bolt12_enabled: bool) -> Element { +fn render_method_choice( + harbor: &HarborWallet, + on_chain_enabled: bool, + bolt12_enabled: bool, +) -> Element { let lightning_choice = radio( "Lightning", ReceiveMethod::Lightning, @@ -208,7 +212,8 @@ fn render_method_choice(harbor: &HarborWallet, on_chain_enabled: bool, bolt12_en ) .text_size(18); - let bolt12_caption = h_caption_text("Reusable offers. Good for donations and recurring payments."); + let bolt12_caption = + h_caption_text("Reusable offers. Good for donations and recurring payments."); let bolt12 = column![bolt12_choice, bolt12_caption].spacing(8); choices.push(bolt12); } diff --git a/harbor-ui/src/routes/send.rs b/harbor-ui/src/routes/send.rs index b5dcaa32..a8759c1e 100644 --- a/harbor-ui/src/routes/send.rs +++ b/harbor-ui/src/routes/send.rs @@ -8,7 +8,10 @@ use crate::components::{ use crate::{HarborWallet, Message, SendStatus}; pub fn send(harbor: &HarborWallet) -> Element { - let header = h_header("Send", "Send to an on-chain address, lightning invoice, or Bolt12 offer."); + let header = h_header( + "Send", + "Send to an on-chain address, lightning invoice, or Bolt12 offer.", + ); let dest_input = h_input(InputArgs { label: "Destination", From 6c922396500db0fb0031bb9ec981aa8e0e37c535 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Wed, 27 Aug 2025 01:04:40 +0100 Subject: [PATCH 05/13] feat: bolt12 multiple payments --- .../2025-08-26-224833_bolt12_payment/down.sql | 2 + .../2025-08-26-224833_bolt12_payment/up.sql | 20 +++ harbor-client/src/cashu_client.rs | 35 +++--- harbor-client/src/db.rs | 43 ++++++- .../src/db_models/lightning_receive.rs | 116 ++++++++++++++++-- harbor-client/src/db_models/mod.rs | 1 + harbor-client/src/db_models/schema.rs | 14 +++ harbor-client/src/fedimint_client.rs | 4 +- harbor-client/src/lib.rs | 2 +- 9 files changed, 204 insertions(+), 33 deletions(-) create mode 100644 harbor-client/migrations/2025-08-26-224833_bolt12_payment/down.sql create mode 100644 harbor-client/migrations/2025-08-26-224833_bolt12_payment/up.sql diff --git a/harbor-client/migrations/2025-08-26-224833_bolt12_payment/down.sql b/harbor-client/migrations/2025-08-26-224833_bolt12_payment/down.sql new file mode 100644 index 00000000..3779280e --- /dev/null +++ b/harbor-client/migrations/2025-08-26-224833_bolt12_payment/down.sql @@ -0,0 +1,2 @@ +-- Undo the bolt12 receive payment table +DROP TABLE IF EXISTS lightning_receive_payments; diff --git a/harbor-client/migrations/2025-08-26-224833_bolt12_payment/up.sql b/harbor-client/migrations/2025-08-26-224833_bolt12_payment/up.sql new file mode 100644 index 00000000..b5f96b87 --- /dev/null +++ b/harbor-client/migrations/2025-08-26-224833_bolt12_payment/up.sql @@ -0,0 +1,20 @@ +-- Add table to store individual payments for Bolt12 receives. +-- Bolt12 offers can be paid multiple times, so we store each successful payment +-- as a separate row linked to the original receive (lightning_receives.operation_id). + +CREATE TABLE IF NOT EXISTS lightning_receive_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + receive_operation_id TEXT NOT NULL REFERENCES lightning_receives(operation_id) ON DELETE CASCADE, + amount_msats BIGINT NOT NULL, + fee_msats BIGINT NOT NULL DEFAULT 0, + payment_hash TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Index to quickly lookup payments for a given receive +CREATE INDEX IF NOT EXISTS idx_lightning_receive_payments_receive_op_id + ON lightning_receive_payments (receive_operation_id); + +-- Index to order by creation time for history queries +CREATE INDEX IF NOT EXISTS idx_lightning_receive_payments_created_at + ON lightning_receive_payments (created_at); diff --git a/harbor-client/src/cashu_client.rs b/harbor-client/src/cashu_client.rs index b496e1a4..6c3b8310 100644 --- a/harbor-client/src/cashu_client.rs +++ b/harbor-client/src/cashu_client.rs @@ -12,8 +12,8 @@ use cdk::nuts::{ CheckStateRequest, CheckStateResponse, Id, KeySet, KeysResponse, KeysetResponse, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltQuoteBolt12Request, MeltRequest, MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, MintQuoteBolt12Request, - MintQuoteBolt12Response, MintQuoteState, MintRequest, MintResponse, RestoreRequest, - RestoreResponse, SwapRequest, SwapResponse, + MintQuoteBolt12Response, MintQuoteState, MintRequest, MintResponse, ProofsMethods, + RestoreRequest, RestoreResponse, SwapRequest, SwapResponse, }; use cdk::util::unix_time; use cdk::wallet::{MeltQuote, MintConnector, MintQuote}; @@ -343,7 +343,7 @@ pub fn spawn_lightning_receive_thread( HarborCore::send_msg(&mut sender, Some(msg_id), CoreUIMsg::ReceiveSuccess(params)) .await; - if let Err(e) = storage.mark_ln_receive_as_success(quote.id) { + if let Err(e) = storage.mark_ln_receive_as_success(quote.id, None) { error!("Could not mark lightning receive as success: {e}"); } @@ -390,9 +390,10 @@ pub fn spawn_bolt12_receive_thread( spawn(async move { let mut error_counter = 0; loop { + let quote_clone = quote.clone(); // For bolt12, we'll check using the regular mint quote state method // The wallet should handle bolt12 quotes the same way as bolt11 quotes - let mint_quote_response = match client.mint_bolt12_quote_state("e.id).await { + let mint_quote_response = match client.mint_bolt12_quote_state("e_clone.id).await { Ok(response) => response, Err(e) => { error!("Error getting mint quote state for bolt12: {e}"); @@ -410,19 +411,25 @@ pub fn spawn_bolt12_receive_thread( mint_quote_response.amount_paid - mint_quote_response.amount_issued; if amount_mintable > 0.into() { - log::info!("Bolt12 quote {} has been paid, minting tokens", quote.id); + log::info!( + "Bolt12 quote {} has been paid, minting tokens", + quote_clone.id + ); match client .mint_bolt12( - "e.id, + "e_clone.id, Some(amount_mintable), SplitTarget::default(), None, ) .await { - Ok(_) => { - log::info!("Successfully minted tokens for bolt12 quote {}", quote.id); + Ok(proofs) => { + log::info!( + "Successfully minted tokens for bolt12 quote {}", + quote_clone.id + ); let params = if is_transfer { ReceiveSuccessMsg::Transfer @@ -436,10 +443,10 @@ pub fn spawn_bolt12_receive_thread( ) .await; - // Note: For now we're using the bolt11 database methods since bolt12 quotes - // are compatible with the same structure. In a future version, the database - // schema should be updated to properly handle bolt12 quotes. - if let Err(e) = storage.mark_ln_receive_as_success(quote.id) { + if let Err(e) = storage.mark_ln_receive_as_success( + quote_clone.id, + proofs.total_amount().ok().map(|a| u64::from(a) * 1_000), + ) { error!("Could not mark bolt12 receive as success: {e}"); } @@ -455,9 +462,7 @@ pub fn spawn_bolt12_receive_thread( ) .await; - update_history(storage, msg_id, &mut sender).await; - - break; + update_history(Arc::clone(&storage), msg_id, &mut sender).await; } Err(e) => { error!( diff --git a/harbor-client/src/db.rs b/harbor-client/src/db.rs index 2ed16d5a..b411d60e 100644 --- a/harbor-client/src/db.rs +++ b/harbor-client/src/db.rs @@ -1,7 +1,10 @@ #![allow(clippy::too_many_arguments)] +use crate::db_models::PaymentStatus; use crate::db_models::mint_metadata::MintMetadata; use crate::db_models::transaction_item::TransactionItem; +use crate::db_models::transaction_item::{TransactionDirection, TransactionItemKind}; + use crate::db_models::{ CashuMint, Fedimint, LightningPayment, LightningReceive, NewFedimint, NewProfile, OnChainPayment, OnChainReceive, Profile, @@ -145,7 +148,11 @@ pub trait DBConnection { fee: Amount, ) -> anyhow::Result<()>; - fn mark_ln_receive_as_success(&self, operation_id: String) -> anyhow::Result<()>; + fn mark_ln_receive_as_success( + &self, + operation_id: String, + amount_msats: Option, + ) -> anyhow::Result<()>; fn mark_ln_receive_as_failed(&self, operation_id: String) -> anyhow::Result<()>; @@ -362,10 +369,14 @@ impl DBConnection for SQLConnection { Ok(()) } - fn mark_ln_receive_as_success(&self, operation_id: String) -> anyhow::Result<()> { + fn mark_ln_receive_as_success( + &self, + operation_id: String, + amount_msats: Option, + ) -> anyhow::Result<()> { let conn = &mut self.db.get()?; - LightningReceive::mark_as_success(conn, operation_id)?; + LightningReceive::mark_as_success(conn, operation_id, amount_msats)?; Ok(()) } @@ -536,6 +547,8 @@ impl DBConnection for SQLConnection { let onchain_receives = OnChainReceive::get_history(conn)?; let lightning_payments = LightningPayment::get_history(conn)?; let lightning_receives = LightningReceive::get_history(conn)?; + // Also include bolt12 individual payments (each payment for a bolt12 receive) + let bolt12_payments = LightningReceive::get_bolt12_payments_history(conn)?; let mut items: Vec = Vec::with_capacity( onchain_payments.len() @@ -557,10 +570,30 @@ impl DBConnection for SQLConnection { } for lightning_receive in lightning_receives { - items.push(lightning_receive.into()); + if lightning_receive.bolt12_offer().is_none() { + items.push(lightning_receive.into()); + } + } + + // Convert bolt12 joined results into transaction items + for (payment, receive) in bolt12_payments { + // Build transaction item from payment + parent receive + let item = TransactionItem { + kind: TransactionItemKind::Lightning, + amount: fedimint_core::Amount::from_msats(payment.amount_msats as u64) + .sats_round_down(), + fee_msats: payment.fee_msats as u64, + txid: None, + preimage: None, + direction: TransactionDirection::Incoming, + mint_identifier: receive.mint_identifier(), + status: PaymentStatus::Success, + timestamp: payment.created_at.and_utc().timestamp() as u64, + }; + + items.push(item); } - // sort by timestamp so that the most recent items are at the top items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); Ok(items) diff --git a/harbor-client/src/db_models/lightning_receive.rs b/harbor-client/src/db_models/lightning_receive.rs index b474def4..af86eb5b 100644 --- a/harbor-client/src/db_models/lightning_receive.rs +++ b/harbor-client/src/db_models/lightning_receive.rs @@ -1,6 +1,6 @@ use crate::MintIdentifier; use crate::db_models::PaymentStatus; -use crate::db_models::schema::lightning_receives; +use crate::db_models::schema::{lightning_receive_payments, lightning_receives}; use crate::db_models::transaction_item::{ TransactionDirection, TransactionItem, TransactionItemKind, }; @@ -43,6 +43,26 @@ struct NewLightningReceive { status: i32, } +#[derive(Queryable, Insertable, Debug, Clone, PartialEq, Eq)] +#[diesel(table_name = lightning_receive_payments)] +pub struct LightningReceivePayment { + pub id: i32, + pub receive_operation_id: String, + pub amount_msats: i64, + pub fee_msats: i64, + pub payment_hash: Option, + pub created_at: chrono::NaiveDateTime, +} + +#[derive(Insertable, Clone)] +#[diesel(table_name = lightning_receive_payments)] +struct NewLightningReceivePayment { + pub receive_operation_id: String, + pub amount_msats: i64, + pub fee_msats: i64, + pub payment_hash: Option, +} + impl LightningReceive { pub fn operation_id(&self) -> OperationId { OperationId::from_str(&self.operation_id).expect("invalid operation id") @@ -173,12 +193,48 @@ impl LightningReceive { pub fn mark_as_success( conn: &mut SqliteConnection, operation_id: String, + amount_msats: Option, ) -> anyhow::Result<()> { - diesel::update( - lightning_receives::table.filter(lightning_receives::operation_id.eq(operation_id)), - ) - .set(lightning_receives::status.eq(PaymentStatus::Success as i32)) - .execute(conn)?; + use crate::db_models::schema::lightning_receives::dsl as lr; + + // fetch the existing receive record + let existing: Option = lr::lightning_receives + .filter(lr::operation_id.eq(&operation_id)) + .order(lr::updated_at.desc()) + .first::(conn) + .optional()?; + + if let Some(rec) = existing { + if rec.bolt12_offer.is_some() { + let new_amount = amount_msats.map(|a| a as i64).unwrap_or(rec.amount_msats); + + let new_payment = NewLightningReceivePayment { + receive_operation_id: rec.operation_id.clone(), + amount_msats: new_amount, + fee_msats: rec.fee_msats, + payment_hash: rec.payment_hash.clone(), + }; + + diesel::insert_into(lightning_receive_payments::table) + .values(new_payment) + .execute(conn)?; + + // Update the receive summary row to Success so it appears in history and update timestamp + diesel::update( + lr::lightning_receives.filter(lr::operation_id.eq(rec.operation_id.clone())), + ) + .set(lr::status.eq(PaymentStatus::Success as i32)) + .execute(conn)?; + } else { + // For bolt11 invoices update the existing row to success + diesel::update( + lightning_receives::table + .filter(lightning_receives::operation_id.eq(operation_id)), + ) + .set(lightning_receives::status.eq(PaymentStatus::Success as i32)) + .execute(conn)?; + } + } Ok(()) } @@ -201,12 +257,52 @@ impl LightningReceive { pub fn get_pending(conn: &mut SqliteConnection) -> anyhow::Result> { Ok(lightning_receives::table - .filter(lightning_receives::status.eq_any([ - PaymentStatus::Pending as i32, - PaymentStatus::WaitingConfirmation as i32, - ])) + .filter( + lightning_receives::status + .eq_any([ + PaymentStatus::Pending as i32, + PaymentStatus::WaitingConfirmation as i32, + ]) + .or(lightning_receives::bolt12_offer.is_not_null()), + ) .load::(conn)?) } + + pub fn get_bolt12_payments_history( + conn: &mut SqliteConnection, + ) -> anyhow::Result> { + use crate::db_models::schema::lightning_receive_payments::dsl as lrp; + use crate::db_models::schema::lightning_receives::dsl as lr; + + let results = lrp::lightning_receive_payments + .inner_join(lr::lightning_receives.on(lrp::receive_operation_id.eq(lr::operation_id))) + .select(( + ( + lrp::id, + lrp::receive_operation_id, + lrp::amount_msats, + lrp::fee_msats, + lrp::payment_hash, + lrp::created_at, + ), + ( + lr::operation_id, + lr::fedimint_id, + lr::cashu_mint_url, + lr::payment_hash, + lr::bolt11, + lr::bolt12_offer, + lr::amount_msats, + lr::fee_msats, + lr::status, + lr::created_at, + lr::updated_at, + ), + )) + .load::<(LightningReceivePayment, LightningReceive)>(conn)?; + + Ok(results) + } } impl From for TransactionItem { diff --git a/harbor-client/src/db_models/mod.rs b/harbor-client/src/db_models/mod.rs index 5252a6c5..80d3fb07 100644 --- a/harbor-client/src/db_models/mod.rs +++ b/harbor-client/src/db_models/mod.rs @@ -1,4 +1,5 @@ pub mod profile; + pub use profile::*; pub mod fedimint; diff --git a/harbor-client/src/db_models/schema.rs b/harbor-client/src/db_models/schema.rs index eb5548b3..4384a620 100644 --- a/harbor-client/src/db_models/schema.rs +++ b/harbor-client/src/db_models/schema.rs @@ -1,4 +1,15 @@ // @generated automatically by Diesel CLI. +diesel::table! { + lightning_receive_payments (id) { + id -> Integer, + receive_operation_id -> Text, + amount_msats -> BigInt, + fee_msats -> BigInt, + payment_hash -> Nullable, + created_at -> Timestamp, + } +} + diesel::table! { cashu_mint (mint_url) { @@ -106,6 +117,7 @@ diesel::joinable!(lightning_payments -> cashu_mint (cashu_mint_url)); diesel::joinable!(lightning_payments -> fedimint (fedimint_id)); diesel::joinable!(lightning_receives -> cashu_mint (cashu_mint_url)); diesel::joinable!(lightning_receives -> fedimint (fedimint_id)); +diesel::joinable!(lightning_receive_payments -> lightning_receives (receive_operation_id)); diesel::joinable!(on_chain_payments -> cashu_mint (cashu_mint_url)); diesel::joinable!(on_chain_payments -> fedimint (fedimint_id)); diesel::joinable!(on_chain_receives -> cashu_mint (cashu_mint_url)); @@ -116,8 +128,10 @@ diesel::allow_tables_to_appear_in_same_query!( fedimint, lightning_payments, lightning_receives, + lightning_receive_payments, mint_metadata, on_chain_payments, on_chain_receives, profile, ); + diff --git a/harbor-client/src/fedimint_client.rs b/harbor-client/src/fedimint_client.rs index fc321c31..05037bb2 100644 --- a/harbor-client/src/fedimint_client.rs +++ b/harbor-client/src/fedimint_client.rs @@ -423,7 +423,7 @@ pub(crate) async fn spawn_invoice_receive_subscription( .await; if let Err(e) = - storage.mark_ln_receive_as_success(operation_id.fmt_full().to_string()) + storage.mark_ln_receive_as_success(operation_id.fmt_full().to_string(), None) { error!("Could not mark lightning receive as success: {e}"); } @@ -486,7 +486,7 @@ pub(crate) async fn spawn_lnv2_receive_subscription( .await; if let Err(e) = - storage.mark_ln_receive_as_success(operation_id.fmt_full().to_string()) + storage.mark_ln_receive_as_success(operation_id.fmt_full().to_string(), None) { error!("Could not mark lightning receive as success: {e}"); } diff --git a/harbor-client/src/lib.rs b/harbor-client/src/lib.rs index 9ffe5eed..9c0af39c 100644 --- a/harbor-client/src/lib.rs +++ b/harbor-client/src/lib.rs @@ -384,7 +384,7 @@ impl HarborCore { client.localstore.get_mint_quote(&item.operation_id).await { // Check if this might be a bolt12 quote by examining the request - if quote.request.starts_with("lno") { + if quote.payment_method == cdk::nuts::PaymentMethod::Bolt12 { // This is likely a bolt12 quote spawn_bolt12_receive_thread( tx.clone(), From 4df640dd5207d64e7bd17a13c3ebda0ca58b7762 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Wed, 27 Aug 2025 11:25:14 +0100 Subject: [PATCH 06/13] feat: pay bip353 --- Cargo.lock | 76 ++++++++++++++++++++++++- harbor-client/Cargo.toml | 4 +- harbor-client/src/cashu_client.rs | 4 +- harbor-client/src/lib.rs | 93 ++++++++++++++++++++++++++++++- harbor-ui/src/bridge.rs | 44 ++++++++++----- harbor-ui/src/main.rs | 24 ++++++++ harbor-ui/src/routes/send.rs | 4 +- 7 files changed, 225 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 09fa9580..3631174e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1248,6 +1248,7 @@ dependencies = [ "tokio-tungstenite 0.26.2", "tokio-util", "tracing", + "trust-dns-resolver", "url", "uuid", "zeroize", @@ -4242,7 +4243,7 @@ dependencies = [ "futures-channel", "futures-io", "futures-util", - "idna", + "idna 1.0.3", "ipnet", "once_cell", "rand 0.9.0", @@ -4826,6 +4827,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "1.0.3" @@ -5686,6 +5697,12 @@ dependencies = [ "web-time", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-keyutils" version = "0.2.4" @@ -5802,6 +5819,15 @@ dependencies = [ "hashbrown 0.15.2", ] +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "lyon" version = "1.0.1" @@ -10552,6 +10578,52 @@ dependencies = [ "syn 2.0.95", ] +[[package]] +name = "trust-dns-proto" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3119112651c157f4488931a01e586aa459736e9d6046d3bd9105ffb69352d374" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna 0.4.0", + "ipnet", + "once_cell", + "rand 0.8.5", + "smallvec", + "thiserror 1.0.69", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "trust-dns-resolver" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a3e6c3aff1718b3c73e395d1f35202ba2ffa847c6a62eea0db8fb4cfe30be6" +dependencies = [ + "cfg-if", + "futures-util", + "ipconfig", + "lru-cache", + "once_cell", + "parking_lot 0.12.3", + "rand 0.8.5", + "resolv-conf", + "smallvec", + "thiserror 1.0.69", + "tokio", + "tracing", + "trust-dns-proto", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -10794,7 +10866,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", - "idna", + "idna 1.0.3", "percent-encoding", "serde", ] diff --git a/harbor-client/Cargo.toml b/harbor-client/Cargo.toml index 418b3804..73a8fad5 100644 --- a/harbor-client/Cargo.toml +++ b/harbor-client/Cargo.toml @@ -28,10 +28,10 @@ once_cell = "1.20.2" httparse = "1.8.0" url = "2.5.0" -cdk = { version = "0.12.0", default-features = false, features = ["wallet"] } +cdk = { version = "0.12.0", default-features = false, features = ["wallet", "bip353"] } cdk-sqlite = { version = "0.12.0", default-features = false, features = ["wallet", "sqlcipher"] } -# cdk = { git = "https://github.com/thesimplekid/cdk", branch = "bip353_examples", default-features = false, features = ["wallet"] } +# cdk = { git = "https://github.com/thesimplekid/cdk", branch = "bip353_examples", default-features = false, features = ["wallet", "bip353"] } # cdk-sqlite = { git = "https://github.com/thesimplekid/cdk/", branch = "bip353_examples", default-features = false, features = ["wallet", "sqlcipher"] } diff --git a/harbor-client/src/cashu_client.rs b/harbor-client/src/cashu_client.rs index 6c3b8310..8d48d2e8 100644 --- a/harbor-client/src/cashu_client.rs +++ b/harbor-client/src/cashu_client.rs @@ -253,8 +253,8 @@ pub fn spawn_lightning_payment_thread( quote.id, outgoing.preimage ); - let preimage: [u8; 32] = FromHex::from_hex(&outgoing.preimage.unwrap()) - .expect("preimage must be valid hex"); + let preimage: [u8; 32] = FromHex::from_hex(&outgoing.preimage.unwrap_or_default()) + .unwrap_or_else(|_| [0u8; 32]); let params = if is_transfer { SendSuccessMsg::Transfer } else { diff --git a/harbor-client/src/lib.rs b/harbor-client/src/lib.rs index 9c0af39c..3794caba 100644 --- a/harbor-client/src/lib.rs +++ b/harbor-client/src/lib.rs @@ -148,12 +148,18 @@ pub enum UICoreMsg { ReceiveBolt12 { mint: MintIdentifier, amount: Option, + // NOTE: The following block was accidentally duplicated during merge. Removing to fix mismatched delimiters. }, SendLnurlPay { mint: MintIdentifier, lnurl: LnUrl, amount_sats: u64, }, + SendBip353 { + mint: MintIdentifier, + address: String, + amount_sats: u64, + }, ReceiveLightning { mint: MintIdentifier, amount: Amount, @@ -868,14 +874,14 @@ impl HarborCore { .await; // Persist the outgoing bolt12 payment so completion can update it later - let amount = Amount::from_msats(amount_msats.unwrap_or(0)); + let amount = Amount::from_sats(quote.amount.into()); self.storage.create_bolt12_payment( quote.id.clone(), None, Some(mint_url.clone()), offer.clone(), amount, - Amount::from_msats(quote.fee_reserve.into()), + Amount::from_sats(quote.fee_reserve.into()), )?; spawn_lightning_payment_thread( @@ -1014,6 +1020,89 @@ impl HarborCore { Ok(()) } + pub async fn send_bip353( + &self, + msg_id: Uuid, + from: MintIdentifier, + address: String, + amount_sats: u64, + is_transfer: bool, + ) -> anyhow::Result<()> { + self.status_update(msg_id, "Preparing to send BIP-353 payment") + .await; + + match from { + MintIdentifier::Cashu(mint_url) => { + self.send_bip353_from_cashu(msg_id, mint_url, address, amount_sats, is_transfer) + .await + } + MintIdentifier::Fedimint(_id) => { + Err(anyhow!("BIP-353 payments are not supported on Fedimint")) + } + } + } + + pub async fn send_bip353_from_cashu( + &self, + msg_id: Uuid, + mint_url: MintUrl, + address: String, + amount_sats: u64, + is_transfer: bool, + ) -> anyhow::Result<()> { + log::info!( + "Paying BIP-353 address: {} from cashu mint: {}", + address, + mint_url + ); + + let client = self.get_cashu_client(&mint_url).await; + + self.status_update(msg_id, "Resolving address and getting quote") + .await; + + // BIP353 expects msats, convert sats->msats + let amount_msats = amount_sats + .checked_mul(1_000) + .ok_or_else(|| anyhow!("amount overflow"))?; + + // Use CDK's BIP-353 melt flow to get a quote + let quote = client + .melt_bip353_quote(&address, cdk::Amount::from(amount_msats)) + .await?; + + log::info!("Sending BIP-353 payment"); + + self.status_update(msg_id, "Creating payment transaction") + .await; + + // Persist the outgoing payment, re-using bolt12 columns with address stored in offer column + self.storage.create_bolt12_payment( + quote.id.clone(), + None, + Some(mint_url.clone()), + address.clone(), + Amount::from_sats(amount_sats), + Amount::from_msats(quote.fee_reserve.into()), + )?; + + spawn_lightning_payment_thread( + self.tx.clone(), + client, + self.storage.clone(), + quote, + msg_id, + is_transfer, + ); + + self.status_update(msg_id, "Waiting for payment confirmation") + .await; + + log::info!("BIP-353 payment sent"); + + Ok(()) + } + async fn receive_lnv2( &self, client: &ClientHandleArc, diff --git a/harbor-ui/src/bridge.rs b/harbor-ui/src/bridge.rs index 7bf4e86d..a6bf28ef 100644 --- a/harbor-ui/src/bridge.rs +++ b/harbor-ui/src/bridge.rs @@ -550,34 +550,30 @@ async fn process_core(core_handle: &mut CoreHandle, core: &HarborCore) { } } } - UICoreMsg::SendLnurlPay { + UICoreMsg::SendBip353 { mint, - lnurl, + address, amount_sats, } => { - log::info!("Got UICoreMsg::SendLnurlPay"); + log::info!("Got UICoreMsg::SendBip353"); core.msg(msg.id, CoreUIMsg::Sending).await; - if let Err(e) = core.send_lnurl_pay(msg.id, mint, lnurl, amount_sats).await + if let Err(e) = core + .send_bip353(msg.id, mint, address, amount_sats, false) + .await { error!("Error sending: {e}"); core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) .await; } } - UICoreMsg::SendOnChain { + UICoreMsg::SendLnurlPay { mint, - address, + lnurl, amount_sats, } => { - log::info!("Got UICoreMsg::SendOnChain"); + log::info!("Got UICoreMsg::SendLnurlPay"); core.msg(msg.id, CoreUIMsg::Sending).await; - let federation_id = match mint { - MintIdentifier::Cashu(_) => panic!("should not receive cashu"), // todo - MintIdentifier::Fedimint(mint) => mint, - }; - if let Err(e) = core - .send_onchain(msg.id, federation_id, address, amount_sats) - .await + if let Err(e) = core.send_lnurl_pay(msg.id, mint, lnurl, amount_sats).await { error!("Error sending: {e}"); core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) @@ -602,6 +598,26 @@ async fn process_core(core_handle: &mut CoreHandle, core: &HarborCore) { } } } + UICoreMsg::SendOnChain { + mint, + address, + amount_sats, + } => { + log::info!("Got UICoreMsg::SendOnChain"); + core.msg(msg.id, CoreUIMsg::Sending).await; + let federation_id = match mint { + MintIdentifier::Cashu(_) => panic!("should not receive cashu"), // todo + MintIdentifier::Fedimint(mint) => mint, + }; + if let Err(e) = core + .send_onchain(msg.id, federation_id, address, amount_sats) + .await + { + error!("Error sending: {e}"); + core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) + .await; + } + } UICoreMsg::Transfer { to, from, amount } => { if let Err(e) = core.transfer(msg.id, to, from, amount).await { error!("Error transferring: {e}"); diff --git a/harbor-ui/src/main.rs b/harbor-ui/src/main.rs index 98bf6005..99240af8 100644 --- a/harbor-ui/src/main.rs +++ b/harbor-ui/src/main.rs @@ -670,6 +670,30 @@ impl HarborWallet { }); self.current_send_id = Some(id); task + } else if invoice_str.contains('@') { + // BIP353 address + if self.is_max { + return Task::done(Message::AddToast(Toast { + title: "Cannot send max with BIP-353 address".to_string(), + body: Some("Please enter a specific amount".to_string()), + status: ToastStatus::Bad, + })); + } + let amount_sats = match self.send_amount_input_str.parse::() { + Ok(amount) => amount, + Err(e) => { + error!("Error parsing amount: {e}"); + self.send_failure_reason = Some(e.to_string()); + return Task::none(); + } + }; + let (id, task) = self.send_from_ui(UICoreMsg::SendBip353 { + mint, + address: invoice_str, + amount_sats, + }); + self.current_send_id = Some(id); + task } else { match parse_lnurl(&invoice_str) { Ok(lnurl) => { diff --git a/harbor-ui/src/routes/send.rs b/harbor-ui/src/routes/send.rs index a8759c1e..21d76b83 100644 --- a/harbor-ui/src/routes/send.rs +++ b/harbor-ui/src/routes/send.rs @@ -10,12 +10,12 @@ use crate::{HarborWallet, Message, SendStatus}; pub fn send(harbor: &HarborWallet) -> Element { let header = h_header( "Send", - "Send to an on-chain address, lightning invoice, or Bolt12 offer.", + "Send to an on-chain address, lightning invoice, Bolt12 offer, or BIP353 address.", ); let dest_input = h_input(InputArgs { label: "Destination", - placeholder: "lnbc1... or lno... or bc1...", + placeholder: "lnbc1... or lno... or â‚¿user@example.com or bc1...", value: &harbor.send_dest_input_str, on_input: Message::SendDestInputChanged, ..InputArgs::default() From 5a254a51b26a4b1b132adfc8e43d81df9078b316 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Wed, 27 Aug 2025 11:26:15 +0100 Subject: [PATCH 07/13] chore: clippy and fmt --- harbor-client/Cargo.toml | 4 ---- harbor-client/src/cashu_client.rs | 4 ++-- harbor-client/src/db_models/schema.rs | 2 -- harbor-client/src/fedimint_client.rs | 8 ++++---- harbor-ui/src/routes/receive.rs | 4 +--- 5 files changed, 7 insertions(+), 15 deletions(-) diff --git a/harbor-client/Cargo.toml b/harbor-client/Cargo.toml index 73a8fad5..663b23ff 100644 --- a/harbor-client/Cargo.toml +++ b/harbor-client/Cargo.toml @@ -31,10 +31,6 @@ url = "2.5.0" cdk = { version = "0.12.0", default-features = false, features = ["wallet", "bip353"] } cdk-sqlite = { version = "0.12.0", default-features = false, features = ["wallet", "sqlcipher"] } -# cdk = { git = "https://github.com/thesimplekid/cdk", branch = "bip353_examples", default-features = false, features = ["wallet", "bip353"] } -# cdk-sqlite = { git = "https://github.com/thesimplekid/cdk/", branch = "bip353_examples", default-features = false, features = ["wallet", "sqlcipher"] } - - bitcoin = { version = "0.32.4", features = ["base64"] } bip39 = "2.0.0" diff --git a/harbor-client/src/cashu_client.rs b/harbor-client/src/cashu_client.rs index 8d48d2e8..024eb644 100644 --- a/harbor-client/src/cashu_client.rs +++ b/harbor-client/src/cashu_client.rs @@ -253,8 +253,8 @@ pub fn spawn_lightning_payment_thread( quote.id, outgoing.preimage ); - let preimage: [u8; 32] = FromHex::from_hex(&outgoing.preimage.unwrap_or_default()) - .unwrap_or_else(|_| [0u8; 32]); + let preimage: [u8; 32] = + FromHex::from_hex(&outgoing.preimage.unwrap_or_default()).unwrap_or([0u8; 32]); let params = if is_transfer { SendSuccessMsg::Transfer } else { diff --git a/harbor-client/src/db_models/schema.rs b/harbor-client/src/db_models/schema.rs index 4384a620..5336868b 100644 --- a/harbor-client/src/db_models/schema.rs +++ b/harbor-client/src/db_models/schema.rs @@ -10,7 +10,6 @@ diesel::table! { } } - diesel::table! { cashu_mint (mint_url) { mint_url -> Text, @@ -134,4 +133,3 @@ diesel::allow_tables_to_appear_in_same_query!( on_chain_receives, profile, ); - diff --git a/harbor-client/src/fedimint_client.rs b/harbor-client/src/fedimint_client.rs index 05037bb2..9e409586 100644 --- a/harbor-client/src/fedimint_client.rs +++ b/harbor-client/src/fedimint_client.rs @@ -422,8 +422,8 @@ pub(crate) async fn spawn_invoice_receive_subscription( ) .await; - if let Err(e) = - storage.mark_ln_receive_as_success(operation_id.fmt_full().to_string(), None) + if let Err(e) = storage + .mark_ln_receive_as_success(operation_id.fmt_full().to_string(), None) { error!("Could not mark lightning receive as success: {e}"); } @@ -485,8 +485,8 @@ pub(crate) async fn spawn_lnv2_receive_subscription( ) .await; - if let Err(e) = - storage.mark_ln_receive_as_success(operation_id.fmt_full().to_string(), None) + if let Err(e) = storage + .mark_ln_receive_as_success(operation_id.fmt_full().to_string(), None) { error!("Could not mark lightning receive as success: {e}"); } diff --git a/harbor-ui/src/routes/receive.rs b/harbor-ui/src/routes/receive.rs index b47008d6..10823d3c 100644 --- a/harbor-ui/src/routes/receive.rs +++ b/harbor-ui/src/routes/receive.rs @@ -15,10 +15,8 @@ pub fn receive(harbor: &HarborWallet) -> Element { Some(invoice.to_string()) } else if let Some(address) = &harbor.receive_address { Some(address.to_string()) - } else if let Some(offer) = &harbor.receive_bolt12_offer { - Some(offer.clone()) } else { - None + harbor.receive_bolt12_offer.clone() }; if let Some(receive_string) = receive_string { From c8a26d23ae6ad1781e560764c65d436b1d459818 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Thu, 4 Sep 2025 12:34:01 +0100 Subject: [PATCH 08/13] feat: code review --- Cargo.lock | 1 + harbor-client/src/db.rs | 1 + .../src/db_models/lightning_payment.rs | 14 ++-- .../src/db_models/lightning_receive.rs | 14 ++-- harbor-client/src/lib.rs | 1 - harbor-ui/Cargo.toml | 3 +- harbor-ui/src/main.rs | 69 +++++++++++++++++-- harbor-ui/src/routes/send.rs | 2 +- 8 files changed, 84 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3631174e..fa47367c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4102,6 +4102,7 @@ dependencies = [ "harbor-client", "iced", "keyring-lib", + "lightning 0.1.5", "log", "lyon_algorithms", "opener", diff --git a/harbor-client/src/db.rs b/harbor-client/src/db.rs index b411d60e..cbe19c80 100644 --- a/harbor-client/src/db.rs +++ b/harbor-client/src/db.rs @@ -594,6 +594,7 @@ impl DBConnection for SQLConnection { items.push(item); } + // sort by timestamp so that the most recent items are at the top items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); Ok(items) diff --git a/harbor-client/src/db_models/lightning_payment.rs b/harbor-client/src/db_models/lightning_payment.rs index 5ea174ec..66d1f3a3 100644 --- a/harbor-client/src/db_models/lightning_payment.rs +++ b/harbor-client/src/db_models/lightning_payment.rs @@ -68,14 +68,16 @@ impl LightningPayment { } } - pub fn payment_hash(&self) -> [u8; 32] { - FromHex::from_hex(self.payment_hash.as_ref().expect("missing payment hash")) - .expect("invalid payment hash") + pub fn payment_hash(&self) -> Option<[u8; 32]> { + self.payment_hash + .as_ref() + .map(|h| FromHex::from_hex(h).expect("invalid payment hash")) } - pub fn bolt11(&self) -> Bolt11Invoice { - Bolt11Invoice::from_str(self.bolt11.as_ref().expect("missing bolt11")) - .expect("invalid bolt11") + pub fn bolt11(&self) -> Option { + self.bolt11 + .as_ref() + .map(|b| Bolt11Invoice::from_str(b).expect("invalid bolt11")) } pub fn bolt12_offer(&self) -> Option<&str> { diff --git a/harbor-client/src/db_models/lightning_receive.rs b/harbor-client/src/db_models/lightning_receive.rs index af86eb5b..ecfe9ce7 100644 --- a/harbor-client/src/db_models/lightning_receive.rs +++ b/harbor-client/src/db_models/lightning_receive.rs @@ -87,14 +87,16 @@ impl LightningReceive { } } - pub fn payment_hash(&self) -> [u8; 32] { - FromHex::from_hex(self.payment_hash.as_ref().expect("missing payment hash")) - .expect("invalid payment hash") + pub fn payment_hash(&self) -> Option<[u8; 32]> { + self.payment_hash + .as_ref() + .map(|h| FromHex::from_hex(h).expect("invalid payment hash")) } - pub fn bolt11(&self) -> Bolt11Invoice { - Bolt11Invoice::from_str(self.bolt11.as_ref().expect("missing bolt11")) - .expect("invalid bolt11") + pub fn bolt11(&self) -> Option { + self.bolt11 + .as_ref() + .map(|b| Bolt11Invoice::from_str(b).expect("invalid bolt11")) } pub fn bolt12_offer(&self) -> Option<&str> { diff --git a/harbor-client/src/lib.rs b/harbor-client/src/lib.rs index 3794caba..ebce2468 100644 --- a/harbor-client/src/lib.rs +++ b/harbor-client/src/lib.rs @@ -148,7 +148,6 @@ pub enum UICoreMsg { ReceiveBolt12 { mint: MintIdentifier, amount: Option, - // NOTE: The following block was accidentally duplicated during merge. Removing to fix mismatched delimiters. }, SendLnurlPay { mint: MintIdentifier, diff --git a/harbor-ui/Cargo.toml b/harbor-ui/Cargo.toml index 4f730f50..5e2aca2d 100644 --- a/harbor-ui/Cargo.toml +++ b/harbor-ui/Cargo.toml @@ -12,6 +12,7 @@ harbor-client = { version = "1.0.0", path = "../harbor-client" } fd-lock = "4.0.2" log = { workspace = true } +lightning = "0.1.5" simplelog = "0.12" iced = { git = "https://github.com/iced-rs/iced", rev = "940a079", features = ["debug", "tokio", "svg", "qr_code", "advanced"] } lyon_algorithms = "1.0" @@ -23,4 +24,4 @@ uuid = { workspace = true } opener = { version = "0.7.2", features = ["reveal"] } serde = { workspace = true } serde_json = { workspace = true } -keyring-lib = "1.0.2" \ No newline at end of file +keyring-lib = "1.0.2" diff --git a/harbor-ui/src/main.rs b/harbor-ui/src/main.rs index 99240af8..7b8c5dbb 100644 --- a/harbor-ui/src/main.rs +++ b/harbor-ui/src/main.rs @@ -55,6 +55,7 @@ use iced::widget::qr_code::Data; use iced::widget::row; use iced::{Color, clipboard}; use iced::{Element, window}; +use lightning::offers::offer::Offer; use log::{debug, error, info, trace}; use routes::Route; use std::collections::HashMap; @@ -640,27 +641,67 @@ impl HarborWallet { self.send_from_ui(UICoreMsg::SendLightning { mint, invoice }); self.current_send_id = Some(id); task - } else if invoice_str.starts_with("lno") { - // This looks like a Bolt12 offer + } else if let Ok(offer) = Offer::from_str(&invoice_str) { + let offer_amount_sats = match offer_amount_sats(&offer) { + Ok(amount) => amount, + Err(e) => { + error!("Error parsing amount: {e}"); + self.send_failure_reason = Some(e.to_string()); + return Task::none(); + } + }; + + // Handle BOLT12 offer payments let amount_msats = if self.is_max { return Task::perform(async {}, |_| { Message::AddToast(Toast { - title: "Cannot send max with Bolt12 offer".to_string(), + title: "Cannot send max with BOLT12 offer".to_string(), body: Some("Please enter a specific amount".to_string()), status: ToastStatus::Bad, }) }); } else if !self.send_amount_input_str.is_empty() { match self.send_amount_input_str.parse::() { - Ok(amount) => Some(amount * 1000), // Convert sats to msats + Ok(amount_sats) => { + // Verify fixed amount matches user input + if let Some(offer_fixed_amount) = offer_amount_sats { + if offer_fixed_amount != amount_sats { + error!( + "Offer amount mismatch: expected {offer_fixed_amount} sats" + ); + self.send_failure_reason = Some(format!( + "Offer amount must be {offer_fixed_amount} sats" + )); + return Task::none(); + } + + None + } else { + Some(amount_sats * 1000) // Convert to msats + } + } Err(e) => { - error!("Error parsing amount: {e}"); + error!("Invalid amount format: {e}"); self.send_failure_reason = Some(e.to_string()); return Task::none(); } } } else { - None // Amount-less Bolt12 offer + // Amount required for all offer types + if let Some(_offer_amount) = offer_amount_sats { + None + } else { + error!("Amount-less offer requires amount input"); + self.send_failure_reason = + Some("Enter an amount for this type of offer".to_string()); + return Task::perform(async {}, |_| { + Message::AddToast(Toast { + title: "Amountless offer".to_string(), + body: Some("Please enter a specific amount".to_string()), + status: ToastStatus::Bad, + }) + }); + } }; let (id, task) = self.send_from_ui(UICoreMsg::SendBolt12 { @@ -1522,3 +1563,19 @@ impl HarborWallet { ) } } + +fn offer_amount_sats(offer: &Offer) -> Result, String> { + // Check if the offer has an amount constraint and validate it + if let Some(offer_amount) = offer.amount() { + match offer_amount { + lightning::offers::offer::Amount::Bitcoin { amount_msats } => { + Ok(Some(amount_msats / 1_000)) + } + lightning::offers::offer::Amount::Currency { .. } => { + return Err("Currency offers are not supported".to_string()); + } + } + } else { + Ok(None) + } +} diff --git a/harbor-ui/src/routes/send.rs b/harbor-ui/src/routes/send.rs index 21d76b83..3b5f639c 100644 --- a/harbor-ui/src/routes/send.rs +++ b/harbor-ui/src/routes/send.rs @@ -15,7 +15,7 @@ pub fn send(harbor: &HarborWallet) -> Element { let dest_input = h_input(InputArgs { label: "Destination", - placeholder: "lnbc1... or lno... or â‚¿user@example.com or bc1...", + placeholder: "lnbc1...", value: &harbor.send_dest_input_str, on_input: Message::SendDestInputChanged, ..InputArgs::default() From ebcce4e9f85da32a7c1de67ceb41dcf8aaacc520 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Thu, 4 Sep 2025 12:56:33 +0100 Subject: [PATCH 09/13] feat: add parseing of bolt12 offer to send --- harbor-ui/src/main.rs | 202 ++++++++++++++++++++++++++---------------- 1 file changed, 127 insertions(+), 75 deletions(-) diff --git a/harbor-ui/src/main.rs b/harbor-ui/src/main.rs index 7b8c5dbb..d4744141 100644 --- a/harbor-ui/src/main.rs +++ b/harbor-ui/src/main.rs @@ -406,6 +406,114 @@ impl HarborWallet { (id, task) } + fn handle_bolt11_send( + &mut self, + invoice: Bolt11Invoice, + mint: MintIdentifier, + ) -> Task { + let (id, task) = self.send_from_ui(UICoreMsg::SendLightning { mint, invoice }); + self.current_send_id = Some(id); + task + } + + fn handle_bolt12_send( + &mut self, + offer: Offer, + mint: MintIdentifier, + invoice_str: String, + ) -> Task { + let offer_amount_sats = match offer_amount_sats(&offer) { + Ok(amount) => amount, + Err(e) => { + error!("Error parsing amount: {e}"); + self.send_failure_reason = Some(e.to_string()); + return Task::none(); + } + }; + + let amount_msats = match self.validate_bolt12_amount(offer_amount_sats) { + Ok(amount) => amount, + Err(error_task) => return error_task, + }; + + let (id, task) = self.send_from_ui(UICoreMsg::SendBolt12 { + mint, + offer: invoice_str, + amount_msats, + }); + self.current_send_id = Some(id); + task + } + + fn validate_bolt12_amount( + &mut self, + offer_amount_sats: Option, + ) -> Result, Task> { + if self.is_max { + return Err(Task::perform(async {}, |_| { + Message::AddToast(Toast { + title: "Cannot send max with BOLT12 offer".to_string(), + body: Some("Please enter a specific amount".to_string()), + status: ToastStatus::Bad, + }) + })); + } + + if !self.send_amount_input_str.is_empty() { + return self.validate_user_input_amount(offer_amount_sats); + } + + self.validate_no_amount_input(offer_amount_sats) + } + + fn validate_user_input_amount( + &mut self, + offer_amount_sats: Option, + ) -> Result, Task> { + let amount_sats = match self.send_amount_input_str.parse::() { + Ok(amount) => amount, + Err(e) => { + error!("Invalid amount format: {e}"); + self.send_failure_reason = Some(e.to_string()); + return Err(Task::none()); + } + }; + + match offer_amount_sats { + Some(offer_fixed_amount) => { + if offer_fixed_amount != amount_sats { + error!("Offer amount mismatch: expected {offer_fixed_amount} sats"); + self.send_failure_reason = + Some(format!("Offer amount must be {offer_fixed_amount} sats")); + return Err(Task::none()); + } + Ok(None) + } + None => Ok(Some(amount_sats * 1000)), // Convert to msats + } + } + + fn validate_no_amount_input( + &mut self, + offer_amount_sats: Option, + ) -> Result, Task> { + match offer_amount_sats { + Some(_) => Ok(None), + None => { + error!("Amount-less offer requires amount input"); + self.send_failure_reason = + Some("Enter an amount for this type of offer".to_string()); + Err(Task::perform(async {}, |_| { + Message::AddToast(Toast { + title: "Amountless offer".to_string(), + body: Some("Please enter a specific amount".to_string()), + status: ToastStatus::Bad, + }) + })) + } + } + } + // Helper function to safely remove a toast by index fn remove_toast(&mut self, index: usize) { if index < self.toasts.len() { @@ -510,9 +618,24 @@ impl HarborWallet { Task::none() } Message::SendDestInputChanged(input) => { - let msats = Bolt11Invoice::from_str(&input) + // Try parsing as Bolt11 invoice first + let bolt11_msats = Bolt11Invoice::from_str(&input) .ok() .and_then(|i| i.amount_milli_satoshis()); + + // Try parsing as Bolt12 offer if Bolt11 parsing failed + let bolt12_msats = if bolt11_msats.is_none() { + Offer::from_str(&input) + .ok() + .and_then(|offer| offer_amount_sats(&offer).ok().flatten()) + .map(|sats| sats * 1_000) // Convert sats to msats + } else { + None + }; + + // Use whichever parsing succeeded + let msats = bolt11_msats.or(bolt12_msats); + self.input_has_amount = msats.is_some(); if let Some(amt) = msats { self.send_amount_input_str = (amt / 1_000).to_string(); @@ -637,80 +760,9 @@ impl HarborWallet { }; if let Ok(invoice) = Bolt11Invoice::from_str(&invoice_str) { - let (id, task) = - self.send_from_ui(UICoreMsg::SendLightning { mint, invoice }); - self.current_send_id = Some(id); - task + self.handle_bolt11_send(invoice, mint) } else if let Ok(offer) = Offer::from_str(&invoice_str) { - let offer_amount_sats = match offer_amount_sats(&offer) { - Ok(amount) => amount, - Err(e) => { - error!("Error parsing amount: {e}"); - self.send_failure_reason = Some(e.to_string()); - return Task::none(); - } - }; - - // Handle BOLT12 offer payments - let amount_msats = if self.is_max { - return Task::perform(async {}, |_| { - Message::AddToast(Toast { - title: "Cannot send max with BOLT12 offer".to_string(), - body: Some("Please enter a specific amount".to_string()), - status: ToastStatus::Bad, - }) - }); - } else if !self.send_amount_input_str.is_empty() { - match self.send_amount_input_str.parse::() { - Ok(amount_sats) => { - // Verify fixed amount matches user input - if let Some(offer_fixed_amount) = offer_amount_sats { - if offer_fixed_amount != amount_sats { - error!( - "Offer amount mismatch: expected {offer_fixed_amount} sats" - ); - self.send_failure_reason = Some(format!( - "Offer amount must be {offer_fixed_amount} sats" - )); - return Task::none(); - } - - None - } else { - Some(amount_sats * 1000) // Convert to msats - } - } - Err(e) => { - error!("Invalid amount format: {e}"); - self.send_failure_reason = Some(e.to_string()); - return Task::none(); - } - } - } else { - // Amount required for all offer types - if let Some(_offer_amount) = offer_amount_sats { - None - } else { - error!("Amount-less offer requires amount input"); - self.send_failure_reason = - Some("Enter an amount for this type of offer".to_string()); - return Task::perform(async {}, |_| { - Message::AddToast(Toast { - title: "Amountless offer".to_string(), - body: Some("Please enter a specific amount".to_string()), - status: ToastStatus::Bad, - }) - }); - } - }; - - let (id, task) = self.send_from_ui(UICoreMsg::SendBolt12 { - mint, - offer: invoice_str, - amount_msats, - }); - self.current_send_id = Some(id); - task + self.handle_bolt12_send(offer, mint, invoice_str) } else if invoice_str.contains('@') { // BIP353 address if self.is_max { @@ -1572,7 +1624,7 @@ fn offer_amount_sats(offer: &Offer) -> Result, String> { Ok(Some(amount_msats / 1_000)) } lightning::offers::offer::Amount::Currency { .. } => { - return Err("Currency offers are not supported".to_string()); + Err("Currency offers are not supported".to_string()) } } } else { From 11b022c06dd3535b4da8f5b3abf69d7593aa3074 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Thu, 4 Sep 2025 13:34:53 +0100 Subject: [PATCH 10/13] feat: bolt12 enabled for default mint --- harbor-client/src/db.rs | 8 ++++---- harbor-client/src/db_models/mod.rs | 2 ++ harbor-client/src/lib.rs | 15 ++++++++++++++- harbor-ui/src/main.rs | 1 + harbor-ui/src/routes/receive.rs | 16 +++++++++------- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/harbor-client/src/db.rs b/harbor-client/src/db.rs index cbe19c80..4ca3e893 100644 --- a/harbor-client/src/db.rs +++ b/harbor-client/src/db.rs @@ -899,9 +899,9 @@ mod tests { ); assert_eq!( payment.payment_hash(), - invoice.payment_hash().to_byte_array() + Some(invoice.payment_hash().to_byte_array()) ); - assert_eq!(payment.bolt11(), invoice); + assert_eq!(payment.bolt11(), Some(invoice)); assert_eq!(payment.amount(), Amount::from_sats(1_000)); assert_eq!(payment.fee(), Amount::from_sats(1)); assert_eq!(payment.preimage(), None); @@ -955,9 +955,9 @@ mod tests { ); assert_eq!( receive.payment_hash(), - invoice.payment_hash().to_byte_array() + Some(invoice.payment_hash().to_byte_array()) ); - assert_eq!(receive.bolt11(), invoice); + assert_eq!(receive.bolt11(), Some(invoice)); assert_eq!(receive.amount(), Amount::from_sats(1_000)); assert_eq!(receive.fee(), Amount::from_sats(1)); assert_eq!(receive.status(), PaymentStatus::Pending); diff --git a/harbor-client/src/db_models/mod.rs b/harbor-client/src/db_models/mod.rs index 80d3fb07..ff666839 100644 --- a/harbor-client/src/db_models/mod.rs +++ b/harbor-client/src/db_models/mod.rs @@ -39,6 +39,7 @@ pub struct MintItem { pub module_kinds: Option>, pub metadata: FederationMeta, pub on_chain_supported: bool, + pub bolt12_supported: bool, pub active: bool, } @@ -52,6 +53,7 @@ impl MintItem { module_kinds: None, metadata: FederationMeta::default(), on_chain_supported: false, + bolt12_supported: false, active: true, } } diff --git a/harbor-client/src/lib.rs b/harbor-client/src/lib.rs index ebce2468..f42fd688 100644 --- a/harbor-client/src/lib.rs +++ b/harbor-client/src/lib.rs @@ -42,7 +42,7 @@ use bitcoin::address::NetworkUnchecked; use bitcoin::{Address, Network, Txid}; use cdk::cdk_database::WalletDatabase; use cdk::mint_url::MintUrl; -use cdk::nuts::{CurrencyUnit, MintInfo}; +use cdk::nuts::{CurrencyUnit, MintInfo, PaymentMethod}; use cdk::wallet::WalletBuilder; use cdk_sqlite::WalletSqliteDatabase; use fedimint_client::{spawn_lnv2_payment_subscription, spawn_lnv2_receive_subscription}; @@ -1823,6 +1823,7 @@ impl HarborCore { module_kinds: Some(module_kinds), metadata: metadata.unwrap_or_default(), on_chain_supported, + bolt12_supported: false, active: true, }); } @@ -1847,6 +1848,15 @@ impl HarborCore { popup_countdown_message: None, }; + let bolt12_supported = if let Some(info) = info { + info.nuts + .nut04 + .get_settings(&CurrencyUnit::Sat, &PaymentMethod::Bolt12) + .is_some() + } else { + false + }; + res.push(MintItem { id: MintIdentifier::Cashu(c.mint_url.clone()), name: metadata @@ -1858,6 +1868,7 @@ impl HarborCore { module_kinds: None, metadata, on_chain_supported: false, + bolt12_supported, active: true, }); } @@ -1893,6 +1904,7 @@ impl HarborCore { module_kinds: None, metadata: m.into(), on_chain_supported: false, + bolt12_supported: false, active: false, }; res.push(item); @@ -1913,6 +1925,7 @@ impl HarborCore { module_kinds: None, metadata: info.into(), on_chain_supported: false, + bolt12_supported: false, active: false, }; res.push(item); diff --git a/harbor-ui/src/main.rs b/harbor-ui/src/main.rs index d4744141..b9887dce 100644 --- a/harbor-ui/src/main.rs +++ b/harbor-ui/src/main.rs @@ -1422,6 +1422,7 @@ impl HarborWallet { module_kinds: Some(module_kinds), metadata, on_chain_supported: false, + bolt12_supported: false, active: true, }; diff --git a/harbor-ui/src/routes/receive.rs b/harbor-ui/src/routes/receive.rs index 10823d3c..483594da 100644 --- a/harbor-ui/src/routes/receive.rs +++ b/harbor-ui/src/routes/receive.rs @@ -39,14 +39,16 @@ fn render_receive_form(harbor: &HarborWallet) -> Element { .active_federation() .is_some_and(|x| x.on_chain_supported)); - // Bolt12 is only available for Cashu mints - let bolt12_enabled = harbor - .active_mint - .as_ref() - .is_some_and(|a| a.mint_url().is_some()); + let bolt12_enabled = if let Some(info) = harbor.active_federation() { + info.bolt12_supported + } else { + false + }; - let header = if on_chain_enabled || bolt12_enabled { - h_header("Deposit", "Receive via lightning, Bolt12, or on-chain.") + let header = if on_chain_enabled { + h_header("Deposit", "Receive via lightning or on-chain.") + } else if bolt12_enabled { + h_header("Deposit", "Receive via lightning; bolt11 or bolt12.") } else { h_header("Deposit", "Receive via lightning.") }; From 1da735d4a3ffa404e251e7bb5dfa96a9d1fc734d Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Thu, 4 Sep 2025 14:55:50 +0100 Subject: [PATCH 11/13] chore: clippyt --- harbor-client/src/db_models/lightning_receive.rs | 12 ++++++------ harbor-ui/src/main.rs | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/harbor-client/src/db_models/lightning_receive.rs b/harbor-client/src/db_models/lightning_receive.rs index ecfe9ce7..e93611a8 100644 --- a/harbor-client/src/db_models/lightning_receive.rs +++ b/harbor-client/src/db_models/lightning_receive.rs @@ -200,15 +200,15 @@ impl LightningReceive { use crate::db_models::schema::lightning_receives::dsl as lr; // fetch the existing receive record - let existing: Option = lr::lightning_receives + let existing: Option = lr::lightning_receives .filter(lr::operation_id.eq(&operation_id)) .order(lr::updated_at.desc()) - .first::(conn) + .first(conn) .optional()?; if let Some(rec) = existing { if rec.bolt12_offer.is_some() { - let new_amount = amount_msats.map(|a| a as i64).unwrap_or(rec.amount_msats); + let new_amount = amount_msats.map_or(rec.amount_msats, |a| a as i64); let new_payment = NewLightningReceivePayment { receive_operation_id: rec.operation_id.clone(), @@ -223,7 +223,7 @@ impl LightningReceive { // Update the receive summary row to Success so it appears in history and update timestamp diesel::update( - lr::lightning_receives.filter(lr::operation_id.eq(rec.operation_id.clone())), + lr::lightning_receives.filter(lr::operation_id.eq(rec.operation_id)), ) .set(lr::status.eq(PaymentStatus::Success as i32)) .execute(conn)?; @@ -272,7 +272,7 @@ impl LightningReceive { pub fn get_bolt12_payments_history( conn: &mut SqliteConnection, - ) -> anyhow::Result> { + ) -> anyhow::Result> { use crate::db_models::schema::lightning_receive_payments::dsl as lrp; use crate::db_models::schema::lightning_receives::dsl as lr; @@ -301,7 +301,7 @@ impl LightningReceive { lr::updated_at, ), )) - .load::<(LightningReceivePayment, LightningReceive)>(conn)?; + .load::<(LightningReceivePayment, Self)>(conn)?; Ok(results) } diff --git a/harbor-ui/src/main.rs b/harbor-ui/src/main.rs index b9887dce..e17350d5 100644 --- a/harbor-ui/src/main.rs +++ b/harbor-ui/src/main.rs @@ -426,7 +426,7 @@ impl HarborWallet { Ok(amount) => amount, Err(e) => { error!("Error parsing amount: {e}"); - self.send_failure_reason = Some(e.to_string()); + self.send_failure_reason = Some(e); return Task::none(); } }; @@ -450,7 +450,7 @@ impl HarborWallet { offer_amount_sats: Option, ) -> Result, Task> { if self.is_max { - return Err(Task::perform(async {}, |_| { + return Err(Task::perform(async {}, |()| { Message::AddToast(Toast { title: "Cannot send max with BOLT12 offer".to_string(), body: Some("Please enter a specific amount".to_string()), @@ -503,7 +503,7 @@ impl HarborWallet { error!("Amount-less offer requires amount input"); self.send_failure_reason = Some("Enter an amount for this type of offer".to_string()); - Err(Task::perform(async {}, |_| { + Err(Task::perform(async {}, |()| { Message::AddToast(Toast { title: "Amountless offer".to_string(), body: Some("Please enter a specific amount".to_string()), @@ -943,7 +943,7 @@ impl HarborWallet { Some(f) => f, None => { error!("No active mint"); - return Task::perform(async {}, |_| { + return Task::perform(async {}, |()| { Message::AddToast(Toast { title: "Cannot generate Bolt12 offer".to_string(), body: Some("No active mint selected".to_string()), @@ -961,7 +961,7 @@ impl HarborWallet { Ok(amount) => Some(Amount::from_sats(amount)), Err(e) => { error!("Error parsing amount: {e}"); - return Task::perform(async {}, move |_| { + return Task::perform(async {}, move |()| { Message::AddToast(Toast { title: "Failed to generate Bolt12 offer".to_string(), body: Some(e.to_string()), From 11674aa17c0c4597fa862a33901709c248a0d557 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Thu, 4 Sep 2025 14:56:02 +0100 Subject: [PATCH 12/13] chore: more clippy --- harbor-ui/src/bridge.rs | 762 ++++++++++++++++++++-------------------- 1 file changed, 388 insertions(+), 374 deletions(-) diff --git a/harbor-ui/src/bridge.rs b/harbor-ui/src/bridge.rs index a6bf28ef..8f9775b2 100644 --- a/harbor-ui/src/bridge.rs +++ b/harbor-ui/src/bridge.rs @@ -488,392 +488,406 @@ pub fn run_core() -> impl Stream { }) } -async fn process_core(core_handle: &mut CoreHandle, core: &HarborCore) { - // Initialize the ui's state - core.init_ui_state().await.expect("Could not init ui state"); - - loop { - let msg = core_handle.recv().await; - - let core = core.clone(); - tokio::spawn(async move { - if let Some(msg) = msg { - match msg.msg { - UICoreMsg::SendLightning { mint, invoice } => { - log::info!("Got UICoreMsg::Send"); - core.msg(msg.id, CoreUIMsg::Sending).await; - if let Err(e) = core.send_lightning(msg.id, mint, invoice, false).await { - error!("Error sending: {e}"); - core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) - .await; - } - } - UICoreMsg::SendBolt12 { - mint, - offer, - amount_msats, - } => { - log::info!("Got UICoreMsg::SendBolt12"); - core.msg(msg.id, CoreUIMsg::Sending).await; - if let Err(e) = core - .send_bolt12(msg.id, mint, offer, amount_msats, false) - .await - { - error!("Error sending bolt12: {e}"); - core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) - .await; - } - } - UICoreMsg::ReceiveBolt12 { mint, amount } => { - core.msg(msg.id, CoreUIMsg::ReceiveGenerating).await; - match core.receive_bolt12(msg.id, mint, amount, false).await { - Err(e) => { - core.msg(msg.id, CoreUIMsg::ReceiveFailed(e.to_string())) - .await; - } - Ok(offer) => { - core.msg(msg.id, CoreUIMsg::ReceiveBolt12OfferGenerated(offer)) - .await; - } - } - } - UICoreMsg::ReceiveLightning { mint, amount } => { - core.msg(msg.id, CoreUIMsg::ReceiveGenerating).await; - match core.receive_lightning(msg.id, mint, amount, false).await { - Err(e) => { - core.msg(msg.id, CoreUIMsg::ReceiveFailed(e.to_string())) - .await; - } - Ok(invoice) => { - core.msg(msg.id, CoreUIMsg::ReceiveInvoiceGenerated(invoice)) - .await; - } - } - } - UICoreMsg::SendBip353 { - mint, - address, - amount_sats, - } => { - log::info!("Got UICoreMsg::SendBip353"); - core.msg(msg.id, CoreUIMsg::Sending).await; - if let Err(e) = core - .send_bip353(msg.id, mint, address, amount_sats, false) - .await - { - error!("Error sending: {e}"); - core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) - .await; - } - } - UICoreMsg::SendLnurlPay { - mint, - lnurl, - amount_sats, - } => { - log::info!("Got UICoreMsg::SendLnurlPay"); - core.msg(msg.id, CoreUIMsg::Sending).await; - if let Err(e) = core.send_lnurl_pay(msg.id, mint, lnurl, amount_sats).await - { - error!("Error sending: {e}"); - core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) - .await; - } - } - UICoreMsg::ReceiveOnChain { mint } => { - core.msg(msg.id, CoreUIMsg::ReceiveGenerating).await; - let federation_id = match mint { - MintIdentifier::Cashu(_) => panic!("should not receive cashu"), // todo - MintIdentifier::Fedimint(mint) => mint, - }; - - match core.receive_onchain(msg.id, federation_id).await { - Err(e) => { - core.msg(msg.id, CoreUIMsg::ReceiveFailed(e.to_string())) - .await; - } - Ok(address) => { - core.msg(msg.id, CoreUIMsg::ReceiveAddressGenerated(address)) - .await; - } - } - } - UICoreMsg::SendOnChain { - mint, - address, - amount_sats, - } => { - log::info!("Got UICoreMsg::SendOnChain"); - core.msg(msg.id, CoreUIMsg::Sending).await; - let federation_id = match mint { - MintIdentifier::Cashu(_) => panic!("should not receive cashu"), // todo - MintIdentifier::Fedimint(mint) => mint, - }; - if let Err(e) = core - .send_onchain(msg.id, federation_id, address, amount_sats) - .await - { - error!("Error sending: {e}"); - core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) - .await; - } - } - UICoreMsg::Transfer { to, from, amount } => { - if let Err(e) = core.transfer(msg.id, to, from, amount).await { - error!("Error transferring: {e}"); - core.msg(msg.id, CoreUIMsg::TransferFailure(e.to_string())) - .await; - } - } - UICoreMsg::GetFederationInfo(invite_code) => { - match core.get_federation_info(msg.id, invite_code).await { - Err(e) => { - error!("Error getting federation info: {e}"); - core.msg(msg.id, CoreUIMsg::AddMintFailed(e.to_string())) - .await; - } - Ok((config, metadata)) => { - core.msg( - msg.id, - CoreUIMsg::MintInfo { - id: MintIdentifier::Fedimint( - config.calculate_federation_id(), - ), - config: Some(config), - metadata, - }, - ) - .await; - } - } - } - UICoreMsg::GetCashuMintInfo(mint_url) => { - match core.get_cashu_mint_info(msg.id, mint_url.clone()).await { - Err(e) => { - error!("Error getting cashu mint info: {e}"); - core.msg(msg.id, CoreUIMsg::AddMintFailed(e.to_string())) - .await; - } - Ok(info) => { - let metadata = FederationMeta { - federation_name: info - .as_ref() - .and_then(|i| i.name.clone()) - .or(Some(mint_url.to_string())), - federation_expiry_timestamp: None, - welcome_message: None, - vetted_gateways: None, - federation_icon_url: info - .as_ref() - .and_then(|i| i.icon_url.clone()), - meta_external_url: None, - preview_message: info.and_then(|i| i.description), - popup_end_timestamp: None, - popup_countdown_message: None, - }; - core.msg( - msg.id, - CoreUIMsg::MintInfo { - id: MintIdentifier::Cashu(mint_url), - config: None, - metadata, - }, - ) - .await; - } - } - } - UICoreMsg::AddFederation(invite_code) => { - let id = invite_code.federation_id(); - match core.add_federation(msg.id, invite_code).await { - Err(e) => { - error!("Error adding federation: {e}"); - core.msg(msg.id, CoreUIMsg::AddMintFailed(e.to_string())) - .await; - } - Ok(()) => { - if let Ok(new_federation_list) = core.get_mint_items().await { - core.msg( - msg.id, - CoreUIMsg::MintListUpdated(new_federation_list), - ) - .await; - } - core.msg( - msg.id, - CoreUIMsg::AddMintSuccess(MintIdentifier::Fedimint(id)), - ) - .await; - } - } - } - UICoreMsg::AddCashuMint(url) => match core - .add_cashu_mint(msg.id, url.clone()) - .await - { - Err(e) => { - error!("Error adding mint: {e}"); - core.msg(msg.id, CoreUIMsg::AddMintFailed(e.to_string())) - .await; - } - Ok(()) => { - if let Ok(new_federation_list) = core.get_mint_items().await { - core.msg(msg.id, CoreUIMsg::MintListUpdated(new_federation_list)) - .await; - } - core.msg( - msg.id, - CoreUIMsg::AddMintSuccess(MintIdentifier::Cashu(url)), - ) - .await; - } - }, - UICoreMsg::RemoveMint(id) => { - // Send status update before attempting removal +async fn handle_core_message(msg: UICoreMsgPacket, core: HarborCore) { + match msg.msg { + UICoreMsg::SendLightning { mint, invoice } => { + log::info!("Got UICoreMsg::Send"); + core.msg(msg.id, CoreUIMsg::Sending).await; + if let Err(e) = core.send_lightning(msg.id, mint, invoice, false).await { + error!("Error sending: {e}"); + core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) + .await; + } + } + UICoreMsg::SendBolt12 { + mint, + offer, + amount_msats, + } => { + log::info!("Got UICoreMsg::SendBolt12"); + core.msg(msg.id, CoreUIMsg::Sending).await; + if let Err(e) = core + .send_bolt12(msg.id, mint, offer, amount_msats, false) + .await + { + error!("Error sending bolt12: {e}"); + core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) + .await; + } + } + UICoreMsg::ReceiveBolt12 { mint, amount } => { + core.msg(msg.id, CoreUIMsg::ReceiveGenerating).await; + match core.receive_bolt12(msg.id, mint, amount, false).await { + Err(e) => { + core.msg(msg.id, CoreUIMsg::ReceiveFailed(e.to_string())) + .await; + } + Ok(offer) => { + core.msg(msg.id, CoreUIMsg::ReceiveBolt12OfferGenerated(offer)) + .await; + } + } + } + UICoreMsg::ReceiveLightning { mint, amount } => { + core.msg(msg.id, CoreUIMsg::ReceiveGenerating).await; + match core.receive_lightning(msg.id, mint, amount, false).await { + Err(e) => { + core.msg(msg.id, CoreUIMsg::ReceiveFailed(e.to_string())) + .await; + } + Ok(invoice) => { + core.msg(msg.id, CoreUIMsg::ReceiveInvoiceGenerated(invoice)) + .await; + } + } + } + UICoreMsg::SendBip353 { + mint, + address, + amount_sats, + } => { + log::info!("Got UICoreMsg::SendBip353"); + core.msg(msg.id, CoreUIMsg::Sending).await; + if let Err(e) = core + .send_bip353(msg.id, mint, address, amount_sats, false) + .await + { + error!("Error sending: {e}"); + core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) + .await; + } + } + UICoreMsg::SendLnurlPay { + mint, + lnurl, + amount_sats, + } => { + log::info!("Got UICoreMsg::SendLnurlPay"); + core.msg(msg.id, CoreUIMsg::Sending).await; + if let Err(e) = core.send_lnurl_pay(msg.id, mint, lnurl, amount_sats).await + { + error!("Error sending: {e}"); + core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) + .await; + } + } + UICoreMsg::ReceiveOnChain { mint } => { + core.msg(msg.id, CoreUIMsg::ReceiveGenerating).await; + let federation_id = match mint { + MintIdentifier::Cashu(_) => panic!("should not receive cashu"), // todo + MintIdentifier::Fedimint(mint) => mint, + }; + + match core.receive_onchain(msg.id, federation_id).await { + Err(e) => { + core.msg(msg.id, CoreUIMsg::ReceiveFailed(e.to_string())) + .await; + } + Ok(address) => { + core.msg(msg.id, CoreUIMsg::ReceiveAddressGenerated(address)) + .await; + } + } + } + UICoreMsg::SendOnChain { + mint, + address, + amount_sats, + } => { + log::info!("Got UICoreMsg::SendOnChain"); + core.msg(msg.id, CoreUIMsg::Sending).await; + let federation_id = match mint { + MintIdentifier::Cashu(_) => panic!("should not receive cashu"), // todo + MintIdentifier::Fedimint(mint) => mint, + }; + if let Err(e) = core + .send_onchain(msg.id, federation_id, address, amount_sats) + .await + { + error!("Error sending: {e}"); + core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) + .await; + } + } + UICoreMsg::Transfer { to, from, amount } => { + if let Err(e) = core.transfer(msg.id, to, from, amount).await { + error!("Error transferring: {e}"); + core.msg(msg.id, CoreUIMsg::TransferFailure(e.to_string())) + .await; + } + } + UICoreMsg::GetFederationInfo(invite_code) => { + match core.get_federation_info(msg.id, invite_code).await { + Err(e) => { + error!("Error getting federation info: {e}"); + core.msg(msg.id, CoreUIMsg::AddMintFailed(e.to_string())) + .await; + } + Ok((config, metadata)) => { + core.msg( + msg.id, + CoreUIMsg::MintInfo { + id: MintIdentifier::Fedimint( + config.calculate_federation_id(), + ), + config: Some(config), + metadata, + }, + ) + .await; + } + } + } + UICoreMsg::GetCashuMintInfo(mint_url) => { + match core.get_cashu_mint_info(msg.id, mint_url.clone()).await { + Err(e) => { + error!("Error getting cashu mint info: {e}"); + core.msg(msg.id, CoreUIMsg::AddMintFailed(e.to_string())) + .await; + } + Ok(info) => { + let metadata = FederationMeta { + federation_name: info + .as_ref() + .and_then(|i| i.name.clone()) + .or(Some(mint_url.to_string())), + federation_expiry_timestamp: None, + welcome_message: None, + vetted_gateways: None, + federation_icon_url: info + .as_ref() + .and_then(|i| i.icon_url.clone()), + meta_external_url: None, + preview_message: info.and_then(|i| i.description), + popup_end_timestamp: None, + popup_countdown_message: None, + }; + core.msg( + msg.id, + CoreUIMsg::MintInfo { + id: MintIdentifier::Cashu(mint_url), + config: None, + metadata, + }, + ) + .await; + } + } + } + UICoreMsg::AddFederation(invite_code) => { + let id = invite_code.federation_id(); + match core.add_federation(msg.id, invite_code).await { + Err(e) => { + error!("Error adding federation: {e}"); + core.msg(msg.id, CoreUIMsg::AddMintFailed(e.to_string())) + .await; + } + Ok(()) => { + if let Ok(new_federation_list) = core.get_mint_items().await { core.msg( msg.id, - CoreUIMsg::StatusUpdate { - message: "Removing mint...".to_string(), - operation_id: Some(msg.id), - }, + CoreUIMsg::MintListUpdated(new_federation_list), ) .await; - - match id { - MintIdentifier::Fedimint(id) => { - match core.remove_federation(msg.id, id).await { - Err(e) => { - error!("Error removing federation: {e}"); - core.msg( - msg.id, - CoreUIMsg::RemoveFederationFailed(e.to_string()), - ) - .await; - } - Ok(()) => { - log::info!("Removed federation: {id}"); - if let Ok(new_federation_list) = core.get_mint_items().await - { - core.msg( - msg.id, - CoreUIMsg::MintListUpdated(new_federation_list), - ) - .await; - } - core.msg(msg.id, CoreUIMsg::RemoveFederationSuccess).await; - } - } - } - MintIdentifier::Cashu(url) => { - match core.remove_cashu_mint(msg.id, &url).await { - Err(e) => { - error!("Error removing cashu mint: {e}"); - core.msg( - msg.id, - CoreUIMsg::RemoveFederationFailed(e.to_string()), - ) - .await; - } - Ok(()) => { - log::info!("Removed cashu mint: {url}"); - if let Ok(new_federation_list) = core.get_mint_items().await - { - core.msg( - msg.id, - CoreUIMsg::MintListUpdated(new_federation_list), - ) - .await; - } - core.msg(msg.id, CoreUIMsg::RemoveFederationSuccess).await; - } - } - } - } } - UICoreMsg::RejoinMint(mint) => match mint { - MintIdentifier::Fedimint(id) => { - if let Ok(Some(invite_code)) = - core.storage.get_federation_invite_code(id) - { - match core.add_federation(msg.id, invite_code).await { - Err(e) => { - error!("Error adding federation: {e}"); - core.msg(msg.id, CoreUIMsg::AddMintFailed(e.to_string())) - .await; - } - Ok(()) => { - if let Ok(new_federation_list) = core.get_mint_items().await - { - core.msg( - msg.id, - CoreUIMsg::MintListUpdated(new_federation_list), - ) - .await; - } - core.msg(msg.id, CoreUIMsg::AddMintSuccess(mint)).await; - info!("Rejoined federation: {id}"); - } - } - } - } - MintIdentifier::Cashu(ref mint_url) => { - match core.add_cashu_mint(msg.id, mint_url.clone()).await { - Err(e) => { - error!("Error adding cashu mint: {e}"); - core.msg(msg.id, CoreUIMsg::AddMintFailed(e.to_string())) - .await; - } - Ok(()) => { - if let Ok(new_list) = core.get_mint_items().await { - core.msg(msg.id, CoreUIMsg::MintListUpdated(new_list)) - .await; - } - info!("Rejoined cashu mint: {mint_url}"); - core.msg(msg.id, CoreUIMsg::AddMintSuccess(mint)).await; - } - } - } - }, - UICoreMsg::FederationListNeedsUpdate => { - if let Ok(new_federation_list) = core.get_mint_items().await { - core.msg(msg.id, CoreUIMsg::MintListUpdated(new_federation_list)) - .await; - } + core.msg( + msg.id, + CoreUIMsg::AddMintSuccess(MintIdentifier::Fedimint(id)), + ) + .await; + } + } + } + UICoreMsg::AddCashuMint(url) => match core + .add_cashu_mint(msg.id, url.clone()) + .await + { + Err(e) => { + error!("Error adding mint: {e}"); + core.msg(msg.id, CoreUIMsg::AddMintFailed(e.to_string())) + .await; + } + Ok(()) => { + if let Ok(new_federation_list) = core.get_mint_items().await { + core.msg(msg.id, CoreUIMsg::MintListUpdated(new_federation_list)) + .await; + } + core.msg( + msg.id, + CoreUIMsg::AddMintSuccess(MintIdentifier::Cashu(url)), + ) + .await; + } + }, + UICoreMsg::RemoveMint(id) => { + handle_remove_mint(&core, msg.id, id).await; + } + UICoreMsg::RejoinMint(mint) => { + handle_rejoin_mint(&core, msg.id, mint).await; + } + UICoreMsg::FederationListNeedsUpdate => { + if let Ok(new_federation_list) = core.get_mint_items().await { + core.msg(msg.id, CoreUIMsg::MintListUpdated(new_federation_list)) + .await; + } + } + UICoreMsg::GetSeedWords => { + let seed_words = core.get_seed_words(); + core.msg(msg.id, CoreUIMsg::SeedWords(seed_words)).await; + } + UICoreMsg::SetOnchainReceiveEnabled(enabled) => { + match core.set_onchain_receive_enabled(enabled) { + Err(e) => { + error!("error setting onchain receive enabled: {e}"); + } + _ => { + core.msg(msg.id, CoreUIMsg::OnchainReceiveEnabled(enabled)) + .await; + } + } + } + UICoreMsg::SetTorEnabled(enabled) => match core.set_tor_enabled(enabled) { + Err(e) => { + error!("error setting tor enabled: {e}"); + } + _ => { + core.msg(msg.id, CoreUIMsg::TorEnabled(enabled)).await; + } + }, + UICoreMsg::TestStatusUpdates => { + core.test_status_updates(msg.id).await; + } + UICoreMsg::Unlock(_password) => { + unreachable!("should already be unlocked") + } + UICoreMsg::Init { .. } => { + unreachable!("should already be inited") + } + } +} + +async fn handle_remove_mint(core: &HarborCore, msg_id: Uuid, id: MintIdentifier) { + // Send status update before attempting removal + core.msg( + msg_id, + CoreUIMsg::StatusUpdate { + message: "Removing mint...".to_string(), + operation_id: Some(msg_id), + }, + ) + .await; + + match id { + MintIdentifier::Fedimint(id) => { + match core.remove_federation(msg_id, id).await { + Err(e) => { + error!("Error removing federation: {e}"); + core.msg( + msg_id, + CoreUIMsg::RemoveFederationFailed(e.to_string()), + ) + .await; + } + Ok(()) => { + log::info!("Removed federation: {id}"); + if let Ok(new_federation_list) = core.get_mint_items().await + { + core.msg( + msg_id, + CoreUIMsg::MintListUpdated(new_federation_list), + ) + .await; } - UICoreMsg::GetSeedWords => { - let seed_words = core.get_seed_words(); - core.msg(msg.id, CoreUIMsg::SeedWords(seed_words)).await; + core.msg(msg_id, CoreUIMsg::RemoveFederationSuccess).await; + } + } + } + MintIdentifier::Cashu(url) => { + match core.remove_cashu_mint(msg_id, &url).await { + Err(e) => { + error!("Error removing cashu mint: {e}"); + core.msg( + msg_id, + CoreUIMsg::RemoveFederationFailed(e.to_string()), + ) + .await; + } + Ok(()) => { + log::info!("Removed cashu mint: {url}"); + if let Ok(new_federation_list) = core.get_mint_items().await + { + core.msg( + msg_id, + CoreUIMsg::MintListUpdated(new_federation_list), + ) + .await; } - UICoreMsg::SetOnchainReceiveEnabled(enabled) => { - match core.set_onchain_receive_enabled(enabled) { - Err(e) => { - error!("error setting onchain receive enabled: {e}"); - } - _ => { - core.msg(msg.id, CoreUIMsg::OnchainReceiveEnabled(enabled)) - .await; - } - } + core.msg(msg_id, CoreUIMsg::RemoveFederationSuccess).await; + } + } + } + } +} + +async fn handle_rejoin_mint(core: &HarborCore, msg_id: Uuid, mint: MintIdentifier) { + match mint { + MintIdentifier::Fedimint(id) => { + if let Ok(Some(invite_code)) = + core.storage.get_federation_invite_code(id) + { + match core.add_federation(msg_id, invite_code).await { + Err(e) => { + error!("Error adding federation: {e}"); + core.msg(msg_id, CoreUIMsg::AddMintFailed(e.to_string())) + .await; } - UICoreMsg::SetTorEnabled(enabled) => match core.set_tor_enabled(enabled) { - Err(e) => { - error!("error setting tor enabled: {e}"); - } - _ => { - core.msg(msg.id, CoreUIMsg::TorEnabled(enabled)).await; + Ok(()) => { + if let Ok(new_federation_list) = core.get_mint_items().await + { + core.msg( + msg_id, + CoreUIMsg::MintListUpdated(new_federation_list), + ) + .await; } - }, - UICoreMsg::TestStatusUpdates => { - core.test_status_updates(msg.id).await; - } - UICoreMsg::Unlock(_password) => { - unreachable!("should already be unlocked") + core.msg(msg_id, CoreUIMsg::AddMintSuccess(mint)).await; + info!("Rejoined federation: {id}"); } - UICoreMsg::Init { .. } => { - unreachable!("should already be inited") + } + } + } + MintIdentifier::Cashu(ref mint_url) => { + match core.add_cashu_mint(msg_id, mint_url.clone()).await { + Err(e) => { + error!("Error adding cashu mint: {e}"); + core.msg(msg_id, CoreUIMsg::AddMintFailed(e.to_string())) + .await; + } + Ok(()) => { + if let Ok(new_list) = core.get_mint_items().await { + core.msg(msg_id, CoreUIMsg::MintListUpdated(new_list)) + .await; } + info!("Rejoined cashu mint: {mint_url}"); + core.msg(msg_id, CoreUIMsg::AddMintSuccess(mint)).await; } } - }); + } + } +} + +async fn process_core(core_handle: &mut CoreHandle, core: &HarborCore) { + // Initialize the ui's state + core.init_ui_state().await.expect("Could not init ui state"); + + loop { + let msg = core_handle.recv().await; + + if let Some(msg) = msg { + let core = core.clone(); + tokio::spawn(async move { + handle_core_message(msg, core).await; + }); + } } } From 9de2b843d54186d8b1cc1f92569c9f737e97a6e8 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Thu, 4 Sep 2025 14:56:24 +0100 Subject: [PATCH 13/13] chore: fmt --- harbor-ui/src/bridge.rs | 102 +++++++++++++--------------------------- 1 file changed, 33 insertions(+), 69 deletions(-) diff --git a/harbor-ui/src/bridge.rs b/harbor-ui/src/bridge.rs index 8f9775b2..2b226b9a 100644 --- a/harbor-ui/src/bridge.rs +++ b/harbor-ui/src/bridge.rs @@ -564,8 +564,7 @@ async fn handle_core_message(msg: UICoreMsgPacket, core: HarborCore) { } => { log::info!("Got UICoreMsg::SendLnurlPay"); core.msg(msg.id, CoreUIMsg::Sending).await; - if let Err(e) = core.send_lnurl_pay(msg.id, mint, lnurl, amount_sats).await - { + if let Err(e) = core.send_lnurl_pay(msg.id, mint, lnurl, amount_sats).await { error!("Error sending: {e}"); core.msg(msg.id, CoreUIMsg::SendFailure(e.to_string())) .await; @@ -627,9 +626,7 @@ async fn handle_core_message(msg: UICoreMsgPacket, core: HarborCore) { core.msg( msg.id, CoreUIMsg::MintInfo { - id: MintIdentifier::Fedimint( - config.calculate_federation_id(), - ), + id: MintIdentifier::Fedimint(config.calculate_federation_id()), config: Some(config), metadata, }, @@ -654,9 +651,7 @@ async fn handle_core_message(msg: UICoreMsgPacket, core: HarborCore) { federation_expiry_timestamp: None, welcome_message: None, vetted_gateways: None, - federation_icon_url: info - .as_ref() - .and_then(|i| i.icon_url.clone()), + federation_icon_url: info.as_ref().and_then(|i| i.icon_url.clone()), meta_external_url: None, preview_message: info.and_then(|i| i.description), popup_end_timestamp: None, @@ -684,11 +679,8 @@ async fn handle_core_message(msg: UICoreMsgPacket, core: HarborCore) { } Ok(()) => { if let Ok(new_federation_list) = core.get_mint_items().await { - core.msg( - msg.id, - CoreUIMsg::MintListUpdated(new_federation_list), - ) - .await; + core.msg(msg.id, CoreUIMsg::MintListUpdated(new_federation_list)) + .await; } core.msg( msg.id, @@ -698,10 +690,7 @@ async fn handle_core_message(msg: UICoreMsgPacket, core: HarborCore) { } } } - UICoreMsg::AddCashuMint(url) => match core - .add_cashu_mint(msg.id, url.clone()) - .await - { + UICoreMsg::AddCashuMint(url) => match core.add_cashu_mint(msg.id, url.clone()).await { Err(e) => { error!("Error adding mint: {e}"); core.msg(msg.id, CoreUIMsg::AddMintFailed(e.to_string())) @@ -778,63 +767,43 @@ async fn handle_remove_mint(core: &HarborCore, msg_id: Uuid, id: MintIdentifier) .await; match id { - MintIdentifier::Fedimint(id) => { - match core.remove_federation(msg_id, id).await { - Err(e) => { - error!("Error removing federation: {e}"); - core.msg( - msg_id, - CoreUIMsg::RemoveFederationFailed(e.to_string()), - ) + MintIdentifier::Fedimint(id) => match core.remove_federation(msg_id, id).await { + Err(e) => { + error!("Error removing federation: {e}"); + core.msg(msg_id, CoreUIMsg::RemoveFederationFailed(e.to_string())) .await; - } - Ok(()) => { - log::info!("Removed federation: {id}"); - if let Ok(new_federation_list) = core.get_mint_items().await - { - core.msg( - msg_id, - CoreUIMsg::MintListUpdated(new_federation_list), - ) + } + Ok(()) => { + log::info!("Removed federation: {id}"); + if let Ok(new_federation_list) = core.get_mint_items().await { + core.msg(msg_id, CoreUIMsg::MintListUpdated(new_federation_list)) .await; - } - core.msg(msg_id, CoreUIMsg::RemoveFederationSuccess).await; } + core.msg(msg_id, CoreUIMsg::RemoveFederationSuccess).await; } - } - MintIdentifier::Cashu(url) => { - match core.remove_cashu_mint(msg_id, &url).await { - Err(e) => { - error!("Error removing cashu mint: {e}"); - core.msg( - msg_id, - CoreUIMsg::RemoveFederationFailed(e.to_string()), - ) + }, + MintIdentifier::Cashu(url) => match core.remove_cashu_mint(msg_id, &url).await { + Err(e) => { + error!("Error removing cashu mint: {e}"); + core.msg(msg_id, CoreUIMsg::RemoveFederationFailed(e.to_string())) .await; - } - Ok(()) => { - log::info!("Removed cashu mint: {url}"); - if let Ok(new_federation_list) = core.get_mint_items().await - { - core.msg( - msg_id, - CoreUIMsg::MintListUpdated(new_federation_list), - ) + } + Ok(()) => { + log::info!("Removed cashu mint: {url}"); + if let Ok(new_federation_list) = core.get_mint_items().await { + core.msg(msg_id, CoreUIMsg::MintListUpdated(new_federation_list)) .await; - } - core.msg(msg_id, CoreUIMsg::RemoveFederationSuccess).await; } + core.msg(msg_id, CoreUIMsg::RemoveFederationSuccess).await; } - } + }, } } async fn handle_rejoin_mint(core: &HarborCore, msg_id: Uuid, mint: MintIdentifier) { match mint { MintIdentifier::Fedimint(id) => { - if let Ok(Some(invite_code)) = - core.storage.get_federation_invite_code(id) - { + if let Ok(Some(invite_code)) = core.storage.get_federation_invite_code(id) { match core.add_federation(msg_id, invite_code).await { Err(e) => { error!("Error adding federation: {e}"); @@ -842,13 +811,9 @@ async fn handle_rejoin_mint(core: &HarborCore, msg_id: Uuid, mint: MintIdentifie .await; } Ok(()) => { - if let Ok(new_federation_list) = core.get_mint_items().await - { - core.msg( - msg_id, - CoreUIMsg::MintListUpdated(new_federation_list), - ) - .await; + if let Ok(new_federation_list) = core.get_mint_items().await { + core.msg(msg_id, CoreUIMsg::MintListUpdated(new_federation_list)) + .await; } core.msg(msg_id, CoreUIMsg::AddMintSuccess(mint)).await; info!("Rejoined federation: {id}"); @@ -865,8 +830,7 @@ async fn handle_rejoin_mint(core: &HarborCore, msg_id: Uuid, mint: MintIdentifie } Ok(()) => { if let Ok(new_list) = core.get_mint_items().await { - core.msg(msg_id, CoreUIMsg::MintListUpdated(new_list)) - .await; + core.msg(msg_id, CoreUIMsg::MintListUpdated(new_list)).await; } info!("Rejoined cashu mint: {mint_url}"); core.msg(msg_id, CoreUIMsg::AddMintSuccess(mint)).await;