diff --git a/Cargo.lock b/Cargo.lock index 9d742cc7..fa47367c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1153,14 +1153,15 @@ source = "git+https://github.com/benthecarman/arti.git?rev=e0f1f7a9a44ae0543c0b6 [[package]] name = "cashu" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c95072ffe3442625d7589faeeabab4c739a4b8a79c11d826f295606dad7a26" +checksum = "c2b5eada71b5675fea6ae15f5d0aeb76d6d0c8ae057fed2f9afc491b3d2e741c" dependencies = [ "bitcoin", "cbor-diag", "ciborium", "instant", + "lightning 0.1.5", "lightning-invoice 0.33.2", "once_cell", "regex", @@ -1173,6 +1174,7 @@ dependencies = [ "tracing", "url", "uuid", + "zeroize", ] [[package]] @@ -1216,9 +1218,9 @@ dependencies = [ [[package]] name = "cdk" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b26c214b8cce92097d4c9c72081eceb6ba52f55e98f0d2f2987d862f07b0fd6" +checksum = "5c3f3b7e0485c9d1f5b9ae28096ec8945a636a8f4d1f69a6ba9baa57d76e25b4" dependencies = [ "anyhow", "arc-swap", @@ -1229,7 +1231,9 @@ dependencies = [ "cdk-common", "cdk-signatory", "ciborium", + "futures", "getrandom 0.2.15", + "lightning 0.1.5", "lightning-invoice 0.33.2", "regex", "reqwest 0.12.12", @@ -1242,16 +1246,19 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tokio-tungstenite 0.26.2", + "tokio-util", "tracing", + "trust-dns-resolver", "url", "uuid", + "zeroize", ] [[package]] name = "cdk-common" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "448d373b82da04b4bdea60f28ae5b08f057888e1bd96cc3c68270c9fb4b3fbfc" +checksum = "51417f6e891a83a6093065ca52d17ea2e1371d6952f2a209b76e7ecd45977622" dependencies = [ "anyhow", "async-trait", @@ -1261,6 +1268,7 @@ dependencies = [ "ciborium", "futures", "instant", + "lightning 0.1.5", "lightning-invoice 0.33.2", "serde", "serde_json", @@ -1273,9 +1281,9 @@ dependencies = [ [[package]] name = "cdk-signatory" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a43766d6de33b9ac2976ed06c571e6c5b566ee75222f48373964289b604bf4" +checksum = "2f5a3079d8c86ad41961d7910eff164731952ebb9abd30add3c7548af9c1c6f1" dependencies = [ "anyhow", "async-trait", @@ -1293,15 +1301,35 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "cdk-sql-common" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05c7ae82839052fe425347f05ad0f110b99a253af205975ce8ee185026b4aea" +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" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "560aded37253bde9778f86592cfd5c8efb3c67a54891c3ecfad8a43d2b1e255d" +checksum = "a62363768b26cbebb7bd7b731dbb0287f195bd16068abf486c50c0b2f208eacc" dependencies = [ "async-trait", "bitcoin", "cdk-common", + "cdk-sql-common", "lightning-invoice 0.33.2", "rusqlite", "serde", @@ -2286,6 +2314,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 +2970,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 +3089,7 @@ dependencies = [ "bitcoin", "fedimint-core", "fedimint-threshold-crypto", - "lightning", + "lightning 0.0.125", "lightning-invoice 0.32.0", "serde", "serde-big-array", @@ -4068,6 +4102,7 @@ dependencies = [ "harbor-client", "iced", "keyring-lib", + "lightning 0.1.5", "log", "lyon_algorithms", "opener", @@ -4085,6 +4120,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" @@ -4203,7 +4244,7 @@ dependencies = [ "futures-channel", "futures-io", "futures-util", - "idna", + "idna 1.0.3", "ipnet", "once_cell", "rand 0.9.0", @@ -4787,6 +4828,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" @@ -5316,10 +5367,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", ] @@ -5577,6 +5629,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" @@ -5630,6 +5698,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" @@ -5746,6 +5820,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" @@ -7261,6 +7344,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" @@ -10487,6 +10579,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" @@ -10729,7 +10867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", - "idna", + "idna 1.0.3", "percent-encoding", "serde", ] @@ -10793,12 +10931,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]] @@ -10887,24 +11027,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", @@ -10925,9 +11065,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", @@ -10935,9 +11075,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", @@ -10948,9 +11088,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 059b805a..663b23ff 100644 --- a/harbor-client/Cargo.toml +++ b/harbor-client/Cargo.toml @@ -28,8 +28,8 @@ 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", "bip353"] } +cdk-sqlite = { version = "0.12.0", default-features = false, features = ["wallet", "sqlcipher"] } bitcoin = { version = "0.32.4", features = ["base64"] } bip39 = "2.0.0" 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/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 101d87ab..024eb644 100644 --- a/harbor-client/src/cashu_client.rs +++ b/harbor-client/src/cashu_client.rs @@ -10,9 +10,10 @@ 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, - RestoreResponse, SwapRequest, SwapResponse, + MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltQuoteBolt12Request, MeltRequest, MintInfo, + MintQuoteBolt11Request, MintQuoteBolt11Response, MintQuoteBolt12Request, + MintQuoteBolt12Response, MintQuoteState, MintRequest, MintResponse, ProofsMethods, + RestoreRequest, RestoreResponse, SwapRequest, SwapResponse, }; use cdk::util::unix_time; use cdk::wallet::{MeltQuote, MintConnector, MintQuote}; @@ -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"])?; @@ -196,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([0u8; 32]); let params = if is_transfer { SendSuccessMsg::Transfer } else { @@ -286,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}"); } @@ -321,3 +378,110 @@ 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 { + 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_clone.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_clone.id + ); + + match client + .mint_bolt12( + "e_clone.id, + Some(amount_mintable), + SplitTarget::default(), + None, + ) + .await + { + Ok(proofs) => { + log::info!( + "Successfully minted tokens for bolt12 quote {}", + quote_clone.id + ); + + let params = if is_transfer { + ReceiveSuccessMsg::Transfer + } else { + ReceiveSuccessMsg::Lightning + }; + HarborCore::send_msg( + &mut sender, + Some(msg_id), + CoreUIMsg::ReceiveSuccess(params), + ) + .await; + + 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}"); + } + + 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(Arc::clone(&storage), msg_id, &mut sender).await; + } + 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/db.rs b/harbor-client/src/db.rs index b459b1a1..4ca3e893 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, @@ -135,7 +138,21 @@ pub trait DBConnection { fee: Amount, ) -> anyhow::Result<()>; - fn mark_ln_receive_as_success(&self, operation_id: String) -> 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, + amount_msats: Option, + ) -> anyhow::Result<()>; fn mark_ln_receive_as_failed(&self, operation_id: String) -> anyhow::Result<()>; @@ -149,6 +166,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, @@ -320,10 +347,36 @@ impl DBConnection for SQLConnection { Ok(()) } - fn mark_ln_receive_as_success(&self, operation_id: String) -> 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<()> { + 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, + 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(()) } @@ -360,6 +413,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, @@ -472,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() @@ -493,7 +570,28 @@ 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 @@ -801,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); @@ -857,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/lightning_payment.rs b/harbor-client/src/db_models/lightning_payment.rs index 43cbdba8..66d1f3a3 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, @@ -66,12 +68,20 @@ impl LightningPayment { } } - pub fn payment_hash(&self) -> [u8; 32] { - FromHex::from_hex(&self.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) -> Option { + self.bolt11 + .as_ref() + .map(|b| Bolt11Invoice::from_str(b).expect("invalid bolt11")) } - pub fn bolt11(&self) -> Bolt11Invoice { - Bolt11Invoice::from_str(&self.bolt11).expect("invalid bolt11") + pub fn bolt12_offer(&self) -> Option<&str> { + self.bolt12_offer.as_deref() } pub fn amount(&self) -> Amount { @@ -114,8 +124,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..e93611a8 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, }; @@ -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,13 +35,34 @@ 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, } +#[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") @@ -65,12 +87,20 @@ impl LightningReceive { } } - pub fn payment_hash(&self) -> [u8; 32] { - FromHex::from_hex(&self.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).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> { + self.bolt12_offer.as_deref() } pub fn amount(&self) -> Amount { @@ -108,8 +138,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, @@ -135,12 +195,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_or(rec.amount_msats, |a| a as i64); + + 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)), + ) + .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(()) } @@ -163,12 +259,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, Self)>(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..ff666839 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; @@ -38,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, } @@ -51,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/db_models/schema.rs b/harbor-client/src/db_models/schema.rs index a7706dc8..5336868b 100644 --- a/harbor-client/src/db_models/schema.rs +++ b/harbor-client/src/db_models/schema.rs @@ -1,4 +1,14 @@ // @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) { @@ -21,8 +31,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 +48,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, @@ -104,6 +116,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)); @@ -114,6 +127,7 @@ diesel::allow_tables_to_appear_in_same_query!( fedimint, lightning_payments, lightning_receives, + lightning_receive_payments, mint_metadata, on_chain_payments, on_chain_receives, diff --git a/harbor-client/src/fedimint_client.rs b/harbor-client/src/fedimint_client.rs index fc321c31..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()) + 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()) + 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-client/src/lib.rs b/harbor-client/src/lib.rs index 8e99dc7c..f42fd688 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; @@ -41,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}; @@ -139,11 +140,25 @@ 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, amount_sats: u64, }, + SendBip353 { + mint: MintIdentifier, + address: String, + amount_sats: u64, + }, ReceiveLightning { mint: MintIdentifier, amount: Amount, @@ -206,6 +221,7 @@ pub enum CoreUIMsg { SendFailure(String), ReceiveGenerating, ReceiveInvoiceGenerated(Bolt11Invoice), + ReceiveBolt12OfferGenerated(String), ReceiveAddressGenerated(Address), ReceiveSuccess(ReceiveSuccessMsg), ReceiveFailed(String), @@ -372,14 +388,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.payment_method == cdk::nuts::PaymentMethod::Bolt12 { + // 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 +820,163 @@ 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; + + // Persist the outgoing bolt12 payment so completion can update it later + 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_sats(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!("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}"); + + // 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(), + 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, @@ -832,6 +1019,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, @@ -1219,7 +1489,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( @@ -1234,7 +1504,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; @@ -1380,7 +1650,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( @@ -1553,6 +1823,7 @@ impl HarborCore { module_kinds: Some(module_kinds), metadata: metadata.unwrap_or_default(), on_chain_supported, + bolt12_supported: false, active: true, }); } @@ -1577,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 @@ -1588,6 +1868,7 @@ impl HarborCore { module_kinds: None, metadata, on_chain_supported: false, + bolt12_supported, active: true, }); } @@ -1623,6 +1904,7 @@ impl HarborCore { module_kinds: None, metadata: m.into(), on_chain_supported: false, + bolt12_supported: false, active: false, }; res.push(item); @@ -1643,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/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/bridge.rs b/harbor-ui/src/bridge.rs index 389e535e..2b226b9a 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"), ); @@ -488,347 +488,370 @@ 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"); +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::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") + } + } +} - loop { - let msg = core_handle.recv().await; +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; - 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::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::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::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::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::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 - core.msg( - msg.id, - CoreUIMsg::StatusUpdate { - message: "Removing mint...".to_string(), - operation_id: Some(msg.id), - }, - ) + 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; + } + }, + } +} - 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; - } - } - } - } +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::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 => { + Ok(()) => { if let Ok(new_federation_list) = core.get_mint_items().await { - core.msg(msg.id, CoreUIMsg::MintListUpdated(new_federation_list)) + core.msg(msg_id, CoreUIMsg::MintListUpdated(new_federation_list)) .await; } + core.msg(msg_id, CoreUIMsg::AddMintSuccess(mint)).await; + info!("Rejoined federation: {id}"); } - 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") + } + } + } + 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; + }); + } } } diff --git a/harbor-ui/src/main.rs b/harbor-ui/src/main.rs index 0addd90d..e17350d5 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; @@ -144,6 +145,7 @@ enum UnlockStatus { pub enum ReceiveMethod { #[default] Lightning, + Bolt12, OnChain, } @@ -206,6 +208,7 @@ pub enum Message { Send(String), Transfer, GenerateInvoice, + GenerateBolt12Offer, GenerateAddress, Unlock(String), Init { @@ -277,6 +280,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 +366,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 @@ -401,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); + 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() { @@ -505,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(); @@ -632,8 +760,31 @@ impl HarborWallet { }; if let Ok(invoice) = Bolt11Invoice::from_str(&invoice_str) { - let (id, task) = - self.send_from_ui(UICoreMsg::SendLightning { mint, invoice }); + self.handle_bolt11_send(invoice, mint) + } else if let Ok(offer) = Offer::from_str(&invoice_str) { + self.handle_bolt12_send(offer, mint, invoice_str) + } 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 { @@ -686,7 +837,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 +936,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 +1347,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 { @@ -1214,6 +1422,7 @@ impl HarborWallet { module_kinds: Some(module_kinds), metadata, on_chain_supported: false, + bolt12_supported: false, active: true, }; @@ -1407,3 +1616,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 { .. } => { + Err("Currency offers are not supported".to_string()) + } + } + } else { + Ok(None) + } +} diff --git a/harbor-ui/src/routes/receive.rs b/harbor-ui/src/routes/receive.rs index 0063a60c..483594da 100644 --- a/harbor-ui/src/routes/receive.rs +++ b/harbor-ui/src/routes/receive.rs @@ -10,12 +10,16 @@ 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 { + harbor.receive_bolt12_offer.clone() + }; + + if let Some(receive_string) = receive_string { render_generated_view(receive_string, harbor) } else { render_receive_form(harbor) @@ -35,18 +39,29 @@ fn render_receive_form(harbor: &HarborWallet) -> Element { .active_federation() .is_some_and(|x| x.on_chain_supported)); + let bolt12_enabled = if let Some(info) = harbor.active_federation() { + info.bolt12_supported + } else { + false + }; + let header = if on_chain_enabled { - h_header("Deposit", "Receive on-chain or via lightning.") + 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.") }; - 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,12 @@ 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 +201,57 @@ 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 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); + } - 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); + 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..3b5f639c 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 or lightning invoice."); + let header = h_header( + "Send", + "Send to an on-chain address, lightning invoice, Bolt12 offer, or BIP353 address.", + ); let dest_input = h_input(InputArgs { label: "Destination",