From e10fbe89cee16325a29c82ba16ee9d25114e613b Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 26 Jun 2026 10:34:48 -0400 Subject: [PATCH] feat(boltz): add Boltz submarine & reverse swaps with deterministic keys Integrate Boltz submarine (onchain -> Lightning) and reverse (Lightning -> onchain) swaps behind the UniFFI surface. Swap keys and reverse-swap preimages are derived deterministically from the wallet seed via Boltz's BIP85 scheme (SwapMasterKey/derive_swapkey, Preimage::from_swap_key). No key material is persisted: boltz.db stores only a monotonic per-swap derivation index, so a leaked database cannot move funds and swaps are recoverable from the seed alone (or via Boltz's rescue API if boltz.db is lost). - Submarine create/refund and reverse create/claim, with cooperative key-path then script-path fallback (delegated to boltz-client). - Managed WebSocket updates stream that auto-claims confirmed reverse swaps; mnemonic held in memory only for the stream's lifetime. - Atomic, collision-free swap-index reservation; schema user_version anchor; input validation on create. - Idempotent claim/refund (returns the recorded txid without re-broadcasting). - SQLite persistence, typed lifecycle status with forward-compatible Unknown { raw }, and recovery/listing APIs. - Unit tests for status mapping, DB round-trip, index reservation, and deterministic derivation; ignored live E2E test against the Boltz API. --- Cargo.lock | 584 +++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/lib.rs | 275 +++++++++++++++- src/modules/activity/types.rs | 12 + src/modules/boltz/README.md | 303 ++++++++++++++++++ src/modules/boltz/api.rs | 224 +++++++++++++ src/modules/boltz/claim.rs | 74 +++++ src/modules/boltz/client.rs | 70 ++++ src/modules/boltz/db.rs | 226 +++++++++++++ src/modules/boltz/errors.rs | 56 ++++ src/modules/boltz/listener.rs | 211 ++++++++++++ src/modules/boltz/mod.rs | 21 ++ src/modules/boltz/models.rs | 155 +++++++++ src/modules/boltz/refund.rs | 62 ++++ src/modules/boltz/tests.rs | 262 +++++++++++++++ src/modules/boltz/types.rs | 282 ++++++++++++++++ src/modules/mod.rs | 1 + 17 files changed, 2814 insertions(+), 5 deletions(-) create mode 100644 src/modules/boltz/README.md create mode 100644 src/modules/boltz/api.rs create mode 100644 src/modules/boltz/claim.rs create mode 100644 src/modules/boltz/client.rs create mode 100644 src/modules/boltz/db.rs create mode 100644 src/modules/boltz/errors.rs create mode 100644 src/modules/boltz/listener.rs create mode 100644 src/modules/boltz/mod.rs create mode 100644 src/modules/boltz/models.rs create mode 100644 src/modules/boltz/refund.rs create mode 100644 src/modules/boltz/tests.rs create mode 100644 src/modules/boltz/types.rs diff --git a/Cargo.lock b/Cargo.lock index b4723da..cb2b635 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,7 +88,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826" dependencies = [ "android_log-sys", - "env_filter", + "env_filter 0.1.4", "log", ] @@ -101,12 +101,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -224,6 +268,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -270,6 +336,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "base85" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36915bbaca237c626689b5bd14d02f2ba7a5a359d30a2a08be697392e3718079" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "basic-toml" version = "0.1.10" @@ -289,7 +364,7 @@ dependencies = [ "bdk-macros", "bip39", "bitcoin 0.30.2", - "electrum-client", + "electrum-client 0.18.0", "getrandom 0.2.17", "js-sys", "log", @@ -356,6 +431,19 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "bip85_extended" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f300582d6cf7cb4b1f59971f14b4353015a3538b3aa048e3de85c7ef80ac34c9" +dependencies = [ + "base64 0.22.1", + "base85", + "bip39", + "bitcoin 0.32.8", + "sha3", +] + [[package]] name = "bitcoin" version = "0.30.2" @@ -378,6 +466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" dependencies = [ "base58ck", + "base64 0.21.7", "bech32 0.11.1", "bitcoin-internals", "bitcoin-io", @@ -484,12 +573,13 @@ dependencies = [ "bip39", "bitcoin 0.32.8", "bitcoin-address-generator", + "boltz-client", "btleplug", "chrono", "hex", "jni", "lazy-regex", - "lightning-invoice", + "lightning-invoice 0.32.0", "lnurl-rs", "log", "once_cell", @@ -609,6 +699,50 @@ dependencies = [ "dbus", ] +[[package]] +name = "boltz-client" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "889efede8a28a19c3f6088ab5406bf1b8379a21a3470f24a5cef4cc6c7122346" +dependencies = [ + "async-trait", + "bip39", + "bip85_extended", + "bitcoin 0.32.8", + "boltz-client-macros", + "electrum-client 0.25.0", + "elements", + "env_logger", + "futures-util", + "getrandom 0.2.17", + "gloo-timers", + "hex", + "js-sys", + "lightning", + "lightning-invoice 0.34.1", + "log", + "reqwest", + "secp256k1 0.32.0-beta.2", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite-wasm", + "url", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "boltz-client-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bae3bbd82eb8c315b3b120d7cb1a8adef85003216315c42e23abead43ae3835" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "borsh" version = "1.6.0" @@ -757,6 +891,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -853,6 +989,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cobs" version = "0.3.0" @@ -862,6 +1007,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -1126,6 +1277,12 @@ dependencies = [ "parking_lot_core 0.9.12", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "dbus" version = "0.9.10" @@ -1150,6 +1307,38 @@ dependencies = [ "tokio", ] +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "der" version = "0.7.10" @@ -1198,6 +1387,12 @@ dependencies = [ "syn 2.0.117", ] +[[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.12" @@ -1216,6 +1411,12 @@ dependencies = [ "phf", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1285,6 +1486,36 @@ dependencies = [ "winapi", ] +[[package]] +name = "electrum-client" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1970c5d7bd9de6d4041cbfc3e46faa3e85fe7efcea2b0eb06750d3ae1ef577b7" +dependencies = [ + "bitcoin 0.32.8", + "byteorder", + "libc", + "log", + "rustls 0.23.37", + "serde", + "serde_json", + "webpki-roots 0.25.4", + "winapi", +] + +[[package]] +name = "elements" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1210978e2686f892318085f806866014d4788479a37f0a634db97fcb39454bde" +dependencies = [ + "bech32 0.11.1", + "bitcoin 0.32.8", + "secp256k1-zkp", + "serde", + "serde_json", +] + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -1334,6 +1565,29 @@ dependencies = [ "regex", ] +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter 2.0.0", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1466,6 +1720,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "2.0.0" @@ -1694,6 +1954,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "goblin" version = "0.8.2" @@ -1734,6 +2006,12 @@ dependencies = [ "ahash 0.7.8", ] +[[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" @@ -2162,6 +2440,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -2177,6 +2461,31 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jiff" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "jni" version = "0.19.0" @@ -2212,6 +2521,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -2234,6 +2553,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "lazy-regex" version = "3.6.0" @@ -2284,6 +2612,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -2307,6 +2641,23 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "lightning" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45c421e68d603b989c8f59a95a1860f1f5de90a6305ed5bcb6d9b35a3a11f450" +dependencies = [ + "bech32 0.11.1", + "bitcoin 0.32.8", + "dnssec-prover", + "hashbrown 0.13.2", + "libm", + "lightning-invoice 0.34.1", + "lightning-macros", + "lightning-types 0.3.2", + "possiblyrandom", +] + [[package]] name = "lightning-invoice" version = "0.32.0" @@ -2315,7 +2666,29 @@ checksum = "90ab9f6ea77e20e3129235e62a2e6bd64ed932363df104e864ee65ccffb54a8f" dependencies = [ "bech32 0.9.1", "bitcoin 0.32.8", - "lightning-types", + "lightning-types 0.1.0", +] + +[[package]] +name = "lightning-invoice" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d83bd798e04ab9eecc8bbef1fa17d3808859bcdc0406bd16c55d51c8834444" +dependencies = [ + "bech32 0.11.1", + "bitcoin 0.32.8", + "lightning-types 0.3.2", +] + +[[package]] +name = "lightning-macros" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c717494cdc2c8bb85bee7113031248f5f6c64f8802b33c1c9e2d98e594aa71" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -2329,6 +2702,15 @@ dependencies = [ "hex-conservative", ] +[[package]] +name = "lightning-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c211dfcff95ca308247da8b1e0e81604bc9e568239967cd2c34572558511e869" +dependencies = [ + "bitcoin 0.32.8", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2641,6 +3023,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -2939,6 +3327,30 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "possiblyrandom" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c564dbf654befd49035528299f1208a40508f6e07efb11c163444e304e4484f" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "postcard" version = "1.1.3" @@ -2995,6 +3407,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3661,6 +4095,8 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -3718,6 +4154,7 @@ version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3882,6 +4319,16 @@ dependencies = [ "secp256k1-sys 0.10.1", ] +[[package]] +name = "secp256k1" +version = "0.32.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5fdc7d6e800869d3fd60ff857c479bf0a83ea7bf44b389e64461e844204994" +dependencies = [ + "rand 0.9.2", + "secp256k1-sys 0.12.0", +] + [[package]] name = "secp256k1-sys" version = "0.8.2" @@ -3900,6 +4347,38 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d3be00697c88c00fe102af8dc316038cc2062eab8da646e7463f4c0e70ca9fd" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-zkp" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a44aed3002b5ae975f8624c5df3a949cfbf00479e18778b6058fcd213b76e3" +dependencies = [ + "bitcoin-private", + "rand 0.8.5", + "secp256k1 0.29.1", + "secp256k1-zkp-sys", + "serde", +] + +[[package]] +name = "secp256k1-zkp-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f08b2d0b143a22e07f798ae4f0ab20d5590d7c68e0d090f2088a48a21d1654" +dependencies = [ + "cc", + "secp256k1-sys 0.10.1", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4063,6 +4542,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -4080,6 +4570,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -4529,6 +5029,42 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls 0.23.37", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-tungstenite-wasm" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4585aa997e4afb43c64f9101c27411b8e1bf9dde49b22e3e47acdde3055b325c" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "httparse", + "js-sys", + "rustls 0.23.37", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4728,6 +5264,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "rustls 0.23.37", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -4924,12 +5479,24 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.22.0" @@ -5141,6 +5708,15 @@ version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + [[package]] name = "webpki-roots" version = "1.0.6" diff --git a/Cargo.toml b/Cargo.toml index 1da0bca..630e59a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ uuid = { version = "1.16.0", features = ["v4"] } hex = "0.4.3" bip39 = "2.0" bdk = { version = "0.30.2", features = ["all-keys"] } +boltz-client = { version = "0.4.1", default-features = false, features = ["electrum", "ws"] } base64 = "0.22" log = "0.4" pubky = "0.6.0" diff --git a/src/lib.rs b/src/lib.rs index 65a4e62..0ad4cb3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,6 +32,10 @@ use crate::modules::blocktank::{ IBt0ConfMinTxFeeWindow, IBtBolt11Invoice, IBtEstimateFeeResponse, IBtEstimateFeeResponse2, IBtInfo, IBtOrder, ICJitEntry, IGift, }; +use crate::modules::boltz::{ + self, BoltzDB, BoltzError, BoltzEventListener, BoltzNetwork, BoltzPairInfo, BoltzSwap, + ReverseSwapResponse, SubmarineSwapResponse, +}; use crate::modules::pubky::{PubkyAuthDetails, PubkyAuthKind, PubkyError, PubkyProfile}; use crate::modules::trezor::account_type_to_script_type; pub use crate::modules::trezor::{ @@ -60,6 +64,7 @@ use crate::onchain::{ start_watcher, stop_all_watchers, stop_watcher, EventListener, WatcherParams, }; pub use modules::activity; +pub use modules::boltz as boltz_swaps; pub use modules::lnurl; pub use modules::onchain; pub use modules::scanner::{DecodingError, Scanner}; @@ -78,6 +83,7 @@ pub struct DatabaseConnections { pub struct AsyncDatabaseConnections { blocktank_db: Option, + boltz_db: Option>, } // Two separate global states for sync and async connections static DB: OnceCell> = OnceCell::new(); @@ -439,7 +445,12 @@ pub fn init_db(base_path: String) -> Result { DB.get_or_init(|| StdMutex::new(DatabaseConnections { activity_db: None })); // Initialize async database state - ASYNC_DB.get_or_init(|| TokioMutex::new(AsyncDatabaseConnections { blocktank_db: None })); + ASYNC_DB.get_or_init(|| { + TokioMutex::new(AsyncDatabaseConnections { + blocktank_db: None, + boltz_db: None, + }) + }); // Create runtime for async operations let rt = ensure_runtime(); @@ -447,6 +458,7 @@ pub fn init_db(base_path: String) -> Result { let activity_db = ActivityDB::new(&format!("{}/activity.db", base_path))?; let blocktank_db = rt .block_on(async { BlocktankDB::new(&format!("{}/blocktank.db", base_path), None).await })?; + let boltz_db = rt.block_on(async { BoltzDB::new(&format!("{}/boltz.db", base_path)).await })?; // Initialize sync database { @@ -460,6 +472,7 @@ pub fn init_db(base_path: String) -> Result { rt.block_on(async { let mut guard = async_db.lock().await; guard.blocktank_db = Some(blocktank_db); + guard.boltz_db = Some(Arc::new(boltz_db)); }); } @@ -2698,3 +2711,263 @@ pub fn onchain_stop_watcher(watcher_id: String) -> Result<(), AccountInfoError> pub fn onchain_stop_all_watchers() { stop_all_watchers(); } + +// ============================================================================ +// Boltz swaps +// ============================================================================ + +/// Clone the shared Boltz database handle. The handle is held only briefly +/// while cloning, so long-running swap operations don't block other callers. +async fn get_boltz_db() -> Result, BoltzError> { + let cell = ASYNC_DB.get().ok_or(BoltzError::ConnectionError { + error_details: "Database not initialized. Call init_db first.".to_string(), + })?; + let guard = cell.lock().await; + guard.boltz_db.clone().ok_or(BoltzError::ConnectionError { + error_details: "Database not initialized. Call init_db first.".to_string(), + }) +} + +fn boltz_runtime_err(e: tokio::task::JoinError) -> BoltzError { + BoltzError::ConnectionError { + error_details: format!("Runtime error: {}", e), + } +} + +/// Fetch fees and limits for submarine swaps (onchain -> Lightning). +#[uniffi::export] +pub async fn boltz_get_submarine_limits( + network: BoltzNetwork, +) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { boltz::get_submarine_limits(network).await }) + .await + .unwrap_or_else(|e| Err(boltz_runtime_err(e))) +} + +/// Fetch fees and limits for reverse swaps (Lightning -> onchain). +#[uniffi::export] +pub async fn boltz_get_reverse_limits(network: BoltzNetwork) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { boltz::get_reverse_limits(network).await }) + .await + .unwrap_or_else(|e| Err(boltz_runtime_err(e))) +} + +/// Create a submarine swap (onchain -> Lightning). +/// +/// `invoice` is a BOLT11 invoice the caller's Lightning node generated. The +/// caller funds the returned lockup address from its onchain wallet. The refund +/// key is derived deterministically from `mnemonic` (only the derivation index +/// is persisted, never the key), and the swap is tracked if an updates stream is +/// running. `bip39_passphrase` must match the wallet's, or refunds will derive +/// the wrong key. +#[uniffi::export] +pub async fn boltz_create_submarine_swap( + network: BoltzNetwork, + electrum_url: String, + invoice: String, + mnemonic: String, + bip39_passphrase: Option, +) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { + let db = get_boltz_db().await?; + let response = db + .create_submarine_swap(network, electrum_url, invoice, mnemonic, bip39_passphrase) + .await?; + boltz::subscribe_if_active(network, &response.id).await; + Ok(response) + }) + .await + .unwrap_or_else(|e| Err(boltz_runtime_err(e))) +} + +/// Create a reverse swap (Lightning -> onchain). +/// +/// The caller pays the returned hold invoice from its Lightning node; +/// `claim_address` is the onchain address the received funds are claimed to. +/// The claim key and preimage are derived deterministically from `mnemonic` +/// (only the derivation index is persisted, never the secrets) so the claim can +/// be made automatically once Boltz locks the funds. `bip39_passphrase` must +/// match the wallet's, or claims will derive the wrong key. +#[uniffi::export] +pub async fn boltz_create_reverse_swap( + network: BoltzNetwork, + electrum_url: String, + amount_sat: u64, + claim_address: String, + mnemonic: String, + bip39_passphrase: Option, +) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { + let db = get_boltz_db().await?; + let response = db + .create_reverse_swap( + network, + electrum_url, + amount_sat, + claim_address, + mnemonic, + bip39_passphrase, + ) + .await?; + boltz::subscribe_if_active(network, &response.id).await; + Ok(response) + }) + .await + .unwrap_or_else(|e| Err(boltz_runtime_err(e))) +} + +/// List every persisted swap, newest first. +#[uniffi::export] +pub async fn boltz_list_swaps() -> Result, BoltzError> { + let rt = ensure_runtime(); + rt.spawn(async move { + let db = get_boltz_db().await?; + Ok(db + .list_swaps() + .await? + .iter() + .map(|r| r.to_boltz_swap()) + .collect()) + }) + .await + .unwrap_or_else(|e| Err(boltz_runtime_err(e))) +} + +/// List swaps that have not reached a terminal state (for recovery/resume). +#[uniffi::export] +pub async fn boltz_list_pending_swaps() -> Result, BoltzError> { + let rt = ensure_runtime(); + rt.spawn(async move { + let db = get_boltz_db().await?; + Ok(db + .list_pending_swaps() + .await? + .iter() + .map(|r| r.to_boltz_swap()) + .collect()) + }) + .await + .unwrap_or_else(|e| Err(boltz_runtime_err(e))) +} + +/// Fetch a single swap by id. +#[uniffi::export] +pub async fn boltz_get_swap(swap_id: String) -> Result, BoltzError> { + let rt = ensure_runtime(); + rt.spawn(async move { + let db = get_boltz_db().await?; + Ok(db.get_swap(&swap_id).await?.map(|r| r.to_boltz_swap())) + }) + .await + .unwrap_or_else(|e| Err(boltz_runtime_err(e))) +} + +/// Claim a reverse swap's onchain funds to its claim address, returning the +/// broadcast claim transaction id. Normally happens automatically via the +/// updates stream; exposed for manual recovery. The claim key is re-derived from +/// `mnemonic`. If the swap was already claimed, the existing claim txid is +/// returned without re-broadcasting. +#[uniffi::export] +pub async fn boltz_claim_reverse_swap( + swap_id: String, + mnemonic: String, + bip39_passphrase: Option, + fee_rate_sat_per_vb: Option, +) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { + let db = get_boltz_db().await?; + let record = db.get_swap(&swap_id).await?.ok_or(BoltzError::NotFound { + error_details: format!("Swap {} not found", swap_id), + })?; + // Idempotent: don't re-broadcast a swap that already has a claim tx + // (e.g. an auto-claim that ran first). + if let Some(existing) = record.claim_tx_id.clone() { + return Ok(existing); + } + let txid = boltz::claim_reverse_swap( + &record, + &mnemonic, + bip39_passphrase.as_deref(), + fee_rate_sat_per_vb, + ) + .await?; + db.set_claim_tx(&swap_id, &txid).await?; + Ok(txid) + }) + .await + .unwrap_or_else(|e| Err(boltz_runtime_err(e))) +} + +/// Refund a submarine swap's locked funds to `refund_address`, returning the +/// broadcast refund transaction id. Used when Boltz fails to pay the invoice or +/// the swap expires. The refund key is re-derived from `mnemonic`. If the swap +/// was already refunded, the existing refund txid is returned without +/// re-broadcasting. +#[uniffi::export] +pub async fn boltz_refund_submarine_swap( + swap_id: String, + refund_address: String, + mnemonic: String, + bip39_passphrase: Option, + fee_rate_sat_per_vb: Option, +) -> Result { + let rt = ensure_runtime(); + rt.spawn(async move { + let db = get_boltz_db().await?; + let record = db.get_swap(&swap_id).await?.ok_or(BoltzError::NotFound { + error_details: format!("Swap {} not found", swap_id), + })?; + // Idempotent: don't re-broadcast a swap that already has a refund tx. + if let Some(existing) = record.refund_tx_id.clone() { + return Ok(existing); + } + let txid = boltz::refund_submarine_swap( + &record, + refund_address, + &mnemonic, + bip39_passphrase.as_deref(), + fee_rate_sat_per_vb, + ) + .await?; + db.set_refund_tx(&swap_id, &txid).await?; + Ok(txid) + }) + .await + .unwrap_or_else(|e| Err(boltz_runtime_err(e))) +} + +/// Open a Boltz WebSocket for `network`, subscribe to all pending swaps, and +/// drive their lifecycle (auto-claiming reverse swaps) until stopped. Replaces +/// any previously running updates stream (only one network is tracked at a +/// time). `mnemonic` is held in memory for the lifetime of the stream so +/// confirmed reverse swaps can be auto-claimed; it is never persisted. Events +/// are delivered to `listener`. +#[uniffi::export] +pub async fn boltz_start_swap_updates( + network: BoltzNetwork, + listener: Arc, + mnemonic: String, + bip39_passphrase: Option, +) -> Result<(), BoltzError> { + let rt = ensure_runtime(); + rt.spawn(async move { + let db = get_boltz_db().await?; + boltz::start_swap_updates(db, network, listener, mnemonic, bip39_passphrase).await + }) + .await + .unwrap_or_else(|e| Err(boltz_runtime_err(e))) +} + +/// Stop the running Boltz updates stream, if any. +#[uniffi::export] +pub async fn boltz_stop_swap_updates() { + let rt = ensure_runtime(); + let _ = rt + .spawn(async move { boltz::stop_swap_updates().await }) + .await; +} diff --git a/src/modules/activity/types.rs b/src/modules/activity/types.rs index 3812c32..da2fc01 100644 --- a/src/modules/activity/types.rs +++ b/src/modules/activity/types.rs @@ -1,5 +1,6 @@ use crate::activity::ActivityError; use crate::modules::blocktank::BlocktankError; +use crate::modules::boltz::BoltzError; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -306,6 +307,9 @@ pub enum DbError { #[error("DB Blocktank Error: {error_details}")] DbBlocktankError { error_details: BlocktankError }, + #[error("DB Boltz Error: {error_details}")] + DbBoltzError { error_details: BoltzError }, + #[error("Initialization Error: {error_details}")] InitializationError { error_details: String }, } @@ -325,3 +329,11 @@ impl From for DbError { } } } + +impl From for DbError { + fn from(error: BoltzError) -> Self { + DbError::DbBoltzError { + error_details: error, + } + } +} diff --git a/src/modules/boltz/README.md b/src/modules/boltz/README.md new file mode 100644 index 0000000..17101bc --- /dev/null +++ b/src/modules/boltz/README.md @@ -0,0 +1,303 @@ +# Boltz Module + +This module integrates [Boltz](https://boltz.exchange) submarine and reverse +swaps so funds can move between onchain Bitcoin and Lightning channels: + +- **Submarine swap** (onchain → Lightning): you lock onchain BTC, Boltz pays a + Lightning invoice your node generated. Use this to **add** Lightning balance + from onchain funds. +- **Reverse swap** (Lightning → onchain): you pay a Boltz hold invoice over + Lightning, Boltz locks onchain BTC, and this module claims it to your onchain + address. Use this to **drain** Lightning balance to onchain. + +The dangerous cryptography (MuSig2 Taproot cooperative signing, swap scripts, +claim/refund transaction construction) is handled by the +[`boltz-client`](https://crates.io/crates/boltz-client) crate. This module adds +persistence, lifecycle tracking, automatic claiming, and the FFI surface. + +## Responsibility split with the app + +bitkit-core does **not** own the Lightning node. The app (via LDK Node) and +bitkit-core cooperate: + +| Step | Owner | +|------|-------| +| Generate the BOLT11 invoice (submarine) | **App** — `node.bolt11Payment().receive(...)` | +| Pay the hold invoice (reverse) | **App** — `node.bolt11Payment().send(...)` | +| Provide an onchain claim address (reverse) | **App** — `node.onchainPayment().newAddress()` | +| Send the onchain lockup (submarine) | **App** — `node.onchainPayment().sendToAddress(...)` | +| Call Boltz, derive keys & preimage, track status | **bitkit-core** | +| Build, sign and broadcast claim/refund transactions | **bitkit-core** | + +The app passes its **wallet mnemonic** (and BIP39 passphrase, if any) to the +create/claim/refund/start-updates calls. + +## Keys, secrets & recovery + +Swap keys are **derived deterministically from the wallet seed**, never randomly +generated and never stored. Each swap uses a unique index under Boltz's BIP85 +scheme (`m/26589'/0'/0'/{index}`); for reverse swaps the preimage is +`sha256(swapKey)`. `boltz.db` persists only that **index** (plus status and +metadata) — it holds **no key material**, so a leaked database cannot move funds. + +This makes swaps recoverable two independent ways: + +1. **Same device / restored `boltz.db`** — the index is on disk; combine it with + the in-memory seed to re-derive keys. Use `boltzListPendingSwaps` after + `boltzStartSwapUpdates` on startup. +2. **Seed only (`boltz.db` lost)** — because keys derive from the seed, an + in-flight swap can still be recovered: the same BIP85 swap mnemonic can be + registered with Boltz's rescue API + (`https://boltz.exchange/rescue/external?mode=rescue-key`) to re-enumerate + swaps and re-derive their keys by scanning indices. + +> **The BIP39 passphrase must match the wallet's.** Keys derived under the wrong +> passphrase (or a typo'd mnemonic) will not control the locked funds. Pass the +> exact same `mnemonic`/`bip39Passphrase` used by the wallet to every Boltz call. + +Secrets exist in process memory only while a swap is being created, claimed or +refunded; the background updates stream additionally holds the mnemonic in memory +for its lifetime (dropped on `boltzStopSwapUpdates`) so it can auto-claim. As +elsewhere in the app, `boltz.db` relies on the platform's app-sandbox/filesystem +encryption at rest. + +## Lifecycle + +Status strings mirror the [Boltz lifecycle](https://api.docs.boltz.exchange/lifecycle.html) +and are surfaced as the typed `BoltzSwapStatus` enum (unknown future states fall +back to `Unknown { raw }`). Register a `BoltzEventListener` via +`boltzStartSwapUpdates` to receive `BoltzSwapEvent`s over a managed WebSocket. + +**Reverse swaps are claimed automatically**: once Boltz's lockup reaches +`transaction.confirmed`, this module builds and broadcasts the claim transaction +(cooperative key-path first, script-path fallback) and emits +`BoltzSwapEvent.Claimed { txid }`. Claiming on confirmation (not mempool) avoids +revealing the preimage before the lockup is final; call `boltzClaimReverseSwap` +manually if you accept the 0-conf risk. + +**Submarine refunds are manual** (the module needs a destination address): on +`invoice.failedToPay` / `transaction.lockupFailed` / `swap.expired`, call +`boltzRefundSubmarineSwap` with an onchain address. + +## FFI surface + +``` +boltzGetSubmarineLimits(network) -> BoltzPairInfo +boltzGetReverseLimits(network) -> BoltzPairInfo +boltzCreateSubmarineSwap(network, electrumUrl, invoice, mnemonic, bip39Passphrase?) -> SubmarineSwapResponse +boltzCreateReverseSwap(network, electrumUrl, amountSat, claimAddress, mnemonic, bip39Passphrase?) -> ReverseSwapResponse +boltzListSwaps() -> [BoltzSwap] +boltzListPendingSwaps() -> [BoltzSwap] +boltzGetSwap(swapId) -> BoltzSwap? +boltzClaimReverseSwap(swapId, mnemonic, bip39Passphrase?, feeRateSatPerVb?) -> String (txid) +boltzRefundSubmarineSwap(swapId, refundAddress, mnemonic, bip39Passphrase?, feeRateSatPerVb?) -> String (txid) +boltzStartSwapUpdates(network, listener, mnemonic, bip39Passphrase?) // managed WebSocket +boltzStopSwapUpdates() +``` + +`network` is `BoltzNetwork.{Mainnet, Testnet, Regtest}`. `electrumUrl` accepts +`ssl://host:port`, `tcp://host:port`, or a bare `host:port` (treated as TLS) and +is stored per-swap for later claim/refund broadcasting. `mnemonic` is the wallet +mnemonic; pass the same `bip39Passphrase` the wallet uses (omit/`null` if none). + +`boltzClaimReverseSwap` and `boltzRefundSubmarineSwap` are **idempotent**: if the +swap already has a recorded claim/refund tx, the existing txid is returned +without re-broadcasting. + +**Only one updates stream runs at a time.** `boltzStartSwapUpdates` stops any +previous stream, so a single network is tracked at once; call it again to switch +networks. + +## Usage Examples + +### Reverse swap — Lightning → onchain + +#### iOS (Swift) +```swift +import BitkitCore + +// 1. Register a listener once (auto-claims reverse swaps). +final class SwapListener: BoltzEventListener { + func onEvent(event: BoltzSwapEvent) { + switch event { + case .statusUpdate(let swapId, let status): + print("swap \(swapId): \(status)") + case .claimed(let swapId, let txid): + print("reverse swap \(swapId) claimed in \(txid)") + case .refunded(let swapId, let txid): + print("swap \(swapId) refunded in \(txid)") + case .error(let swapId, let message): + print("swap \(swapId) error: \(message)") + } + } +} +// `mnemonic` is the wallet's seed phrase; pass the wallet's BIP39 passphrase too +// (or nil). It's held in memory for the stream's lifetime to auto-claim. +try await boltzStartSwapUpdates( + network: .mainnet, + listener: SwapListener(), + mnemonic: wallet.mnemonic, + bip39Passphrase: nil +) + +func drainToOnchain(amountSat: UInt64) async throws { + // 2. A fresh onchain address from the LDK node receives the funds. + let claimAddress = try lightningService.node.onchainPayment().newAddress() + + // 3. Create the swap and pay its hold invoice over Lightning. + let swap = try await boltzCreateReverseSwap( + network: .mainnet, + electrumUrl: "ssl://electrum.blockstream.info:50002", + amountSat: amountSat, + claimAddress: claimAddress, + mnemonic: wallet.mnemonic, + bip39Passphrase: nil + ) + _ = try lightningService.node.bolt11Payment().send(invoice: swap.invoice, sendingParameters: nil) + + // 4. Once Boltz locks & confirms onchain, the module auto-claims and the + // listener reports `.claimed`. Nothing else to do. +} +``` + +#### Android (Kotlin) +```kotlin +import com.synonym.bitkitcore.* + +class SwapListener : BoltzEventListener { + override fun onEvent(event: BoltzSwapEvent) { + when (event) { + is BoltzSwapEvent.StatusUpdate -> println("swap ${event.swapId}: ${event.status}") + is BoltzSwapEvent.Claimed -> println("reverse swap ${event.swapId} claimed in ${event.txid}") + is BoltzSwapEvent.Refunded -> println("swap ${event.swapId} refunded in ${event.txid}") + is BoltzSwapEvent.Error -> println("swap ${event.swapId} error: ${event.message}") + } + } +} + +suspend fun drainToOnchain(amountSat: ULong) { + // mnemonic = wallet seed phrase; pass the wallet's BIP39 passphrase or null. + boltzStartSwapUpdates(BoltzNetwork.MAINNET, SwapListener(), wallet.mnemonic, null) + + val claimAddress = lightningService.node.onchainPayment().newAddress() + val swap = boltzCreateReverseSwap( + network = BoltzNetwork.MAINNET, + electrumUrl = "ssl://electrum.blockstream.info:50002", + amountSat = amountSat, + claimAddress = claimAddress, + mnemonic = wallet.mnemonic, + bip39Passphrase = null, + ) + lightningService.node.bolt11Payment().send(swap.invoice, null) + // Auto-claimed on confirmation; listener emits Claimed. +} +``` + +#### Python +```python +from bitkitcore import ( + boltz_create_reverse_swap, boltz_start_swap_updates, + BoltzNetwork, BoltzEventListener, +) + +class SwapListener(BoltzEventListener): + def on_event(self, event): + print(event) + +await boltz_start_swap_updates(BoltzNetwork.MAINNET, SwapListener(), wallet_mnemonic, None) +swap = await boltz_create_reverse_swap( + network=BoltzNetwork.MAINNET, + electrum_url="ssl://electrum.blockstream.info:50002", + amount_sat=50_000, + claim_address=claim_address, # from your onchain wallet + mnemonic=wallet_mnemonic, + bip39_passphrase=None, +) +# Pay swap.invoice over Lightning; the module claims onchain automatically. +``` + +### Submarine swap — onchain → Lightning + +#### iOS (Swift) +```swift +func topUpLightning(amountSat: UInt64) async throws { + // 1. Your LDK node issues the invoice Boltz will pay. + let invoice = try lightningService.node.bolt11Payment() + .receive(amountMsat: amountSat * 1000, description: "Boltz top-up", expirySecs: 3600) + + // 2. Create the swap; Boltz returns the lockup address & exact amount. + let swap = try await boltzCreateSubmarineSwap( + network: .mainnet, + electrumUrl: "ssl://electrum.blockstream.info:50002", + invoice: invoice, + mnemonic: wallet.mnemonic, + bip39Passphrase: nil + ) + + // 3. Fund the lockup from your onchain wallet. + _ = try lightningService.node.onchainPayment() + .sendToAddress(address: swap.address, amountSats: swap.expectedAmountSat) + + // 4. Boltz pays the invoice on confirmation (.invoicePaid → .transactionClaimed). + // If it fails, refund onchain (key re-derived from the mnemonic): + // try await boltzRefundSubmarineSwap( + // swapId: swap.id, refundAddress: addr, + // mnemonic: wallet.mnemonic, bip39Passphrase: nil, feeRateSatPerVb: nil) +} +``` + +#### Android (Kotlin) +```kotlin +suspend fun topUpLightning(amountSat: ULong) { + val invoice = lightningService.node.bolt11Payment() + .receive(amountSat * 1000u, "Boltz top-up", 3600u) + + val swap = boltzCreateSubmarineSwap( + network = BoltzNetwork.MAINNET, + electrumUrl = "ssl://electrum.blockstream.info:50002", + invoice = invoice, + mnemonic = wallet.mnemonic, + bip39Passphrase = null, + ) + lightningService.node.onchainPayment().sendToAddress(swap.address, swap.expectedAmountSat) + // On failure: boltzRefundSubmarineSwap(swap.id, refundAddress, wallet.mnemonic, null, null) +} +``` + +## Recovery after restart + +On startup (after `initDb`), resume tracking and surface anything actionable: + +```kotlin +// re-subscribes all pending swaps; holds the mnemonic to auto-claim +boltzStartSwapUpdates(BoltzNetwork.MAINNET, SwapListener(), wallet.mnemonic, null) +val pending = boltzListPendingSwaps() // for UI / manual refunds +``` + +`boltzListPendingSwaps` returns every non-terminal swap; combined with the wallet +seed (keys are re-derived from each swap's index), an interrupted reverse swap can +still be claimed and a failed submarine swap refunded. If `boltz.db` itself was +lost, recover via Boltz's rescue API using the seed (see *Keys, secrets & +recovery* above). + +## Testing + +```bash +cargo test modules::boltz # unit tests (offline) +cargo test modules::boltz -- --ignored --nocapture # live E2E (testnet) +BOLTZ_LIVE_NETWORK=mainnet cargo test modules::boltz -- --ignored --nocapture +``` + +The offline tests cover status mapping, DB round-trip/recovery, monotonic index +reservation, and deterministic key/preimage derivation (same seed+index → +same key; distinct indices/passphrases → distinct keys). The **claim/refund +broadcast paths are not yet covered by an automated test** — they require a +regtest Boltz + Electrum stack; the live test exercises swap creation and +cryptographically validates the locally-derived redeem script and invoice +against Boltz's response, but does not broadcast. A regtest end-to-end test that +actually claims and refunds is the recommended follow-up. + +The live test creates a real reverse swap and asserts the locally-derived redeem +script and invoice match Boltz's response (the guarantee that a later claim is +valid). The swap is never paid and simply expires — no funds move. It skips +gracefully if the Boltz endpoint is temporarily unavailable. diff --git a/src/modules/boltz/api.rs b/src/modules/boltz/api.rs new file mode 100644 index 0000000..a8c4c30 --- /dev/null +++ b/src/modules/boltz/api.rs @@ -0,0 +1,224 @@ +use crate::modules::boltz::client::build_boltz_client; +use crate::modules::boltz::errors::BoltzError; +use crate::modules::boltz::models::{derive_swap_keypair, BoltzDB, SwapRecord}; +use crate::modules::boltz::types::{ + BoltzNetwork, BoltzPairInfo, BoltzSwapType, ReverseSwapResponse, SubmarineSwapResponse, +}; +use boltz_client::swaps::boltz::{CreateReverseRequest, CreateSubmarineRequest}; +use boltz_client::util::secrets::Preimage; + +/// Raw status string a freshly created swap starts in. +const STATUS_CREATED: &str = "swap.created"; + +/// Fetch fees and limits for submarine swaps (onchain -> Lightning) on `network`. +pub async fn get_submarine_limits(network: BoltzNetwork) -> Result { + let client = build_boltz_client(network); + let pairs = client + .get_submarine_pairs() + .await + .map_err(map_api_err("fetch submarine pairs"))?; + let pair = pairs.get_btc_to_btc_pair().ok_or(BoltzError::ApiError { + error_details: "BTC submarine pair unavailable".to_string(), + })?; + Ok(BoltzPairInfo { + hash: pair.hash, + rate: pair.rate, + minimal_sat: pair.limits.minimal, + maximal_sat: pair.limits.maximal, + fee_percentage: pair.fees.percentage, + miner_fees_sat: pair.fees.miner_fees, + }) +} + +/// Fetch fees and limits for reverse swaps (Lightning -> onchain) on `network`. +pub async fn get_reverse_limits(network: BoltzNetwork) -> Result { + let client = build_boltz_client(network); + let pairs = client + .get_reverse_pairs() + .await + .map_err(map_api_err("fetch reverse pairs"))?; + let pair = pairs.get_btc_to_btc_pair().ok_or(BoltzError::ApiError { + error_details: "BTC reverse pair unavailable".to_string(), + })?; + Ok(BoltzPairInfo { + hash: pair.hash, + rate: pair.rate, + minimal_sat: pair.limits.minimal, + maximal_sat: pair.limits.maximal, + fee_percentage: pair.fees.percentage, + miner_fees_sat: pair.fees.miner_fees.lockup + pair.fees.miner_fees.claim, + }) +} + +impl BoltzDB { + /// Create a submarine swap (onchain -> Lightning). + /// + /// `invoice` is the BOLT11 invoice the caller's Lightning node generated for + /// the amount it wants to receive. Boltz returns a lockup address the caller + /// funds from its onchain wallet. The refund key is derived deterministically + /// from `mnemonic` at a freshly reserved index (persisted, not the key), so + /// the swap can be refunded — and recovered from the seed — if Boltz fails to + /// pay the invoice. + pub async fn create_submarine_swap( + &self, + network: BoltzNetwork, + electrum_url: String, + invoice: String, + mnemonic: String, + bip39_passphrase: Option, + ) -> Result { + if invoice.trim().is_empty() { + return Err(BoltzError::InvalidInput { + error_details: "invoice must not be empty".to_string(), + }); + } + + let swap_index = self.reserve_swap_index().await?; + let keypair = + derive_swap_keypair(&mnemonic, bip39_passphrase.as_deref(), network, swap_index)?; + let refund_public_key = bitcoin::PublicKey::new(keypair.public_key()); + + let client = build_boltz_client(network); + let request = CreateSubmarineRequest { + from: "BTC".to_string(), + to: "BTC".to_string(), + invoice: invoice.clone(), + refund_public_key, + pair_hash: None, + referral_id: None, + webhook: None, + }; + let response = client + .post_swap_req(&request) + .await + .map_err(map_api_err("create submarine swap"))?; + + let record = SwapRecord { + id: response.id.clone(), + swap_type: BoltzSwapType::Submarine, + status: STATUS_CREATED.to_string(), + network, + electrum_url, + swap_index, + invoice: Some(invoice), + lockup_address: Some(response.address.clone()), + onchain_address: None, + amount_sat: response.expected_amount, + onchain_amount_sat: None, + timeout_block_height: response.timeout_block_height, + create_response_json: serde_json::to_string(&response)?, + claim_tx_id: None, + refund_tx_id: None, + created_at: now_secs(), + }; + self.insert_swap(&record).await?; + + Ok(SubmarineSwapResponse { + id: response.id, + address: response.address, + bip21: response.bip21, + expected_amount_sat: response.expected_amount, + accept_zero_conf: response.accept_zero_conf, + timeout_block_height: response.timeout_block_height, + }) + } + + /// Create a reverse swap (Lightning -> onchain). + /// + /// `amount_sat` is the Lightning amount the caller will pay; `claim_address` + /// is the onchain address the received funds are claimed to (typically a + /// fresh address from the caller's onchain wallet). The claim key and the + /// preimage are derived deterministically from `mnemonic` at a freshly + /// reserved index (the preimage is `sha256(swap_key)`), so the claim can be + /// made — and recovered from the seed — once Boltz locks the onchain funds. + pub async fn create_reverse_swap( + &self, + network: BoltzNetwork, + electrum_url: String, + amount_sat: u64, + claim_address: String, + mnemonic: String, + bip39_passphrase: Option, + ) -> Result { + if amount_sat == 0 { + return Err(BoltzError::InvalidInput { + error_details: "amount_sat must be greater than 0".to_string(), + }); + } + if claim_address.trim().is_empty() { + return Err(BoltzError::InvalidInput { + error_details: "claim_address must not be empty".to_string(), + }); + } + + let swap_index = self.reserve_swap_index().await?; + let keypair = + derive_swap_keypair(&mnemonic, bip39_passphrase.as_deref(), network, swap_index)?; + let claim_public_key = bitcoin::PublicKey::new(keypair.public_key()); + let preimage = Preimage::from_swap_key(&keypair); + + let client = build_boltz_client(network); + let request = CreateReverseRequest { + from: "BTC".to_string(), + to: "BTC".to_string(), + claim_public_key, + invoice: None, + invoice_amount: Some(amount_sat), + preimage_hash: Some(preimage.sha256), + description: None, + description_hash: None, + address: None, + address_signature: None, + referral_id: None, + webhook: None, + }; + let response = client + .post_reverse_req(request) + .await + .map_err(map_api_err("create reverse swap"))?; + + let invoice = response.invoice.clone().ok_or(BoltzError::ApiError { + error_details: "Reverse swap response missing invoice".to_string(), + })?; + let timeout_block_height = response.timeout_block_height as u64; + let onchain_amount = response.onchain_amount; + + let record = SwapRecord { + id: response.id.clone(), + swap_type: BoltzSwapType::Reverse, + status: STATUS_CREATED.to_string(), + network, + electrum_url, + swap_index, + invoice: Some(invoice.clone()), + lockup_address: Some(response.lockup_address.clone()), + onchain_address: Some(claim_address), + amount_sat, + onchain_amount_sat: Some(onchain_amount), + timeout_block_height, + create_response_json: serde_json::to_string(&response)?, + claim_tx_id: None, + refund_tx_id: None, + created_at: now_secs(), + }; + self.insert_swap(&record).await?; + + Ok(ReverseSwapResponse { + id: response.id, + invoice, + lockup_address: response.lockup_address, + onchain_amount_sat: onchain_amount, + timeout_block_height, + }) + } +} + +fn now_secs() -> u64 { + chrono::Utc::now().timestamp().max(0) as u64 +} + +fn map_api_err(context: &'static str) -> impl Fn(boltz_client::error::Error) -> BoltzError { + move |e| BoltzError::ApiError { + error_details: format!("Failed to {}: {}", context, e), + } +} diff --git a/src/modules/boltz/claim.rs b/src/modules/boltz/claim.rs new file mode 100644 index 0000000..b7583d6 --- /dev/null +++ b/src/modules/boltz/claim.rs @@ -0,0 +1,74 @@ +use crate::modules::boltz::client::{build_boltz_client, build_chain_client}; +use crate::modules::boltz::errors::BoltzError; +use crate::modules::boltz::models::SwapRecord; +use boltz_client::swaps::{SwapScript, SwapTransactionParams, TransactionOptions}; +use boltz_client::util::fees::Fee; + +/// Default claim fee rate in sat/vByte used when the caller doesn't specify one. +pub(crate) const DEFAULT_FEERATE_SAT_PER_VB: f64 = 2.0; + +/// Claim a reverse swap's onchain funds to the address captured at creation, +/// revealing the preimage so Boltz can settle the Lightning invoice. +/// +/// A cooperative (key-path) claim is attempted first for a smaller, cheaper +/// transaction; if Boltz declines to cooperate it falls back to the script-path +/// spend, which is always available while the lockup is unspent. Returns the +/// broadcast claim transaction id. +pub async fn claim_reverse_swap( + record: &SwapRecord, + mnemonic: &str, + bip39_passphrase: Option<&str>, + fee_rate_sat_per_vb: Option, +) -> Result { + let reverse_resp = record.reverse_response()?; + let keypair = record.keypair(mnemonic, bip39_passphrase)?; + let preimage = record.preimage(mnemonic, bip39_passphrase)?; + let our_pubkey = bitcoin::PublicKey::new(keypair.public_key()); + + let claim_address = record + .onchain_address + .clone() + .ok_or_else(|| BoltzError::InvalidInput { + error_details: "Reverse swap is missing a claim address".to_string(), + })?; + + let chain = record.network.as_chain(); + let swap_script = SwapScript::reverse_from_swap_resp(chain, &reverse_resp, our_pubkey)?; + let chain_client = build_chain_client(record.network, &record.electrum_url)?; + let boltz_client = build_boltz_client(record.network); + let fee = Fee::Relative(fee_rate_sat_per_vb.unwrap_or(DEFAULT_FEERATE_SAT_PER_VB)); + + let make_params = |cooperative: bool| SwapTransactionParams { + keys: keypair, + output_address: claim_address.clone(), + fee, + swap_id: record.id.clone(), + chain_client: &chain_client, + boltz_client: &boltz_client, + options: Some(TransactionOptions::default().with_cooperative(cooperative)), + }; + + // Try the cooperative key-path claim first; fall back to the script path. + let tx = match swap_script + .construct_claim(&preimage, make_params(true)) + .await + { + Ok(tx) => tx, + Err(coop_err) => swap_script + .construct_claim(&preimage, make_params(false)) + .await + .map_err(|script_err| BoltzError::SwapError { + error_details: format!( + "Claim failed (cooperative: {}; script-path: {})", + coop_err, script_err + ), + })?, + }; + + chain_client + .broadcast_tx(&tx) + .await + .map_err(|e| BoltzError::BroadcastError { + error_details: format!("Failed to broadcast claim transaction: {}", e), + }) +} diff --git a/src/modules/boltz/client.rs b/src/modules/boltz/client.rs new file mode 100644 index 0000000..719eb01 --- /dev/null +++ b/src/modules/boltz/client.rs @@ -0,0 +1,70 @@ +use crate::modules::boltz::errors::BoltzError; +use crate::modules::boltz::types::BoltzNetwork; +use boltz_client::network::electrum::ElectrumBitcoinClient; +use boltz_client::swaps::boltz::BoltzApiClientV2; +use boltz_client::swaps::ChainClient; + +/// Electrum socket timeout, in seconds, used when building swap chain clients. +const ELECTRUM_TIMEOUT_SECS: u8 = 30; + +/// Build a Boltz REST client for the given network. +pub fn build_boltz_client(network: BoltzNetwork) -> BoltzApiClientV2 { + BoltzApiClientV2::default(network.as_client_network()) +} + +/// Build an Electrum-backed [`ChainClient`] for broadcasting and fetching swap +/// UTXOs. `electrum_url` accepts the same `ssl://`/`tcp://` scheme conventions +/// used elsewhere in the crate; a bare `host:port` is treated as TLS. +pub fn build_chain_client( + network: BoltzNetwork, + electrum_url: &str, +) -> Result { + let (host_port, tls) = parse_electrum_url(electrum_url); + let client = ElectrumBitcoinClient::new( + network.as_bitcoin_chain(), + &host_port, + tls, + tls, + ELECTRUM_TIMEOUT_SECS, + ) + .map_err(|e| BoltzError::ConnectionError { + error_details: format!("Failed to connect to Electrum server: {}", e), + })?; + Ok(ChainClient::new().with_bitcoin(client)) +} + +/// Split an Electrum URL into a `host:port` and a TLS flag. `ssl://` and +/// `tls://` mean TLS; `tcp://` means plaintext; a scheme-less URL defaults to +/// TLS (the safe default for public servers). +fn parse_electrum_url(url: &str) -> (String, bool) { + if let Some(rest) = url.strip_prefix("ssl://") { + (rest.to_string(), true) + } else if let Some(rest) = url.strip_prefix("tls://") { + (rest.to_string(), true) + } else if let Some(rest) = url.strip_prefix("tcp://") { + (rest.to_string(), false) + } else { + (url.to_string(), true) + } +} + +#[cfg(test)] +mod tests { + use super::parse_electrum_url; + + #[test] + fn parses_schemes() { + assert_eq!( + parse_electrum_url("ssl://electrum.example.com:50002"), + ("electrum.example.com:50002".to_string(), true) + ); + assert_eq!( + parse_electrum_url("tcp://10.0.0.1:50001"), + ("10.0.0.1:50001".to_string(), false) + ); + assert_eq!( + parse_electrum_url("electrum.example.com:50002"), + ("electrum.example.com:50002".to_string(), true) + ); + } +} diff --git a/src/modules/boltz/db.rs b/src/modules/boltz/db.rs new file mode 100644 index 0000000..4c5ba0a --- /dev/null +++ b/src/modules/boltz/db.rs @@ -0,0 +1,226 @@ +use crate::modules::boltz::errors::BoltzError; +use crate::modules::boltz::models::{ + BoltzDB, SwapRecord, CREATE_META_TABLE, CREATE_SWAPS_TABLE, SCHEMA_VERSION, +}; +use crate::modules::boltz::types::{BoltzNetwork, BoltzSwapStatus, BoltzSwapType}; +use rusqlite::{params, Connection, OptionalExtension, Row}; + +/// Counter key in `swap_meta` for the next deterministic swap index. +const NEXT_SWAP_INDEX_KEY: &str = "next_swap_index"; + +impl BoltzDB { + /// Open (or create) the swaps database at `db_path` and run migrations. + pub async fn new(db_path: &str) -> Result { + let conn = Connection::open(db_path).map_err(|e| BoltzError::InitializationError { + error_details: format!("Error opening database: {}", e), + })?; + let db = BoltzDB { + conn: tokio::sync::Mutex::new(conn), + }; + db.initialize().await?; + Ok(db) + } + + async fn initialize(&self) -> Result<(), BoltzError> { + let conn = self.conn.lock().await; + conn.execute(CREATE_SWAPS_TABLE, []) + .map_err(|e| BoltzError::InitializationError { + error_details: format!("Failed to create swaps table: {}", e), + })?; + conn.execute(CREATE_META_TABLE, []) + .map_err(|e| BoltzError::InitializationError { + error_details: format!("Failed to create swap_meta table: {}", e), + })?; + conn.pragma_update(None, "user_version", SCHEMA_VERSION) + .map_err(|e| BoltzError::InitializationError { + error_details: format!("Failed to set schema version: {}", e), + })?; + Ok(()) + } + + /// Atomically reserve the next deterministic swap index. The connection + /// mutex serializes all access, so the read-then-write below cannot + /// interleave with another reservation. Indices are monotonic and never + /// reused, so each swap derives a unique key even if a creation later fails. + pub async fn reserve_swap_index(&self) -> Result { + let conn = self.conn.lock().await; + let current: Option = conn + .query_row( + "SELECT value FROM swap_meta WHERE key = ?1", + params![NEXT_SWAP_INDEX_KEY], + |row| row.get(0), + ) + .optional()?; + let index = current.unwrap_or(0); + conn.execute( + "INSERT INTO swap_meta (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = ?2", + params![NEXT_SWAP_INDEX_KEY, index + 1], + )?; + Ok(index as u64) + } + + /// Insert a newly-created swap. + pub async fn insert_swap(&self, record: &SwapRecord) -> Result<(), BoltzError> { + let conn = self.conn.lock().await; + conn.execute( + "INSERT INTO swaps ( + id, swap_type, status, network, electrum_url, swap_index, + invoice, lockup_address, onchain_address, amount_sat, onchain_amount_sat, + timeout_block_height, create_response_json, claim_tx_id, refund_tx_id, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", + params![ + record.id, + record.swap_type.as_str(), + record.status, + record.network.as_str(), + record.electrum_url, + record.swap_index as i64, + record.invoice, + record.lockup_address, + record.onchain_address, + record.amount_sat as i64, + record.onchain_amount_sat.map(|v| v as i64), + record.timeout_block_height as i64, + record.create_response_json, + record.claim_tx_id, + record.refund_tx_id, + record.created_at as i64, + ], + ) + .map_err(|e| BoltzError::DatabaseError { + error_details: format!("Failed to insert swap: {}", e), + })?; + Ok(()) + } + + /// Update the raw status string of a swap. + pub async fn update_status(&self, swap_id: &str, status: &str) -> Result<(), BoltzError> { + let conn = self.conn.lock().await; + conn.execute( + "UPDATE swaps SET status = ?1 WHERE id = ?2", + params![status, swap_id], + )?; + Ok(()) + } + + /// Record the broadcast claim transaction id for a swap. + pub async fn set_claim_tx(&self, swap_id: &str, txid: &str) -> Result<(), BoltzError> { + let conn = self.conn.lock().await; + conn.execute( + "UPDATE swaps SET claim_tx_id = ?1 WHERE id = ?2", + params![txid, swap_id], + )?; + Ok(()) + } + + /// Record the broadcast refund transaction id for a swap. + pub async fn set_refund_tx(&self, swap_id: &str, txid: &str) -> Result<(), BoltzError> { + let conn = self.conn.lock().await; + conn.execute( + "UPDATE swaps SET refund_tx_id = ?1 WHERE id = ?2", + params![txid, swap_id], + )?; + Ok(()) + } + + /// Fetch a single swap by id. + pub async fn get_swap(&self, swap_id: &str) -> Result, BoltzError> { + let conn = self.conn.lock().await; + let record = conn + .query_row( + "SELECT id, swap_type, status, network, electrum_url, swap_index, + invoice, lockup_address, onchain_address, amount_sat, onchain_amount_sat, + timeout_block_height, create_response_json, claim_tx_id, refund_tx_id, + created_at + FROM swaps WHERE id = ?1", + params![swap_id], + row_to_record, + ) + .optional()?; + record.transpose() + } + + /// List every persisted swap, newest first. + pub async fn list_swaps(&self) -> Result, BoltzError> { + self.query_swaps( + "SELECT id, swap_type, status, network, electrum_url, swap_index, + invoice, lockup_address, onchain_address, amount_sat, onchain_amount_sat, + timeout_block_height, create_response_json, claim_tx_id, refund_tx_id, created_at + FROM swaps ORDER BY created_at DESC", + ) + .await + } + + /// List swaps that have not reached a terminal state, for recovery and for + /// resubscribing to status updates after a restart. + pub async fn list_pending_swaps(&self) -> Result, BoltzError> { + Ok(self + .list_swaps() + .await? + .into_iter() + .filter(|r| !BoltzSwapStatus::from_raw(&r.status).is_terminal()) + .collect()) + } + + async fn query_swaps(&self, sql: &str) -> Result, BoltzError> { + let conn = self.conn.lock().await; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([], row_to_record)?; + let mut records = Vec::new(); + for row in rows { + records.push(row??); + } + Ok(records) + } +} + +/// Map a SQLite row to a [`SwapRecord`]. The outer `rusqlite::Result` covers +/// column-access failures; the inner `Result` covers +/// decoding of persisted enum strings (`swap_type`, `network`). No secrets are +/// stored, so there is no key material to decode here. +fn row_to_record(row: &Row) -> rusqlite::Result> { + let swap_type_str: String = row.get(1)?; + let network_str: String = row.get(3)?; + let amount_sat: i64 = row.get(9)?; + let onchain_amount_sat: Option = row.get(10)?; + let timeout_block_height: i64 = row.get(11)?; + let created_at: i64 = row.get(15)?; + let swap_index: i64 = row.get(5)?; + + let swap_type = match BoltzSwapType::from_str(&swap_type_str) { + Some(t) => t, + None => { + return Ok(Err(BoltzError::DatabaseError { + error_details: format!("Unknown swap_type: {}", swap_type_str), + })) + } + }; + let network = match BoltzNetwork::from_str(&network_str) { + Some(n) => n, + None => { + return Ok(Err(BoltzError::DatabaseError { + error_details: format!("Unknown network: {}", network_str), + })) + } + }; + + Ok(Ok(SwapRecord { + id: row.get(0)?, + swap_type, + status: row.get(2)?, + network, + electrum_url: row.get(4)?, + swap_index: swap_index as u64, + invoice: row.get(6)?, + lockup_address: row.get(7)?, + onchain_address: row.get(8)?, + amount_sat: amount_sat as u64, + onchain_amount_sat: onchain_amount_sat.map(|v| v as u64), + timeout_block_height: timeout_block_height as u64, + create_response_json: row.get(12)?, + claim_tx_id: row.get(13)?, + refund_tx_id: row.get(14)?, + created_at: created_at as u64, + })) +} diff --git a/src/modules/boltz/errors.rs b/src/modules/boltz/errors.rs new file mode 100644 index 0000000..f4d7a89 --- /dev/null +++ b/src/modules/boltz/errors.rs @@ -0,0 +1,56 @@ +use thiserror::Error; + +/// Errors surfaced by the Boltz swaps module. +#[derive(uniffi::Error, Debug, Error)] +pub enum BoltzError { + #[error("Database initialization failed: {error_details}")] + InitializationError { error_details: String }, + + #[error("Database connection error: {error_details}")] + ConnectionError { error_details: String }, + + #[error("Database error: {error_details}")] + DatabaseError { error_details: String }, + + #[error("Boltz API error: {error_details}")] + ApiError { error_details: String }, + + #[error("Swap error: {error_details}")] + SwapError { error_details: String }, + + #[error("Broadcast error: {error_details}")] + BroadcastError { error_details: String }, + + #[error("Invalid input: {error_details}")] + InvalidInput { error_details: String }, + + #[error("Serialization error: {error_details}")] + SerializationError { error_details: String }, + + #[error("Swap not found: {error_details}")] + NotFound { error_details: String }, +} + +impl From for BoltzError { + fn from(err: boltz_client::error::Error) -> Self { + BoltzError::SwapError { + error_details: err.to_string(), + } + } +} + +impl From for BoltzError { + fn from(err: serde_json::Error) -> Self { + BoltzError::SerializationError { + error_details: err.to_string(), + } + } +} + +impl From for BoltzError { + fn from(err: rusqlite::Error) -> Self { + BoltzError::DatabaseError { + error_details: err.to_string(), + } + } +} diff --git a/src/modules/boltz/listener.rs b/src/modules/boltz/listener.rs new file mode 100644 index 0000000..0630383 --- /dev/null +++ b/src/modules/boltz/listener.rs @@ -0,0 +1,211 @@ +use crate::modules::boltz::claim::claim_reverse_swap; +use crate::modules::boltz::client::build_boltz_client; +use crate::modules::boltz::errors::BoltzError; +use crate::modules::boltz::models::{BoltzDB, SwapRecord}; +use crate::modules::boltz::types::{BoltzNetwork, BoltzSwapEvent, BoltzSwapStatus, BoltzSwapType}; +use boltz_client::swaps::boltz::{BoltzWsApi, BoltzWsConfig, SwapStatus}; +use once_cell::sync::OnceCell; +use std::sync::Arc; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::Mutex as TokioMutex; +use tokio::task::JoinHandle; + +/// Callback interface for receiving Boltz swap lifecycle events. +/// +/// Implement this in Swift/Kotlin/Python and register it via +/// `boltz_start_swap_updates` to receive typed notifications as swaps progress. +/// Reverse swaps are claimed automatically; the [`BoltzSwapEvent::Claimed`] +/// event reports the resulting transaction id. +#[uniffi::export(with_foreign)] +pub trait BoltzEventListener: Send + Sync { + fn on_event(&self, event: BoltzSwapEvent); +} + +struct UpdatesHandle { + ws: Arc, + network: BoltzNetwork, + ws_task: JoinHandle<()>, + process_task: JoinHandle<()>, +} + +/// Wallet credentials needed by the background stream to re-derive swap keys for +/// automatic reverse-swap claims. Held in memory only for the lifetime of the +/// updates stream (dropped on [`stop_swap_updates`]); never persisted. +#[derive(Clone)] +struct AutoClaimKeys { + mnemonic: String, + bip39_passphrase: Option, +} + +static SWAP_UPDATES: OnceCell>> = OnceCell::new(); + +fn updates_cell() -> &'static TokioMutex> { + SWAP_UPDATES.get_or_init(|| TokioMutex::new(None)) +} + +/// Open a Boltz WebSocket for `network`, subscribe to every pending swap, and +/// drive their lifecycle until [`stop_swap_updates`] is called. +/// +/// Any previously running updates stream is stopped first; only one stream (for +/// one network) runs at a time. `mnemonic` is held in memory for the lifetime of +/// the stream so confirmed reverse swaps can be auto-claimed (their keys are +/// re-derived on demand). Must be invoked from within a Tokio runtime context +/// (it spawns background tasks). +pub async fn start_swap_updates( + db: Arc, + network: BoltzNetwork, + listener: Arc, + mnemonic: String, + bip39_passphrase: Option, +) -> Result<(), BoltzError> { + stop_swap_updates().await; + let keys = AutoClaimKeys { + mnemonic, + bip39_passphrase, + }; + + let boltz_client = build_boltz_client(network); + let ws = Arc::new(BoltzWsApi::new( + boltz_client.get_ws_url(), + BoltzWsConfig::default(), + )); + + let ws_task = tokio::spawn(ws.clone().run_ws_loop()); + + // Subscribe to every non-terminal swap for this network. + let pending = db.list_pending_swaps().await?; + for record in pending.iter().filter(|r| r.network == network) { + if let Err(e) = ws.subscribe_swap(&record.id).await { + log::warn!("Failed to subscribe to swap {}: {}", record.id, e); + } + } + + let process_task = { + let ws = ws.clone(); + let db = db.clone(); + let listener = listener.clone(); + let keys = keys.clone(); + tokio::spawn(async move { + let mut updates = ws.updates(); + loop { + match updates.recv().await { + Ok(status) => process_status(&db, listener.as_ref(), &keys, status).await, + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => break, + } + } + }) + }; + + let mut guard = updates_cell().lock().await; + *guard = Some(UpdatesHandle { + ws, + network, + ws_task, + process_task, + }); + Ok(()) +} + +/// Stop the running updates stream, if any, and tear down its tasks. +pub async fn stop_swap_updates() { + let mut guard = updates_cell().lock().await; + if let Some(handle) = guard.take() { + handle.process_task.abort(); + handle.ws_task.abort(); + // Dropping the last `Arc` triggers its shutdown. + drop(handle.ws); + } +} + +/// If an updates stream is running for `network`, subscribe it to `swap_id` so +/// newly created swaps are tracked without restarting the stream. +pub async fn subscribe_if_active(network: BoltzNetwork, swap_id: &str) { + let guard = updates_cell().lock().await; + if let Some(handle) = guard.as_ref() { + if handle.network == network { + if let Err(e) = handle.ws.subscribe_swap(swap_id).await { + log::warn!("Failed to subscribe to swap {}: {}", swap_id, e); + } + } + } +} + +/// Handle a single status update: persist it, notify the listener, and trigger +/// an automatic claim for reverse swaps whose lockup is now spendable. +async fn process_status( + db: &Arc, + listener: &dyn BoltzEventListener, + keys: &AutoClaimKeys, + status: SwapStatus, +) { + let swap_id = status.id.clone(); + let raw = status.status.clone(); + + if let Err(e) = db.update_status(&swap_id, &raw).await { + log::warn!("Failed to persist status for swap {}: {}", swap_id, e); + } + + listener.on_event(BoltzSwapEvent::StatusUpdate { + swap_id: swap_id.clone(), + status: BoltzSwapStatus::from_raw(&raw), + }); + + let record = match db.get_swap(&swap_id).await { + Ok(Some(record)) => record, + Ok(None) => return, + Err(e) => { + log::warn!("Failed to load swap {} after update: {}", swap_id, e); + return; + } + }; + + if should_auto_claim(&record, &raw) { + auto_claim(db, listener, keys, &record).await; + } +} + +/// A reverse swap is auto-claimed once Boltz's lockup *confirms*, provided it +/// hasn't already been claimed. +/// +/// Claiming reveals the preimage, which lets Boltz settle the Lightning +/// invoice. Doing that against an unconfirmed (mempool-only) lockup risks the +/// preimage leaking before the lockup confirms — if the lockup were then +/// replaced, the user could be debited on Lightning without receiving onchain +/// funds. We therefore wait for confirmation here; a caller that accepts the +/// 0-conf risk can still claim early via `boltz_claim_reverse_swap`. +fn should_auto_claim(record: &SwapRecord, raw_status: &str) -> bool { + record.swap_type == BoltzSwapType::Reverse + && record.claim_tx_id.is_none() + && raw_status == "transaction.confirmed" +} + +async fn auto_claim( + db: &Arc, + listener: &dyn BoltzEventListener, + keys: &AutoClaimKeys, + record: &SwapRecord, +) { + match claim_reverse_swap( + record, + &keys.mnemonic, + keys.bip39_passphrase.as_deref(), + None, + ) + .await + { + Ok(txid) => { + if let Err(e) = db.set_claim_tx(&record.id, &txid).await { + log::warn!("Failed to persist claim tx for swap {}: {}", record.id, e); + } + listener.on_event(BoltzSwapEvent::Claimed { + swap_id: record.id.clone(), + txid, + }); + } + Err(e) => listener.on_event(BoltzSwapEvent::Error { + swap_id: record.id.clone(), + message: e.to_string(), + }), + } +} diff --git a/src/modules/boltz/mod.rs b/src/modules/boltz/mod.rs new file mode 100644 index 0000000..5508794 --- /dev/null +++ b/src/modules/boltz/mod.rs @@ -0,0 +1,21 @@ +mod api; +mod claim; +mod client; +mod db; +mod errors; +mod listener; +mod models; +mod refund; +#[cfg(test)] +mod tests; +mod types; + +pub use api::{get_reverse_limits, get_submarine_limits}; +pub use claim::claim_reverse_swap; +pub use errors::BoltzError; +pub use listener::{ + start_swap_updates, stop_swap_updates, subscribe_if_active, BoltzEventListener, +}; +pub use models::{BoltzDB, SwapRecord}; +pub use refund::refund_submarine_swap; +pub use types::*; diff --git a/src/modules/boltz/models.rs b/src/modules/boltz/models.rs new file mode 100644 index 0000000..b3115e4 --- /dev/null +++ b/src/modules/boltz/models.rs @@ -0,0 +1,155 @@ +use crate::modules::boltz::errors::BoltzError; +use crate::modules::boltz::types::{BoltzNetwork, BoltzSwap, BoltzSwapStatus, BoltzSwapType}; +use boltz_client::swaps::boltz::{CreateReverseResponse, CreateSubmarineResponse}; +use boltz_client::util::secrets::{Preimage, SwapMasterKey}; +use boltz_client::Keypair; +use rusqlite::Connection; +use tokio::sync::Mutex; + +/// SQLite-backed store for Boltz swaps. +/// +/// Wraps a single connection behind an async mutex (mirroring the blocktank +/// module). No swap secrets are persisted: each swap's key and (for reverse +/// swaps) preimage are *re-derived on demand* from the wallet mnemonic and the +/// swap's deterministic [`SwapRecord::swap_index`] via BIP85 +/// ([`derive_swap_keypair`]). The database therefore holds no key material — +/// only the index needed to reconstruct it given the seed. +pub struct BoltzDB { + pub(crate) conn: Mutex, +} + +/// Re-derive a swap's secp256k1 keypair from the wallet mnemonic. +/// +/// Keys are derived via Boltz's BIP85 scheme: the wallet mnemonic yields a +/// per-wallet swap master key, and each swap uses a unique `index` under it +/// (`m/26589'/0'/0'/{index}`). The same `(mnemonic, passphrase, index)` always +/// reproduces the same key, so swaps are recoverable from the seed alone — the +/// derived swap mnemonic can also be registered with Boltz's rescue API. +/// +/// The key value is independent of `network`; the parameter is accepted for +/// symmetry with the rest of the API and to match how the swap was created. +pub(crate) fn derive_swap_keypair( + mnemonic: &str, + passphrase: Option<&str>, + network: BoltzNetwork, + index: u64, +) -> Result { + let master = + SwapMasterKey::new(mnemonic, passphrase, network.as_client_network()).map_err(|e| { + BoltzError::InvalidInput { + error_details: format!("Invalid mnemonic or key derivation failed: {}", e), + } + })?; + master.derive_swapkey(index).map_err(BoltzError::from) +} + +pub const CREATE_SWAPS_TABLE: &str = "CREATE TABLE IF NOT EXISTS swaps ( + id TEXT PRIMARY KEY, + swap_type TEXT NOT NULL, + status TEXT NOT NULL, + network TEXT NOT NULL, + electrum_url TEXT NOT NULL, + swap_index INTEGER NOT NULL, + invoice TEXT, + lockup_address TEXT, + onchain_address TEXT, + amount_sat INTEGER NOT NULL, + onchain_amount_sat INTEGER, + timeout_block_height INTEGER NOT NULL, + create_response_json TEXT NOT NULL, + claim_tx_id TEXT, + refund_tx_id TEXT, + created_at INTEGER NOT NULL +)"; + +/// Single-row counter table backing monotonic [`BoltzDB::reserve_swap_index`] +/// allocation. Keeping the counter in its own table (rather than `MAX(index)+1`) +/// guarantees an index is never reused even if a later swap creation fails after +/// the index was reserved. +pub const CREATE_META_TABLE: &str = "CREATE TABLE IF NOT EXISTS swap_meta ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL +)"; + +/// Current `boltz.db` schema version, written to `PRAGMA user_version` so future +/// changes have a migration anchor. +pub const SCHEMA_VERSION: i64 = 1; + +/// Internal, fully-detailed representation of a persisted swap, including +/// secrets. This is never exposed across the FFI boundary — use +/// [`SwapRecord::to_boltz_swap`] to produce the public [`BoltzSwap`]. +#[derive(Debug, Clone)] +pub struct SwapRecord { + pub id: String, + pub swap_type: BoltzSwapType, + /// Raw Boltz status string (mapped to [`BoltzSwapStatus`] on the way out). + pub status: String, + pub network: BoltzNetwork, + pub electrum_url: String, + /// Deterministic BIP85 derivation index for this swap's key (and, for + /// reverse swaps, its preimage). The secrets themselves are never stored; + /// they are re-derived from the wallet mnemonic and this index. + pub swap_index: u64, + pub invoice: Option, + pub lockup_address: Option, + pub onchain_address: Option, + pub amount_sat: u64, + pub onchain_amount_sat: Option, + pub timeout_block_height: u64, + /// Serialized `CreateSubmarineResponse` or `CreateReverseResponse`, used to + /// reconstruct the swap script for claims/refunds. + pub create_response_json: String, + pub claim_tx_id: Option, + pub refund_tx_id: Option, + pub created_at: u64, +} + +impl SwapRecord { + /// Re-derive the client keypair from the wallet mnemonic and this swap's + /// [`SwapRecord::swap_index`]. + pub fn keypair(&self, mnemonic: &str, passphrase: Option<&str>) -> Result { + derive_swap_keypair(mnemonic, passphrase, self.network, self.swap_index) + } + + /// Re-derive the preimage (reverse swaps only) from the swap key. The + /// preimage is `sha256(swap_private_key)`, so it is reproducible from the + /// seed without ever being stored. + pub fn preimage( + &self, + mnemonic: &str, + passphrase: Option<&str>, + ) -> Result { + let keypair = self.keypair(mnemonic, passphrase)?; + Ok(Preimage::from_swap_key(&keypair)) + } + + /// Deserialize the stored submarine swap creation response. + pub fn submarine_response(&self) -> Result { + serde_json::from_str(&self.create_response_json).map_err(BoltzError::from) + } + + /// Deserialize the stored reverse swap creation response. + pub fn reverse_response(&self) -> Result { + serde_json::from_str(&self.create_response_json).map_err(BoltzError::from) + } + + /// Project to the public FFI type. + pub fn to_boltz_swap(&self) -> BoltzSwap { + BoltzSwap { + id: self.id.clone(), + swap_type: self.swap_type, + status: BoltzSwapStatus::from_raw(&self.status), + network: self.network, + swap_index: self.swap_index, + amount_sat: self.amount_sat, + onchain_amount_sat: self.onchain_amount_sat, + invoice: self.invoice.clone(), + lockup_address: self.lockup_address.clone(), + onchain_address: self.onchain_address.clone(), + timeout_block_height: self.timeout_block_height, + created_at: self.created_at, + claim_tx_id: self.claim_tx_id.clone(), + refund_tx_id: self.refund_tx_id.clone(), + } + } +} diff --git a/src/modules/boltz/refund.rs b/src/modules/boltz/refund.rs new file mode 100644 index 0000000..e996da2 --- /dev/null +++ b/src/modules/boltz/refund.rs @@ -0,0 +1,62 @@ +use crate::modules::boltz::claim::DEFAULT_FEERATE_SAT_PER_VB; +use crate::modules::boltz::client::{build_boltz_client, build_chain_client}; +use crate::modules::boltz::errors::BoltzError; +use crate::modules::boltz::models::SwapRecord; +use boltz_client::swaps::{SwapScript, SwapTransactionParams, TransactionOptions}; +use boltz_client::util::fees::Fee; + +/// Refund a submarine swap's locked onchain funds back to `refund_address`. +/// +/// Used when Boltz fails to pay the invoice (`invoice.failedToPay`), the wrong +/// amount was locked (`transaction.lockupFailed`), or the swap expired. A +/// cooperative refund is attempted first; if unavailable it falls back to the +/// script-path refund, which becomes spendable after the swap's onchain +/// timeout. Returns the broadcast refund transaction id. +pub async fn refund_submarine_swap( + record: &SwapRecord, + refund_address: String, + mnemonic: &str, + bip39_passphrase: Option<&str>, + fee_rate_sat_per_vb: Option, +) -> Result { + let submarine_resp = record.submarine_response()?; + let keypair = record.keypair(mnemonic, bip39_passphrase)?; + let our_pubkey = bitcoin::PublicKey::new(keypair.public_key()); + + let chain = record.network.as_chain(); + let swap_script = SwapScript::submarine_from_swap_resp(chain, &submarine_resp, our_pubkey)?; + let chain_client = build_chain_client(record.network, &record.electrum_url)?; + let boltz_client = build_boltz_client(record.network); + let fee = Fee::Relative(fee_rate_sat_per_vb.unwrap_or(DEFAULT_FEERATE_SAT_PER_VB)); + + let make_params = |cooperative: bool| SwapTransactionParams { + keys: keypair, + output_address: refund_address.clone(), + fee, + swap_id: record.id.clone(), + chain_client: &chain_client, + boltz_client: &boltz_client, + options: Some(TransactionOptions::default().with_cooperative(cooperative)), + }; + + // Prefer a cooperative refund; fall back to the timeout script path. + let tx = match swap_script.construct_refund(make_params(true)).await { + Ok(tx) => tx, + Err(coop_err) => swap_script + .construct_refund(make_params(false)) + .await + .map_err(|script_err| BoltzError::SwapError { + error_details: format!( + "Refund failed (cooperative: {}; script-path: {})", + coop_err, script_err + ), + })?, + }; + + chain_client + .broadcast_tx(&tx) + .await + .map_err(|e| BoltzError::BroadcastError { + error_details: format!("Failed to broadcast refund transaction: {}", e), + }) +} diff --git a/src/modules/boltz/tests.rs b/src/modules/boltz/tests.rs new file mode 100644 index 0000000..e9cef88 --- /dev/null +++ b/src/modules/boltz/tests.rs @@ -0,0 +1,262 @@ +use crate::modules::boltz::api::{get_reverse_limits, get_submarine_limits}; +use crate::modules::boltz::models::{derive_swap_keypair, BoltzDB, SwapRecord}; +use crate::modules::boltz::types::{BoltzNetwork, BoltzSwapStatus, BoltzSwapType}; +use boltz_client::util::secrets::Preimage; + +/// A throwaway BIP39 mnemonic used only by the offline tests. +const TEST_MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +fn sample_record(id: &str, swap_type: BoltzSwapType) -> SwapRecord { + SwapRecord { + id: id.to_string(), + swap_type, + status: "swap.created".to_string(), + network: BoltzNetwork::Testnet, + electrum_url: "ssl://electrum.example.com:50002".to_string(), + swap_index: 0, + invoice: Some("lnbc1...".to_string()), + lockup_address: Some("bc1qexample".to_string()), + onchain_address: Some("bc1qclaim".to_string()), + amount_sat: 100_000, + onchain_amount_sat: Some(99_000), + timeout_block_height: 800_000, + create_response_json: "{}".to_string(), + claim_tx_id: None, + refund_tx_id: None, + created_at: 1_700_000_000, + } +} + +#[test] +fn status_round_trips_through_raw() { + let cases = [ + ("swap.created", BoltzSwapStatus::SwapCreated), + ("transaction.mempool", BoltzSwapStatus::TransactionMempool), + ( + "transaction.claim.pending", + BoltzSwapStatus::TransactionClaimPending, + ), + ("invoice.failedToPay", BoltzSwapStatus::InvoiceFailedToPay), + ("transaction.claimed", BoltzSwapStatus::TransactionClaimed), + ]; + for (raw, expected) in cases { + let mapped = BoltzSwapStatus::from_raw(raw); + assert_eq!(mapped, expected); + assert_eq!(mapped.as_raw(), raw); + } +} + +#[test] +fn unknown_status_preserves_raw() { + let mapped = BoltzSwapStatus::from_raw("some.future.state"); + assert_eq!( + mapped, + BoltzSwapStatus::Unknown { + raw: "some.future.state".to_string() + } + ); + assert_eq!(mapped.as_raw(), "some.future.state"); +} + +#[test] +fn terminal_states_are_terminal() { + assert!(BoltzSwapStatus::TransactionClaimed.is_terminal()); + assert!(BoltzSwapStatus::TransactionRefunded.is_terminal()); + assert!(BoltzSwapStatus::SwapExpired.is_terminal()); + assert!(!BoltzSwapStatus::TransactionMempool.is_terminal()); + assert!(!BoltzSwapStatus::SwapCreated.is_terminal()); +} + +#[test] +fn derivation_is_deterministic_and_index_unique() { + // Same (mnemonic, index) reproduces the same key and preimage — the + // property that makes swaps recoverable from the seed alone. + let k0a = derive_swap_keypair(TEST_MNEMONIC, None, BoltzNetwork::Mainnet, 0).unwrap(); + let k0b = derive_swap_keypair(TEST_MNEMONIC, None, BoltzNetwork::Mainnet, 0).unwrap(); + assert_eq!(k0a.secret_bytes(), k0b.secret_bytes()); + assert_eq!( + Preimage::from_swap_key(&k0a).bytes, + Preimage::from_swap_key(&k0b).bytes + ); + + // Different indices yield different keys (no key reuse across swaps). + let k1 = derive_swap_keypair(TEST_MNEMONIC, None, BoltzNetwork::Mainnet, 1).unwrap(); + assert_ne!(k0a.secret_bytes(), k1.secret_bytes()); + + // A different passphrase yields a different key (passphrase must match). + let k0p = derive_swap_keypair(TEST_MNEMONIC, Some("pass"), BoltzNetwork::Mainnet, 0).unwrap(); + assert_ne!(k0a.secret_bytes(), k0p.secret_bytes()); +} + +#[test] +fn rejects_invalid_mnemonic() { + assert!(derive_swap_keypair("not a valid mnemonic", None, BoltzNetwork::Mainnet, 0).is_err()); +} + +#[tokio::test] +async fn reserve_swap_index_is_monotonic_and_persists() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("boltz.db"); + let db = BoltzDB::new(path.to_str().unwrap()).await.unwrap(); + + assert_eq!(db.reserve_swap_index().await.unwrap(), 0); + assert_eq!(db.reserve_swap_index().await.unwrap(), 1); + assert_eq!(db.reserve_swap_index().await.unwrap(), 2); + drop(db); + + // The counter survives reopening the database — no index is ever reused. + let db = BoltzDB::new(path.to_str().unwrap()).await.unwrap(); + assert_eq!(db.reserve_swap_index().await.unwrap(), 3); +} + +#[tokio::test] +async fn db_round_trip_and_recovery() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("boltz.db"); + let db = BoltzDB::new(path.to_str().unwrap()).await.unwrap(); + + let mut reverse = sample_record("rev-1", BoltzSwapType::Reverse); + reverse.swap_index = 3; + let submarine = sample_record("sub-1", BoltzSwapType::Submarine); + db.insert_swap(&reverse).await.unwrap(); + db.insert_swap(&submarine).await.unwrap(); + + // get_swap reconstructs the record faithfully (no secrets are stored, only + // the derivation index needed to reconstruct them from the seed). + let loaded = db.get_swap("rev-1").await.unwrap().expect("swap exists"); + assert_eq!(loaded.swap_type, BoltzSwapType::Reverse); + assert_eq!(loaded.swap_index, 3); + assert_eq!(loaded.amount_sat, 100_000); + assert_eq!(loaded.onchain_amount_sat, Some(99_000)); + + // Both swaps are pending until they reach a terminal state. + assert_eq!(db.list_swaps().await.unwrap().len(), 2); + assert_eq!(db.list_pending_swaps().await.unwrap().len(), 2); + + // Status transitions and tx ids persist. + db.update_status("rev-1", "transaction.mempool") + .await + .unwrap(); + db.set_claim_tx("rev-1", "abc123").await.unwrap(); + let loaded = db.get_swap("rev-1").await.unwrap().unwrap(); + assert_eq!(loaded.status, "transaction.mempool"); + assert_eq!(loaded.claim_tx_id, Some("abc123".to_string())); + + // A terminal status drops the swap from the pending set. + db.update_status("sub-1", "transaction.refunded") + .await + .unwrap(); + let pending = db.list_pending_swaps().await.unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, "rev-1"); +} + +/// Returns true if a Boltz API error indicates the endpoint is temporarily +/// unreachable (gateway/timeout), so the live test can skip rather than fail. +fn api_unavailable(err: &crate::modules::boltz::BoltzError) -> bool { + let msg = err.to_string(); + msg.contains("502") + || msg.contains("503") + || msg.contains("504") + || msg.contains("Bad Gateway") + || msg.contains("timed out") + || msg.contains("error sending request") +} + +/// Live end-to-end test against the Boltz API. Ignored by default (needs +/// network); run with: +/// `cargo test modules::boltz -- --ignored --nocapture` +/// +/// Targets testnet by default; set `BOLTZ_LIVE_NETWORK=mainnet` (or `regtest`) +/// to override. A reverse swap is ideal for an unattended live test: Boltz +/// generates the hold invoice itself, so no Lightning node or onchain funds are +/// required, and an unpaid swap simply expires. The test asserts the +/// locally-derived redeem script and invoice match what Boltz returned — the +/// core cryptographic guarantee that a later claim/refund will be valid. If the +/// endpoint is temporarily down it skips instead of failing. +#[tokio::test] +#[ignore = "hits the live Boltz API"] +async fn live_reverse_swap() { + let network = match std::env::var("BOLTZ_LIVE_NETWORK").as_deref() { + Ok("mainnet") => BoltzNetwork::Mainnet, + Ok("regtest") => BoltzNetwork::Regtest, + _ => BoltzNetwork::Testnet, + }; + println!("live test network: {}", network.as_str()); + + // 1. Real fees/limits round-trip for both directions. + let reverse = match get_reverse_limits(network).await { + Ok(limits) => limits, + Err(e) if api_unavailable(&e) => { + println!("SKIP: Boltz API unavailable ({e})"); + return; + } + Err(e) => panic!("fetch reverse limits: {e:?}"), + }; + let submarine = get_submarine_limits(network) + .await + .expect("fetch submarine limits"); + println!( + "submarine: min={} max={} fee%={}", + submarine.minimal_sat, submarine.maximal_sat, submarine.fee_percentage + ); + println!( + "reverse: min={} max={} fee%={}", + reverse.minimal_sat, reverse.maximal_sat, reverse.fee_percentage + ); + assert!(reverse.maximal_sat > reverse.minimal_sat); + + // 2. Create a real reverse swap at the minimum amount. + let dir = tempfile::tempdir().unwrap(); + let db = BoltzDB::new(dir.path().join("boltz.db").to_str().unwrap()) + .await + .unwrap(); + // Any valid testnet address — only stored locally as the claim destination, + // not sent to Boltz (we never broadcast in this test). + let claim_address = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx".to_string(); + let response = db + .create_reverse_swap( + network, + "ssl://blockstream.info:993".to_string(), + reverse.minimal_sat, + claim_address, + TEST_MNEMONIC.to_string(), + None, + ) + .await + .expect("create reverse swap"); + + println!("swap id: {}", response.id); + println!("hold invoice: {}", response.invoice); + println!("lockup address: {}", response.lockup_address); + assert!( + response.invoice.starts_with("lnbc") || response.invoice.starts_with("lntb"), + "expected a BOLT11 invoice, got: {}", + response.invoice + ); + assert!(!response.lockup_address.is_empty()); + + // 3. Reconstruct the swap from persisted secrets and verify the invoice's + // payment hash and the redeem script match — proving our claim key, + // preimage and locally-built swap script are correct. + let record = db.get_swap(&response.id).await.unwrap().expect("persisted"); + let keypair = record.keypair(TEST_MNEMONIC, None).unwrap(); + let preimage = record.preimage(TEST_MNEMONIC, None).unwrap(); + let our_pubkey = bitcoin::PublicKey::new(keypair.public_key()); + let reverse_resp = record.reverse_response().unwrap(); + reverse_resp + .validate(&preimage, &our_pubkey, network.as_chain()) + .expect("invoice + redeem script validate against our keys"); + + // The swap is left to expire on testnet; nothing is broadcast. + println!("reverse swap created and cryptographically validated ✅"); +} + +#[tokio::test] +async fn get_missing_swap_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("boltz.db"); + let db = BoltzDB::new(path.to_str().unwrap()).await.unwrap(); + assert!(db.get_swap("nope").await.unwrap().is_none()); +} diff --git a/src/modules/boltz/types.rs b/src/modules/boltz/types.rs new file mode 100644 index 0000000..46c6935 --- /dev/null +++ b/src/modules/boltz/types.rs @@ -0,0 +1,282 @@ +use boltz_client::network::{BitcoinChain, Chain, Network as BoltzClientNetwork}; +use serde::{Deserialize, Serialize}; + +/// Bitcoin network selection for Boltz swaps. Maps to the networks Boltz +/// operates on (mainnet, testnet, regtest). +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum, Serialize, Deserialize)] +pub enum BoltzNetwork { + Mainnet, + Testnet, + Regtest, +} + +impl BoltzNetwork { + pub(crate) fn as_client_network(self) -> BoltzClientNetwork { + match self { + BoltzNetwork::Mainnet => BoltzClientNetwork::Mainnet, + BoltzNetwork::Testnet => BoltzClientNetwork::Testnet, + BoltzNetwork::Regtest => BoltzClientNetwork::Regtest, + } + } + + pub(crate) fn as_bitcoin_chain(self) -> BitcoinChain { + match self { + BoltzNetwork::Mainnet => BitcoinChain::Bitcoin, + BoltzNetwork::Testnet => BitcoinChain::BitcoinTestnet, + BoltzNetwork::Regtest => BitcoinChain::BitcoinRegtest, + } + } + + pub(crate) fn as_chain(self) -> Chain { + Chain::Bitcoin(self.as_bitcoin_chain()) + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + BoltzNetwork::Mainnet => "mainnet", + BoltzNetwork::Testnet => "testnet", + BoltzNetwork::Regtest => "regtest", + } + } + + pub(crate) fn from_str(s: &str) -> Option { + match s { + "mainnet" => Some(BoltzNetwork::Mainnet), + "testnet" => Some(BoltzNetwork::Testnet), + "regtest" => Some(BoltzNetwork::Regtest), + _ => None, + } + } +} + +/// The direction of a Boltz swap. +/// +/// - `Submarine`: onchain Bitcoin -> Lightning (the user locks onchain funds, +/// Boltz pays a Lightning invoice). +/// - `Reverse`: Lightning -> onchain Bitcoin (the user pays a Boltz hold +/// invoice, Boltz locks onchain funds the user then claims). +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum, Serialize, Deserialize)] +pub enum BoltzSwapType { + Submarine, + Reverse, +} + +impl BoltzSwapType { + pub(crate) fn as_str(self) -> &'static str { + match self { + BoltzSwapType::Submarine => "submarine", + BoltzSwapType::Reverse => "reverse", + } + } + + pub(crate) fn from_str(s: &str) -> Option { + match s { + "submarine" => Some(BoltzSwapType::Submarine), + "reverse" => Some(BoltzSwapType::Reverse), + _ => None, + } + } +} + +/// Typed view of the Boltz swap lifecycle. `Unknown` carries the raw status so +/// new server-side states don't break the bindings. +/// +/// See . +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum, Serialize, Deserialize)] +pub enum BoltzSwapStatus { + /// `swap.created` — initial state. + SwapCreated, + /// `invoice.set` — invoice attached to a submarine swap. + InvoiceSet, + /// `transaction.mempool` — a lockup transaction is in the mempool. + TransactionMempool, + /// `transaction.confirmed` — a lockup transaction confirmed. + TransactionConfirmed, + /// `invoice.pending` — Boltz is paying the submarine swap invoice. + InvoicePending, + /// `invoice.paid` — submarine swap invoice paid by Boltz. + InvoicePaid, + /// `invoice.settled` — reverse swap invoice settled (preimage revealed). + InvoiceSettled, + /// `invoice.failedToPay` — submarine swap invoice could not be paid; refund. + InvoiceFailedToPay, + /// `invoice.expired` — reverse swap invoice expired before payment. + InvoiceExpired, + /// `transaction.claim.pending` — Boltz ready for a cooperative claim. + TransactionClaimPending, + /// `transaction.claimed` — onchain funds claimed. + TransactionClaimed, + /// `transaction.refunded` — onchain funds refunded. + TransactionRefunded, + /// `transaction.lockupFailed` — wrong amount locked; can refund. + TransactionLockupFailed, + /// `transaction.failed` — Boltz failed to lock the agreed funds. + TransactionFailed, + /// `swap.expired` — swap expired without completing. + SwapExpired, + /// Any status not yet modelled. `raw` holds the verbatim Boltz status. + Unknown { raw: String }, +} + +impl BoltzSwapStatus { + /// Map a raw Boltz status string to a typed status. + pub fn from_raw(raw: &str) -> BoltzSwapStatus { + match raw { + "swap.created" => BoltzSwapStatus::SwapCreated, + "invoice.set" => BoltzSwapStatus::InvoiceSet, + "transaction.mempool" => BoltzSwapStatus::TransactionMempool, + "transaction.confirmed" => BoltzSwapStatus::TransactionConfirmed, + "invoice.pending" => BoltzSwapStatus::InvoicePending, + "invoice.paid" => BoltzSwapStatus::InvoicePaid, + "invoice.settled" => BoltzSwapStatus::InvoiceSettled, + "invoice.failedToPay" => BoltzSwapStatus::InvoiceFailedToPay, + "invoice.expired" => BoltzSwapStatus::InvoiceExpired, + "transaction.claim.pending" => BoltzSwapStatus::TransactionClaimPending, + "transaction.claimed" => BoltzSwapStatus::TransactionClaimed, + "transaction.refunded" => BoltzSwapStatus::TransactionRefunded, + "transaction.lockupFailed" => BoltzSwapStatus::TransactionLockupFailed, + "transaction.failed" => BoltzSwapStatus::TransactionFailed, + "swap.expired" => BoltzSwapStatus::SwapExpired, + other => BoltzSwapStatus::Unknown { + raw: other.to_string(), + }, + } + } + + /// The raw Boltz status string this typed status was derived from. + pub fn as_raw(&self) -> String { + match self { + BoltzSwapStatus::SwapCreated => "swap.created".to_string(), + BoltzSwapStatus::InvoiceSet => "invoice.set".to_string(), + BoltzSwapStatus::TransactionMempool => "transaction.mempool".to_string(), + BoltzSwapStatus::TransactionConfirmed => "transaction.confirmed".to_string(), + BoltzSwapStatus::InvoicePending => "invoice.pending".to_string(), + BoltzSwapStatus::InvoicePaid => "invoice.paid".to_string(), + BoltzSwapStatus::InvoiceSettled => "invoice.settled".to_string(), + BoltzSwapStatus::InvoiceFailedToPay => "invoice.failedToPay".to_string(), + BoltzSwapStatus::InvoiceExpired => "invoice.expired".to_string(), + BoltzSwapStatus::TransactionClaimPending => "transaction.claim.pending".to_string(), + BoltzSwapStatus::TransactionClaimed => "transaction.claimed".to_string(), + BoltzSwapStatus::TransactionRefunded => "transaction.refunded".to_string(), + BoltzSwapStatus::TransactionLockupFailed => "transaction.lockupFailed".to_string(), + BoltzSwapStatus::TransactionFailed => "transaction.failed".to_string(), + BoltzSwapStatus::SwapExpired => "swap.expired".to_string(), + BoltzSwapStatus::Unknown { raw } => raw.clone(), + } + } + + /// Whether the swap has reached a terminal state and no further action is + /// possible or required. + pub fn is_terminal(&self) -> bool { + matches!( + self, + BoltzSwapStatus::TransactionClaimed + | BoltzSwapStatus::TransactionRefunded + | BoltzSwapStatus::InvoiceSettled + | BoltzSwapStatus::TransactionFailed + | BoltzSwapStatus::InvoiceExpired + | BoltzSwapStatus::SwapExpired + ) + } +} + +/// Fees and limits for a swap pair, used to size a swap and present costs. +#[derive(Debug, Clone, uniffi::Record)] +pub struct BoltzPairInfo { + /// Pair hash identifying the current terms (passed back to Boltz if needed). + pub hash: String, + /// Exchange rate of the pair. + pub rate: f64, + /// Minimum swap amount in satoshis. + pub minimal_sat: u64, + /// Maximum swap amount in satoshis. + pub maximal_sat: u64, + /// Boltz service fee as a percentage of the swap amount. + pub fee_percentage: f64, + /// Estimated absolute miner fees in satoshis. + pub miner_fees_sat: u64, +} + +/// Result of creating a submarine swap (onchain -> Lightning). +/// +/// The caller funds `address` with `expected_amount_sat` from its onchain +/// wallet; Boltz then pays the Lightning invoice supplied at creation. +#[derive(Debug, Clone, uniffi::Record)] +pub struct SubmarineSwapResponse { + pub id: String, + /// Onchain lockup address to send funds to. + pub address: String, + /// BIP21 URI for the lockup payment. + pub bip21: String, + /// Exact amount in satoshis the caller must send to `address`. + pub expected_amount_sat: u64, + /// Whether Boltz will accept a zero-conf lockup. + pub accept_zero_conf: bool, + /// Onchain timeout height after which a refund is possible. + pub timeout_block_height: u64, +} + +/// Result of creating a reverse swap (Lightning -> onchain). +/// +/// The caller pays `invoice` from its Lightning node; once Boltz locks funds at +/// `lockup_address`, the module claims them to the provided onchain address. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ReverseSwapResponse { + pub id: String, + /// Hold invoice the caller must pay via Lightning. + pub invoice: String, + /// Address Boltz locks the onchain funds to. + pub lockup_address: String, + /// Amount in satoshis that will be received onchain (after Boltz fees). + pub onchain_amount_sat: u64, + /// Onchain timeout height for the swap. + pub timeout_block_height: u64, +} + +/// A persisted swap and its current state, returned by the listing/query APIs. +#[derive(Debug, Clone, uniffi::Record)] +pub struct BoltzSwap { + pub id: String, + pub swap_type: BoltzSwapType, + pub status: BoltzSwapStatus, + pub network: BoltzNetwork, + /// Deterministic BIP85 index used to derive this swap's key and preimage + /// from the wallet seed. The recovery handle: given the seed and this index + /// (or by scanning indices), the swap's secrets can be reconstructed. + pub swap_index: u64, + /// For submarine swaps: the amount to lock onchain. For reverse swaps: the + /// Lightning invoice amount. + pub amount_sat: u64, + /// For reverse swaps: the onchain amount that will be received. + pub onchain_amount_sat: Option, + /// Lightning invoice associated with the swap (the hold invoice for reverse + /// swaps, the invoice Boltz pays for submarine swaps). + pub invoice: Option, + /// Onchain lockup address. + pub lockup_address: Option, + /// The address funds are claimed to (reverse) or refunded to (submarine). + pub onchain_address: Option, + pub timeout_block_height: u64, + pub created_at: u64, + /// Txid of the claim transaction once broadcast (reverse swaps). + pub claim_tx_id: Option, + /// Txid of the refund transaction once broadcast (submarine swaps). + pub refund_tx_id: Option, +} + +/// Events emitted to a registered [`crate::modules::boltz::BoltzEventListener`] +/// as swaps progress through their lifecycle. +#[derive(Debug, Clone, uniffi::Enum)] +pub enum BoltzSwapEvent { + /// The swap transitioned to a new status. + StatusUpdate { + swap_id: String, + status: BoltzSwapStatus, + }, + /// A reverse swap was claimed onchain. `txid` is the claim transaction. + Claimed { swap_id: String, txid: String }, + /// A submarine swap was refunded onchain. `txid` is the refund transaction. + Refunded { swap_id: String, txid: String }, + /// An error occurred while processing the swap (e.g. an auto-claim failed). + Error { swap_id: String, message: String }, +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 3bc1337..beb8686 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -1,5 +1,6 @@ pub mod activity; pub mod blocktank; +pub mod boltz; pub mod lnurl; pub mod onchain; pub mod pubky;