Skip to content

feat(boltz): Boltz submarine & reverse swaps - #116

Open
coreyphillips wants to merge 20 commits into
masterfrom
feat/boltz-swaps-deterministic-keys
Open

feat(boltz): Boltz submarine & reverse swaps#116
coreyphillips wants to merge 20 commits into
masterfrom
feat/boltz-swaps-deterministic-keys

Conversation

@coreyphillips

Copy link
Copy Markdown
Collaborator

Summary

Adds a boltz module 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-client crate. 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.db stores only a monotonic per-swap derivation index.

Consequences:

  • A leaked database cannot move funds (it holds no key material).
  • Swaps are recoverable two ways: same-device (index + in-memory seed), or seed-only via Boltz's rescue API if boltz.db is lost.
  • The wallet 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.

Reviewers: please confirm threading the seed through these entry points fits Bitkit's key-handling conventions on the Swift/Kotlin side.

What's included

  • Submarine create/refund and reverse create/claim, with cooperative key-path → script-path fallback.
  • Managed WebSocket updates stream; auto-claims reverse swaps on transaction.confirmed (not mempool, to avoid revealing the preimage against an unconfirmed lockup).
  • Atomic, collision-free swap-index reservation; PRAGMA user_version migration anchor; input validation on create.
  • Idempotent claim/refund — returns the recorded txid without re-broadcasting.
  • Typed lifecycle status with forward-compatible Unknown { raw }; recovery/listing APIs.
  • Only one updates stream (one network) runs at a time — documented.

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.

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.
@coreyphillips coreyphillips changed the title feat(boltz): Boltz submarine & reverse swaps with deterministic keys feat(boltz): Boltz submarine & reverse swaps Jun 26, 2026

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed the current draft, added a few considerations in scoped review comments

Comment thread src/modules/boltz/api.rs
Comment thread src/lib.rs
Comment thread src/lib.rs Outdated
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.
@coreyphillips
coreyphillips marked this pull request as ready for review July 14, 2026 16:16
…terministic-keys

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	Package.swift
#	bindings/android/gradle.properties
#	bindings/android/lib/src/main/jniLibs/arm64-v8a/libbitkitcore.so
#	bindings/android/lib/src/main/jniLibs/armeabi-v7a/libbitkitcore.so
#	bindings/android/lib/src/main/jniLibs/x86/libbitkitcore.so
#	bindings/android/lib/src/main/jniLibs/x86_64/libbitkitcore.so
#	bindings/ios/BitkitCore.xcframework.zip
#	bindings/ios/BitkitCore.xcframework/ios-arm64-simulator/libbitkitcore.a
#	bindings/ios/BitkitCore.xcframework/ios-arm64/libbitkitcore.a
#	bindings/python/bitkitcore/libbitkitcore.dylib
#	bindings/python/setup.py
@coreyphillips

coreyphillips commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Latest release for testing can be found as v0.5.1 here.

@ovitrif
ovitrif self-requested a review July 27, 2026 14:12

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I focused this review on the Boltz feature behavior and its fund-recovery paths. I left six scoped comments covering response validation, recovery state, updates lifecycle, input validation, and the documented FFI signature.

Comment thread src/modules/boltz/api.rs Outdated
// refund key and the invoice's preimage hash and checks it hashes to the
// address Boltz returned. Without it, a malicious or buggy server could
// hand back an address we cannot refund from.
response

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the current dependency validation leaves several Bitcoin response terms unbound: submarine validation does not compare the script hashlock with the invoice, while reverse validation does not compare the script hashlock with the preimage or the invoice amount with amount_sat. Could we add those checks before returning payment details, with focused mismatched-response tests?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and worth spelling out how much validate() leaves open on the Bitcoin branch: CreateSubmarineResponse::validate parses the invoice but never compares the script hashlock to it (only the Liquid arm does), and CreateReverseResponse::validate checks the invoice's payment hash and the lockup address but neither the script hashlock nor the invoice amount. A server could return an internally consistent script/address pair whose hashlock it controls, and for submarine swaps that is a theft vector: it could claim the lockup with its own preimage without paying the invoice. For reverse swaps a wrong hashlock silently breaks our script-path claim, and an unchecked amount lets the invoice ask for more than requested.

