feat(boltz): Boltz submarine & reverse swaps#116
Conversation
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.
ovitrif
left a comment
There was a problem hiding this comment.
reviewed the current draft, added a few considerations in scoped review comments
| referral_id: None, | ||
| webhook: None, | ||
| }; | ||
| let response = client |
There was a problem hiding this comment.
Could we validate the Boltz create response before persisting or returning it? boltz-client exposes response.validate(...) for both submarine and reverse swaps, and that check proves the returned script/address matches our derived key and invoice/preimage before the app sends funds or pays the invoice.
There was a problem hiding this comment.
Good catch, done. Both create paths now validate before the response can do any damage.
create_submarine_swap calls response.validate(&invoice, &refund_public_key, network.as_chain()) and create_reverse_swap calls response.validate(&preimage, &claim_public_key, network.as_chain()). Both run immediately after the API call and before insert_swap and before the response is handed back, so a response that fails validation never reaches the database and never reaches the app. That ordering is the point: for submarine swaps the caller is about to fund the lockup address, and for reverse swaps it is about to pay the invoice, so validating after persisting would still let the app act on a bad response.
Failures surface as BoltzError::SwapError with the underlying reason.
One limitation worth stating plainly: I could not exercise this against a live Boltz response. The live_reverse_swap test skips right now because the Boltz testnet API is returning 502 Bad Gateway, so the validation is covered by the type checker and the existing offline tests, not by an observed round trip against a real server. Once testnet is healthy, cargo test modules::boltz -- --ignored will cover it.
|
|
||
| /// Fetch fees and limits for submarine swaps (onchain -> Lightning). | ||
| #[uniffi::export] | ||
| pub async fn boltz_get_submarine_limits( |
There was a problem hiding this comment.
Since this adds new UniFFI exports and public types, could we bump the package version and regenerate the mobile/Python bindings in this PR? Right now Rust builds, but downstream iOS/Android consumers would not get the new boltz* APIs from the checked-in artifacts.
There was a problem hiding this comment.
Done. Bumped to 0.5.0 and regenerated the iOS, Android and Python bindings, so the boltz* APIs are now in the checked-in artifacts.
Minor rather than patch: this PR adds new public API, and build.sh's default patch bump would have collided with the 0.4.1 already sitting on fix/broadcast-return-known-txid.
Version is in sync across Cargo.toml, Package.swift, bindings/android/gradle.properties and bindings/python/setup.py. The generated surfaces now carry the boltz exports, where before they had none: 231 references in bitkitcore.swift, 285 in bitkitcore.android.kt, 429 in the Python module, plus rebuilt native libraries for all four Android ABIs and both iOS slices.
One thing that needs a second opinion, since it changes what the repo tracks. Linking boltz-client grew the xcframework's static libraries from ~98 MB to ~113 MB, which is over GitHub's hard 100 MB per-file limit, so the push was rejected outright. They were already within 2 MB of the limit on master, so this was going to happen on the next dependency regardless. Stripping only got them to 103 MB, still over.
I stopped tracking BitkitCore.xcframework/**/libbitkitcore.a and gitignored it. Those files only duplicate the compressed copies inside BitkitCore.xcframework.zip (76 MB, still committed), and that zip is what Package.swift actually downloads from the GitHub release via binaryTarget(url:checksum:), so no consumer resolves iOS through the expanded directory. Nothing about the SPM consumption path changes.
Worth deciding as a team whether that is the fix you want long term, or whether these binaries should move to Git LFS or out of the repo entirely. The zip is at 76 MB and climbing, so it will hit the same wall eventually.
Separately: build.sh -r --<bump> all fails at the Android step if ANDROID_NDK_HOME points at an NDK that is not installed, and on the way out it deletes the checked-in jniLibs and Kotlin bindings instead of leaving them alone. It also strips [[bin]] example from Cargo.toml and removes example/main.rs, and skips its own cleanup if interrupted. I restored all of that by hand here, but the script could use some hardening.
| })?; | ||
| // 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() { |
There was a problem hiding this comment.
Could we serialize claim/refund attempts per swap, or mark a claim/refund as in progress before broadcasting? As written, auto-claim and a manual recovery call can both see no recorded txid and try to broadcast before either path persists the result.
There was a problem hiding this comment.
You are right, and the idempotency check I had was not sufficient. Reading claim_tx_id, broadcasting, then recording the txid spans several .await points (Boltz plus Electrum round trips), so the auto-claim and a manual recovery call could both observe None and both broadcast. Checking harder at the top could not fix that; the check and the write had to become atomic.
Fixed by serializing per swap. New src/modules/boltz/guard.rs keeps a per-swap-id async lock, and claiming and refunding now go through single guarded entry points, claim_reverse_swap_guarded and refund_submarine_swap_guarded. Each one takes the swap's lock, re-reads the record under the lock (the caller's copy may be stale by then), returns the recorded txid if one appeared while it waited, and otherwise broadcasts and persists while still holding the lock.
Both racing paths now funnel through that: boltz_claim_reverse_swap / boltz_refund_submarine_swap in lib.rs, and auto_claim in the listener. Whichever arrives second waits, sees the first one's txid, and broadcasts nothing. The claim path returns a ClaimOutcome so the listener can tell the two cases apart: it only emits BoltzSwapEvent::Claimed when it actually broadcast, rather than double-reporting a claim the manual caller already received.
Tests added:
- a claim of an already-claimed swap returns the recorded txid without broadcasting (the record points at an unreachable Electrum server, so reaching the broadcast path at all would fail the test rather than pass it)
- the same for refunds
- a missing swap reports
NotFound - four concurrent claims of one swap never overlap in the critical section, while locks on different swaps stay independent
Scope: this serializes within the process, which is where the race you identified lives, since both racers are in this library. It is not a cross-process lock.
Add fee_rate_sat_per_vb to boltz_start_swap_updates so the wallet provides the fee rate used for automatic reverse-swap claims; core does no fee estimation. The rate is threaded through AutoClaimConfig into the auto-claim and falls back to the conservative default when None. Update the README signature and examples accordingly.
set_claim_tx and set_refund_tx previously updated only the tx id column, so a claimed or refunded swap kept its pre-claim status (the status column is otherwise advanced only by the live updates stream). They now also set the terminal transaction.claimed / transaction.refunded status, so claimed and refunded swaps report the correct state and drop out of the pending set.
Recording a claim or refund tx id now also advances the swap to its terminal status, so the test assertions for the pre-claim status and the pending set were stale. Also covers set_refund_tx, which was untested.
Rebuild the swap script from our own key and check it against the address Boltz returned, before the record is stored or the response reaches the caller. A submarine caller is about to fund the lockup address and a reverse caller is about to pay the invoice, so validating any later would still let the app act on a bad response.
Reading claim_tx_id, broadcasting, then recording the txid spans several await points, so the automatic claim from the updates stream and a manual recovery call could both see no recorded txid and both broadcast. Route both through guarded entry points that hold a per-swap lock across the whole read-broadcast-record sequence and re-read the record under it, so the second caller returns the first one's txid instead of broadcasting again.
Adds new public API (the boltz exports), so this is a minor bump. The iOS, Android and Python bindings now carry the boltz surface, which the checked-in artifacts were missing. Stop tracking the xcframework's static libraries. Linking boltz-client grew them past GitHub's 100 MB file limit, and they only duplicate the compressed copies inside BitkitCore.xcframework.zip, which is the archive Package.swift downloads from the release.
Summary
Adds a
boltzmodule integrating Boltz submarine (onchain → Lightning) and reverse (Lightning → onchain) swaps behind the UniFFI surface, for iOS/Android/Python.The dangerous cryptography (MuSig2 Taproot cooperative signing, swap scripts, claim/refund tx construction) is delegated to the
boltz-clientcrate. This module adds deterministic key management, SQLite persistence, lifecycle tracking, automatic claiming, and the FFI surface.Key design decision: deterministic keys, no stored secrets
Swap keys and reverse-swap preimages are derived from the wallet seed via Boltz's BIP85 scheme (
SwapMasterKey/derive_swapkey,Preimage::from_swap_key) — never random, never persisted.boltz.dbstores only a monotonic per-swap derivation index.Consequences:
boltz.dbis lost.mnemonic(+ optional BIP39 passphrase) now flows through the create/claim/refund/start-updates FFI calls. The background updates stream holds the mnemonic in memory only for its lifetime (dropped on stop) to auto-claim. The passphrase must match the wallet's, or derived keys won't control the funds.What's included
transaction.confirmed(not mempool, to avoid revealing the preimage against an unconfirmed lockup).PRAGMA user_versionmigration anchor; input validation on create.Unknown { raw }; recovery/listing APIs.Testing
cargo build, all 9 boltz unit tests, clippy, and fmt are clean. Tests cover status mapping, DB round-trip/recovery, monotonic index reservation, and deterministic derivation. An ignored live E2E test creates a real reverse swap and cryptographically validates the locally-derived redeem script + invoice against Boltz's response (no broadcast).Known follow-up: the claim/refund broadcast paths are not yet covered by an automated test — they need a regtest Boltz + Electrum stack. Recommended as a follow-up.