Fixed in 498be8f with a new validation module. validate_submarine_response binds the script hashlock to the invoice's payment hash160, and validate_reverse_response binds the script hashlock to our preimage's hash160 and the invoice amount to the requested amount_sat. Both wrap the existing response.validate call and run before anything is persisted or returned.

The mismatched-response tests build fixtures whose script, tree and taproot lockup address all agree with each other, so boltz-client's own address validation passes and only the term under test is wrong, which is exactly the malicious-server shape. Covered: reverse hashlock mismatch, reverse invoice amount mismatch, submarine hashlock mismatch, plus consistent baselines for both directions that must validate.

Comment thread src/modules/boltz/db.rs Outdated
.list_swaps()
.await?
.into_iter()
.filter(|r| !BoltzSwapStatus::from_raw(&r.status).is_terminal())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think recovery needs to depend on local completion state here. A cooperative reverse claim can disclose the preimage before Electrum broadcast; if broadcast then fails, Boltz may report invoice.settled and this filter removes the swap even though claim_tx_id is absent. Delayed server updates can also overwrite a locally recorded completion status. Could we keep the swap recoverable while its local txid is absent and preserve local completion once a txid exists?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right on both halves, and the scenario is real: the cooperative claim posts the preimage to Boltz before the Electrum broadcast, so Boltz can settle the invoice while our claim transaction never reached the chain. With the old filter that swap silently left the pending set with funds still sitting in the lockup.

Fixed in 498be8f. Recovery now depends on local completion state via SwapRecord::is_locally_complete: a terminal server status only completes a reverse swap if a claim txid is recorded locally, so invoice.settled with no local txid stays in the pending set. And update_status now refuses to touch a row once claim_tx_id or refund_tx_id is set, so a delayed or re-ordered server update (the WebSocket and the reconcile loop both feed it) cannot overwrite a locally recorded completion.

One step further so the swap actually completes rather than just staying visible: should_auto_claim now also fires on invoice.settled when no local claim txid exists. The periodic reconcile therefore retries the broadcast every cycle until it lands. The preimage is already disclosed at that point, so retrying carries no added risk.

Tests added: a settled reverse swap without a local claim txid stays recoverable and drops out once set_claim_tx records one; late server updates cannot regress a locally recorded claimed/refunded status; and the new invoice.settled arm of should_auto_claim, including that a recorded txid still suppresses it.

};

let mut guard = updates_cell().lock().await;
*guard = Some(UpdatesHandle {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to serialize the updates-stream replacement. Two concurrent starts can both pass stop_swap_updates, spawn their tasks, and then replace each other here. Dropping the first handle does not abort its detached tasks, so an obsolete stream can remain active. Could we serialize the start/stop transition and ensure tasks spawned during failed or superseded initialization are aborted, with a concurrent-start test?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Dropping a JoinHandle detaches rather than aborts, so two concurrent starts could both pass the stop, and whichever handle was overwritten left its three tasks (WebSocket loop, processor, reconciler) running forever. The error path had the same leak in miniature: a failing list_pending_swaps returned after the WebSocket task was already spawned.

Fixed in 498be8f by serializing the whole transition. start_swap_updates now holds the stream slot's mutex across teardown, setup and publication: it takes the old handle and calls its new shutdown() (which aborts all three tasks and drops the WebSocket), runs the only fallible step before anything is spawned so a failed start cannot leak a task, then spawns and stores the new handle while still holding the lock. stop_swap_updates goes through the same shutdown path, and the old internal stop call inside start is gone (it would now deadlock, which is a nice guarantee that every transition goes through the one lock).

For the test, aborts had to be observable, so each spawned task future carries an RAII marker counted in a static gauge; the count drops when the future is dropped, whether it completed or was aborted. The new test runs four concurrent starts against a shared slot and asserts exactly one stream (three live tasks) survives, then that stop_swap_updates takes it to zero.

Comment thread src/modules/boltz/api.rs Outdated
error_details: "amount_sat must be greater than 0".to_string(),
});
}
if claim_address.trim().is_empty() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we validate claim_address against the selected network before sending the create request? Reverse creation currently only checks that it is nonblank, while parsing and network validation happen later, after the caller may have paid the invoice, and the stored destination cannot be replaced.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 498be8f. create_reverse_swap now runs the claim address through validate_onchain_address before the create request: it must parse as a Bitcoin address and belong to the selected network, and the canonical (trimmed) form is what gets persisted as the claim destination. A malformed or wrong-network address now fails at creation, before any invoice exists to pay, instead of at claim time when the destination can no longer be replaced.

The refund path gets the same treatment for consistency: refund_submarine_swap_guarded validates the refund address against the swap's network before any transaction construction. That check sits after the idempotent early return, since a swap with a recorded refund txid returns it without using the address at all.

Tests cover a mainnet address rejected on testnet and regtest (and the reverse), garbage and empty input, and that surrounding whitespace is tolerated and stripped. The live E2E test now also picks its sample claim address per network so it keeps working under BOLTZ_LIVE_NETWORK overrides.

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));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we require a finite, positive fee rate before starting updates or constructing a claim/refund? Arbitrary f64 values reach Fee::Relative; negative values and NaN become zero, while infinity saturates, so invalid configuration can fail only after fund-moving work begins.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 498be8f. validate_fee_rate requires a provided rate to be finite and strictly positive (None still falls back to the built-in default), and it runs at every entry point that leads to Fee::Relative: boltz_start_swap_updates rejects a bad auto-claim rate before any stream state exists, and claim_reverse_swap_guarded and refund_submarine_swap_guarded reject one at the top, before the swap lock is taken or any Boltz/Electrum work begins. Invalid configuration now surfaces as InvalidInput immediately instead of after fund-moving work has started.

Tests cover NaN, negative, zero and both infinities being rejected, and that valid rates and None pass.

Comment thread src/modules/boltz/README.md Outdated
boltzGetSwap(swapId) -> BoltzSwap?
boltzClaimReverseSwap(swapId, mnemonic, bip39Passphrase?, feeRateSatPerVb?) -> String (txid)
boltzRefundSubmarineSwap(swapId, refundAddress, mnemonic, bip39Passphrase?, feeRateSatPerVb?) -> String (txid)
boltzStartSwapUpdates(network, listener, mnemonic, bip39Passphrase?, feeRateSatPerVb?) // managed WebSocket

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add the explicit acceptZeroConf argument to the API table and examples, using false in the safety-first examples? The generated Swift, Kotlin, and Python APIs require it, while the documentation still uses the five-argument signature.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 498be8f. The API table now shows the six-argument boltzStartSwapUpdates signature including acceptZeroConf, and every example that starts the stream (Swift, Kotlin, Python, and the recovery-after-restart snippet) passes it explicitly as false with a note that false claims only after the lockup confirms. The lifecycle section now describes acceptZeroConf as the opt-in for mempool-time claiming instead of the outdated suggestion to call boltzClaimReverseSwap manually for the 0-conf case, and the recovery section was updated for the new local-completion semantics of boltzListPendingSwaps from the other thread.

…m lifecycle

- Bind the Bitcoin response terms boltz-client's validate leaves open:
  submarine script hashlock vs invoice payment hash, reverse script
  hashlock vs our preimage, and reverse invoice amount vs the requested
  amount_sat, with consistent-but-wrong response fixtures covering each
- Validate the reverse claim address against the selected network before
  the create request; same for refund addresses before construction
- Require a finite, positive fee rate before starting updates or
  constructing a claim/refund
- Judge recovery by local completion: a settled reverse swap stays in
  the pending set until its claim txid is recorded locally, the updates
  stream retries such claims, and server updates no longer overwrite a
  locally recorded completion status
- Serialize updates-stream replacement under the stream slot lock and
  abort superseded streams' tasks, with a concurrent-start test
- Document the acceptZeroConf argument in the README table and examples
Takes master's Android publishing fixes (ELF build ids in .cargo/config.toml
and the 16 KB-compatible gradle-publish workflow) and resolves the version
and binary-artifact conflicts in favor of this branch:

- Version stays 0.5.2 (master moved to 0.4.4 on the 0.4.x line; this branch
  bumped minor for the new boltz API) across Cargo.toml, Package.swift,
  gradle.properties and setup.py
- Checked-in binaries (Android .so, iOS xcframework zip, Python dylib) stay
  at this branch's builds since they carry the boltz exports the committed
  binding sources reference; the next release build will regenerate them
  with master's linker flags included
- The expanded xcframework static libraries stay deleted and gitignored
  (they exceed GitHub's 100 MB file limit; the zip is the SPM source)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants