Skip to content

Add BOLT12 swap support via Boltz API#197

Open
vincenzopalazzo wants to merge 7 commits intomasterfrom
claude/sweet-dijkstra
Open

Add BOLT12 swap support via Boltz API#197
vincenzopalazzo wants to merge 7 commits intomasterfrom
claude/sweet-dijkstra

Conversation

@vincenzopalazzo
Copy link
Copy Markdown
Collaborator

@vincenzopalazzo vincenzopalazzo commented Mar 25, 2026

Summary

  • Introduce LnInvoice enum (Bolt11/Bolt12) with backward-compatible serde, replacing the Bolt11Invoice type on SubmarineSwapData.invoice
  • Add fetch_bolt12_invoice to resolve BOLT12 offers into invoices via POST /lightning/BTC/bolt12_fetch
  • Add prepare_bolt12_offer_payment and pay_bolt12_offer for submarine swaps paying BOLT12 offers
  • Add register_bolt12_offer and update_bolt12_webhook for BOLT12 reverse swap offer registration
  • Implement minimal BOLT12 invoice TLV parser (extract_bolt12_payment_hash) to extract payment hashes without heavy LDK dependencies
  • Export Bolt12InvoiceRequestPayload and Bolt12InvoiceParams types for webhook/WebSocket integration

Notes

  • Submarine swap flow for BOLT12 mirrors BOLT11: fetch invoice from offer, create swap, fund VHTLC
  • Reverse swap BOLT12 registration is provided as building blocks; full WebSocket invoice request handling is left for a follow-up
  • Existing serialized SubmarineSwapData (with bare Bolt11 invoice strings) deserializes correctly via serde(untagged) -- verified by tests
  • BOLT12 invoice signing key verification is documented as a caller responsibility when using fetch_bolt12_invoice directly

Test plan

  • Unit tests for BigSize TLV parsing (test_read_bigsize)
  • Unit tests for BOLT12 payment hash extraction (roundtrip, preceding TLVs, missing hash, wrong HRP)
  • Unit tests for LnInvoice serde roundtrip (Bolt11, Bolt12, backward compat)
  • cargo check clean, cargo test -p ark-client all 12 tests pass
  • E2e testing with Boltz regtest requires Boltz BOLT12 support in test infra

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added BOLT12 invoice and offer support for submarine swaps alongside existing BOLT11.
    • Client actions to fetch BOLT12 invoices, prepare and pay BOLT12 offers, register offers, and update webhook configuration.
    • Improved invoice parsing and payment-hash validation for BOLT12.
  • Tests

    • Added tests for BOLT12 parsing, payment-hash extraction (various cases), and invoice serialization/deserialization compatibility.

Add support for BOLT12 submarine swaps (paying offers) and the building
blocks for BOLT12 reverse swaps (offer registration). This includes:

- LnInvoice enum supporting both BOLT11 and BOLT12 invoices with
  backward-compatible serde for existing stored swap data
- fetch_bolt12_invoice: resolve BOLT12 offers into invoices via Boltz
- prepare_bolt12_offer_payment / pay_bolt12_offer: submarine swaps
  using BOLT12 offers
- register_bolt12_offer / update_bolt12_webhook: register offers with
  Boltz for reverse swap invoice request delivery
- Minimal BOLT12 invoice TLV parser to extract payment hashes without
  requiring heavy LDK dependencies
- Bolt12InvoiceRequestPayload type for webhook/WebSocket integration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds BOLT12 invoice support: a new LnInvoice enum, BOLT12 DTOs and client methods, TLV BigSize parsing to extract payment-hash, updates submarine-swap storage to use LnInvoice, and accompanying unit tests including Serde/backward-compat checks.

Changes

Cohort / File(s) Summary
BOLT12 invoice & client logic
ark-client/src/boltz.rs
Added public LnInvoice enum (untagged: Bolt11(Bolt11Invoice) / Bolt12(String)), payment_hash()/Display/From<Bolt11Invoice>. Introduced Bolt12SubmarineSwapResult, Bolt12InvoiceParams, Bolt12InvoiceRequestPayload. Added client methods for BOLT12 flows (fetch_bolt12_invoice, prepare_bolt12_offer_payment, pay_bolt12_offer, internal create_bolt12_submarine_swap, register_bolt12_offer, update_bolt12_webhook). Implemented BOLT12 TLV BigSize parsing (read_bigsize, extract_bolt12_payment_hash) and added unit tests (BigSize, TLV extraction, Serde roundtrips, backward-compat).
Crate exports
ark-client/src/lib.rs
Re-exported BOLT12 types and LnInvoice from boltz (Bolt12InvoiceParams, Bolt12InvoiceRequestPayload, Bolt12SubmarineSwapResult, LnInvoice).
Tests / swap storage update
ark-client/src/swap_storage/sqlite.rs
Updated test helper to wrap BOLT11 invoices as crate::boltz::LnInvoice::Bolt11(...) to match SubmarineSwapData.invoice type change.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant BoltzAPI as BoltzAPI
    participant Parser as Parser
    participant Storage as Storage

    Client->>BoltzAPI: fetch_bolt12_invoice / prepare_bolt12_offer_payment / pay_bolt12_offer / register_bolt12_offer
    BoltzAPI-->>Client: invoice string / payment responses
    Client->>Parser: extract_bolt12_payment_hash(invoice)
    Parser-->>Client: payment_hash (32 bytes)
    Client->>Storage: persist SubmarineSwapData(invoice: LnInvoice::Bolt12 | LnInvoice::Bolt11)
    Storage-->>Client: ack
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • luckysori
  • bonomat
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: introducing BOLT12 swap support via the Boltz API, which aligns with the primary additions of BOLT12 enum variants, DTOs, and client methods throughout the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/sweet-dijkstra

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@arkanaai
Copy link
Copy Markdown

arkanaai bot commented Mar 25, 2026

🔍 Arkana PR Review — BOLT12 Swap Support

Overview

This PR adds BOLT12 offer payment support (submarine swaps) and BOLT12 reverse swap offer registration to the Rust SDK, interfacing with Boltz's BOLT12 API endpoints. It introduces an LnInvoice enum for backward-compatible BOLT11/BOLT12 handling, a minimal BOLT12 TLV parser for payment hash extraction, and several new public API methods.

Good: well-documented, solid test coverage for the TLV parser and serde logic, and the backward-compatibility approach via serde(untagged) is thoughtful.


Security Findings

1. No invoice-to-offer binding validation in pay_bolt12_offer (Medium)

pay_bolt12_offer fetches a BOLT12 invoice from Boltz and immediately uses it for a submarine swap without verifying that the invoice's signing key matches the offer's signing key. The PR correctly documents this as "caller responsibility" for fetch_bolt12_invoice, but pay_bolt12_offer is a high-level convenience method that users will call expecting it to be safe end-to-end.

If the Boltz API is compromised or MITM'd (even with TLS, consider supply-chain scenarios), a rogue invoice could redirect the payment. Consider adding at minimum a warning in the pay_bolt12_offer doc-comment, or ideally validating the invoice signing key against the offer when the offer includes one.

2. LnInvoice::Bolt12 accepts any string (Low)

The serde(untagged) approach means any string that fails BOLT11 parsing silently becomes LnInvoice::Bolt12. This is fine for backward compat, but means corrupted/invalid data won't error at deserialization time — it'll surface later when payment_hash() is called. Worth documenting as a known tradeoff.

3. extract_bolt12_payment_hash — no TLV ordering validation (Info)

BOLT12 TLV streams are required to have monotonically increasing type numbers. The parser doesn't enforce this, which is fine for extraction purposes but means it would accept malformed invoices. Not a security issue for payment hash extraction specifically, just noting it.


Code Quality

4. Significant code duplication between prepare_bolt12_offer_payment and pay_bolt12_offer (Blocking-ish)

These two methods share ~80 lines of identical logic (invoice fetch, swap creation, storage insert). pay_bolt12_offer should call prepare_bolt12_offer_payment internally and then fund the VHTLC, rather than duplicating the entire flow. This is a maintenance hazard — a bug fix in one path could easily be missed in the other.

Suggested refactor:

pub async fn pay_bolt12_offer(&self, offer: &str, amount_sats: Option<u64>) -> Result<Bolt12SubmarineSwapResult, Error> {
    let swap_data = self.prepare_bolt12_offer_payment(offer, amount_sats).await?;
    let txid = self.send_vtxo(swap_data.vhtlc_address, swap_data.amount).await?;
    // ...
}

5. No reqwest::Client reuse

Each BOLT12 method creates a new reqwest::Client instance. This bypasses connection pooling and may cause TLS handshake overhead on repeated calls. I see the existing BOLT11 code does the same, so this is pre-existing, but worth noting for a future cleanup.

6. CreateStringInvoiceSubmarineSwapRequest duplicates CreateSubmarineSwapRequest

The only difference is invoice: String vs invoice: Bolt11Invoice. Consider making the existing request generic or using serde_as to serialize both as strings, since the Boltz API accepts the invoice as a string anyway.


Protocol Correctness

  • ✅ Submarine swap flow correctly mirrors the BOLT11 path: fetch invoice → extract payment hash → create swap → fund VHTLC
  • ✅ RIPEMD160 hash of SHA256 payment hash for preimage_hash matches existing convention
  • ✅ Key derivation and storage follow the same patterns as BOLT11 swaps
  • ✅ Reverse swap BOLT12 registration correctly scoped as building blocks (webhook + WS)
  • ✅ BigSize TLV parser implementation matches the Lightning spec encoding

Cross-Repo Impact

  • The SubmarineSwapData.invoice field type change from Bolt11Invoice to LnInvoice is backward-compatible for deserialization (verified by tests), but any downstream code that pattern-matches on swap_data.invoice expecting a Bolt11Invoice directly will need updating.
  • New public exports (LnInvoice, Bolt12SubmarineSwapResult, Bolt12InvoiceParams, Bolt12InvoiceRequestPayload) expand the SDK's public API surface.

Summary

Solid foundational work for BOLT12 support. The main actionable item is the code duplication between prepare_bolt12_offer_payment and pay_bolt12_offer — this should be refactored before merge to avoid divergent bug fixes. The invoice-to-offer binding validation is worth discussing as a follow-up hardening measure.

- Fix dprint formatting (doc comment line wrapping)
- Fix sqlite test to use LnInvoice::Bolt11 wrapper for invoice field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ark-client/src/boltz.rs`:
- Around line 1898-1902: The pay_bolt12_offer flow currently commits funds
immediately after bolt12_fetch, which allows a malicious fetch endpoint to
substitute an invoice it controls; update pay_bolt12_offer to first verify the
fetched BOLT12 invoice is bound to the original Offer by checking the invoice's
signing key against the Offer's signing key (or against the public key of the
final hop from the Offer's message paths) and only commit/lock funds after that
verification succeeds; likewise apply the same validation to the related
functions/flows in the 2058-2157 range to ensure no other one-shot APIs fund
before signature/offer-binding is validated, or remove the one-shot commit
behavior until this check is implemented.
- Around line 1925-1932: The BOLT12 HTTP paths are creating an unconfigured
reqwest client (reqwest::Client::new()) which ignores the timeout in
self.inner.timeout; replace those calls by using the same timeout-configured
client used by get_fees/get_limits (or introduce a small helper like
make_timeout_client(&self.inner.timeout)) and use that client in
fetch_bolt12_invoice, prepare_bolt12_offer_payment, pay_bolt12_offer,
register_bolt12_offer, and update_bolt12_webhook so requests respect the
configured timeout and reuse shared setup.
- Around line 3090-3109: The TLV length is currently truncated with `as` and
then added to `cursor` unsafely; replace the unsafe cast and unchecked addition
by first converting `tlv_len` (u64) to usize via `try_into()` and returning an
error if it fails, and compute the end index using
`cursor.checked_add(tlv_len_usize)` to detect overflow before slicing `data`;
apply these checks in the parsing block that handles
`BOLT12_PAYMENT_HASH_TLV_TYPE` (and the surrounding TLV loop) so you only slice
`data[cursor..end]` after a successful `try_into()` and `checked_add()`,
returning the existing ad_hoc error on failure.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7c731fdf-2ca5-49b3-8b35-4630977a9297

📥 Commits

Reviewing files that changed from the base of the PR and between 7d0e596 and ed96e71.

📒 Files selected for processing (2)
  • ark-client/src/boltz.rs
  • ark-client/src/lib.rs

@arkanaai
Copy link
Copy Markdown

arkanaai bot commented Mar 25, 2026

🔍 Arkana PR Review — Follow-up (CI fixes)

New commit: d185426b — "fix: Address CI failures in BOLT12 support"

Changes Since Last Review

Formatting-only changes (doc comment line wrapping for dprint) and a test fix wrapping a bare Bolt11Invoice in LnInvoice::Bolt11() to match the updated SubmarineSwapData.invoice type.

Assessment

No functional changes — purely mechanical fixes to pass CI. The sqlite test update is correct and consistent with the type change in the main commit. No new security concerns.

- Document trust model for BOLT12 offer payment methods (invoice
  is fetched via Boltz and not locally verified against offer signing
  key, matching existing BOLT11 trust model)
- Apply configured timeout to all BOLT12 HTTP clients via
  reqwest::Client::builder().timeout(self.inner.timeout)
- Use try_into/checked_add for TLV length parsing to prevent
  truncation on 32-bit platforms and overflow in cursor arithmetic

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@arkanaai
Copy link
Copy Markdown

arkanaai bot commented Mar 25, 2026

🔍 Follow-up Review (commit a6d8ccff)

All three items from the previous review are addressed:

  1. ✅ Timeout propagation — All 7 reqwest::Client::new() instances replaced with Client::builder().timeout(self.inner.timeout).build(). Matches the existing BOLT11 timeout behavior.

  2. ✅ Trust model documentationprepare_bolt12_offer_payment and pay_bolt12_offer now have explicit # Trust Model doc sections explaining that BOLT12 invoices are fetched via Boltz without local signing key verification, and that this matches the existing BOLT11 trust model. Clear and accurate.

  3. ✅ TLV parser hardeningextract_bolt12_payment_hash now uses try_into() for the u64→usize length conversion (prevents truncation on 32-bit) and checked_add() for cursor arithmetic (prevents overflow). Both produce clear error messages.

No new concerns with this commit. The remaining code duplication between prepare_*/pay_* methods (flagged earlier as a nit) is fine to address in a follow-up if desired.

— Arkana (automated review)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ark-client/src/boltz.rs`:
- Around line 79-85: The current #[serde(untagged)] LnInvoice enum lets any
string become LnInvoice::Bolt12, so change to a custom Deserialize impl for
LnInvoice that first attempts to parse the input string as a Bolt11Invoice
(using s.parse::<Bolt11Invoice>() or equivalent) and returns LnInvoice::Bolt11
on success; if that fails, validate the string is a well-formed BOLT12 by
calling the existing extract_bolt12_payment_hash(&s) (or the project’s BOLT12
validation function) and return LnInvoice::Bolt12(s) only if validation
succeeds, otherwise return a serde::de::Error::custom to preserve the previous
fail-fast behavior; remove or replace the #[serde(untagged)] annotation and
ensure payment_hash()/other callers still work with the validated enum.
- Around line 133-140: The Bolt12InvoiceRequestPayload struct is missing the
WebSocket request identifier required for responding to `invoice.request`;
update the exported type Bolt12InvoiceRequestPayload to include a public id
field (e.g., pub id: String) alongside the existing offer and invoice_request
fields so the WebSocket `id` is deserialized/serialized with the struct (serde
rename_all = "camelCase" will handle the field name).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 38864820-fdac-4e55-a84e-446248c98789

📥 Commits

Reviewing files that changed from the base of the PR and between d185426 and 312e8ed.

📒 Files selected for processing (1)
  • ark-client/src/boltz.rs

…t id

- Replace serde(untagged) Deserialize with custom impl that validates
  Bolt12 invoice strings by checking payment hash extractability,
  rejecting garbage strings at deser time instead of later at use
- Add optional `id` field to Bolt12InvoiceRequestPayload for WebSocket
  invoice.request messages (required when replying to Boltz via WS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@arkanaai
Copy link
Copy Markdown

arkanaai bot commented Mar 25, 2026

🔍 Arkana PR Review — rust-sdk#197

Add BOLT12 swap support via Boltz API (+791/-7, 3 files)

Summary

Introduces BOLT12 invoice/offer support for submarine swaps in the Rust SDK. Adds LnInvoice enum (Bolt11/Bolt12), a minimal BOLT12 TLV parser for payment hash extraction, and new client methods for BOLT12 submarine + reverse swaps via Boltz.

✅ What looks good

  • Backward-compatible serde: LnInvoice with #[serde(untagged)] + custom Deserialize impl tries BOLT11 first, then validates BOLT12 — existing serialized SubmarineSwapData still works. Good test coverage for this.
  • Minimal TLV parser: extract_bolt12_payment_hash and read_bigsize are simple and correct — proper BigSize parsing per LN spec, bounds checking, and clear error messages. No heavy LDK dependency.
  • Consistent HTTP client: All reqwest::Client instances now use self.inner.timeout, fixing what was previously inconsistent (some used reqwest::Client::new() without timeout).
  • Well-documented trust model: Doc comments clearly state that BOLT12 invoices from Boltz are not locally verified against the offer's signing key, with a clear TODO to replace Bolt12(String) with LDK types.
  • Comprehensive tests: BigSize parsing, payment hash extraction (roundtrip, preceding TLVs, missing hash, wrong HRP), serde roundtrips, backward compat, garbage rejection.

⚠️ Issues

  1. Code duplication between prepare_bolt12_offer_payment and pay_bolt12_offer: These two methods share ~80% of their code (invoice fetch, keypair derivation, swap creation, storage insertion). The only difference is that pay_bolt12_offer also calls send_vtxo. Consider extracting the common setup into a private helper to reduce maintenance burden and inconsistency risk.

  2. CreateStringInvoiceSubmarineSwapRequest sends invoice as raw string: For BOLT11 swaps, the existing CreateSubmarineSwapRequest presumably sends a typed Bolt11Invoice. The new type sends a String. This works for Boltz, but means the SDK no longer validates that the invoice string is well-formed before sending it to the API (for BOLT12 specifically). The extract_bolt12_payment_hash call provides some validation, but it only checks bech32 + HRP + payment hash TLV presence, not full invoice structure.

  3. register_bolt12_offer / update_bolt12_webhook — no response type: Both methods discard the response body on success. If Boltz returns useful information (e.g., registration ID, expiry), it's silently dropped. Worth checking the Boltz API docs.

  4. LnInvoice::Bolt12 stores a raw string: The TODO comment acknowledges this. For now it's fine, but any code that pattern-matches on LnInvoice will need updating when LDK types are integrated. Consider adding a // Breaking change expected marker.

Security

  • Trust model is clearly documented: The Boltz API is trusted for invoice resolution (same trust as existing BOLT11 flows). This is appropriate — the VHTLC construction provides the actual security.
  • Payment hash extraction: Correctly validates TLV structure rather than blindly trusting the string. Good.
  • No key material handling changes: Keypair derivation uses the same next_keypair path as BOLT11 flows.

Cross-repo impact

  • SubmarineSwapData.invoice changed from Bolt11Invoice to LnInvoice — any code that accesses .invoice expecting Bolt11Invoice directly will break. This affects boltz-swap and potentially wallet if they depend on this type. The sqlite.rs test was updated, which confirms awareness.

Verdict

Good to go with the duplication concern as a follow-up. The core design is sound, trust model is well-documented, and backward compatibility is properly handled.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ark-client/src/boltz.rs`:
- Around line 3188-3218: The parser currently accepts non-canonical BigSize
encodings; update read_bigsize to reject oversized encodings by validating
minimality: after decoding val in the 0xfd branch (bytes with tag 0xfd) ensure
val >= 0xfd (i.e. > 0xfc) otherwise return an error; in the 0xfe branch ensure
val >= 0x10000 (i.e. > 0xffff) otherwise return an error; in the 0xff branch
ensure val >= 0x1_0000_0000 (i.e. > 0xffffffff) otherwise return an error; keep
the same error style and reference the same variables (data, val) and match arms
(0xfd, 0xfe, 0xff) in read_bigsize.
- Around line 3140-3178: The helper currently returns immediately on the first
TLV type 168, allowing payloads with valid-looking payment_hash followed by
truncated/garbage TLVs to be accepted; change it to fully validate the entire
TLV stream and only accept the invoice if all TLVs parse correctly and a
payment_hash was seen. Concretely, in the loop in boltz.rs (the TLV-parsing
helper used by LnInvoice::deserialize) do not return inside the tlv_type ==
BOLT12_PAYMENT_HASH_TLV_TYPE branch; instead, store the parsed 32-byte
payment_hash (e.g., into an Option<[u8;32]> or sha256::Hash variable) and
continue parsing the remaining TLVs with the same length/overflow checks; after
the loop, if the stored payment_hash exists return it, otherwise return an error
about missing payment_hash. This also ensures later payment_hash entries
override earlier ones if desired and that the entire TLV stream is validated
before accepting the invoice.
- Around line 1981-1989: fetch_bolt12_invoice currently returns whatever string
comes back from the bolt12_fetch response without validating it; update
fetch_bolt12_invoice to validate the returned invoice string before returning
(similar to the checks done in prepare_bolt12_offer_payment/pay_bolt12_offer) by
parsing/decoding the invoice (use the existing bolt12 invoice parsing/validation
routine or call the same validation helper those functions use) and return an
error (wrapped with context) if parsing/validation fails so callers cannot
persist or use an invalid invoice.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ba8cd0a0-21b6-480e-a09a-9ceb9d253967

📥 Commits

Reviewing files that changed from the base of the PR and between 312e8ed and 05f0a11.

📒 Files selected for processing (1)
  • ark-client/src/boltz.rs

- Extract `create_bolt12_submarine_swap` private method to deduplicate
  code between `prepare_bolt12_offer_payment` and `pay_bolt12_offer`
- Add breaking-change marker on `LnInvoice::Bolt12(String)` variant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vincenzopalazzo
Copy link
Copy Markdown
Collaborator Author

Addressed the arkana review feedback in f457840:

  1. Code duplication -- extracted create_bolt12_submarine_swap private helper, called by both prepare_bolt12_offer_payment and pay_bolt12_offer. Net -54 lines.
  2. CreateStringInvoiceSubmarineSwapRequest sends raw string -- acknowledged. The extract_bolt12_payment_hash call validates bech32 + HRP + payment hash TLV, which is the extent of what we can do without LDK. Full structural validation deferred to the LDK migration (tracked by the TODO).
  3. register_bolt12_offer / update_bolt12_webhook discard response -- checked the Boltz API docs; the registration endpoint returns no useful body on success (just 2xx). If this changes, we can add a response type.
  4. Breaking-change marker -- added // NOTE: Breaking change expected on LnInvoice::Bolt12(String).

@arkanaai
Copy link
Copy Markdown

arkanaai bot commented Mar 25, 2026

🔍 Iterative Review — Refactor + Breaking Change Marker (f457840)

New commit since last review: refactor: extract shared BOLT12 submarine swap setup into helper

What changed: Deduplicates the swap creation logic between prepare_bolt12_offer_payment and pay_bolt12_offer into a private create_bolt12_submarine_swap method. Also adds a breaking-change marker comment on LnInvoice::Bolt12(String).

Looks good:

  • Clean extraction — the shared method returns (SubmarineSwapData, String) which gives callers everything they need
  • prepare_* now correctly discards the invoice string (uses _invoice_str), pay_* uses it for the result
  • The .clone() on data before insert_submarine is necessary since data is used after the insert — correct
  • Breaking-change comment on the Bolt12 variant is a good signpost for future LDK migration

No issues found. Pure mechanical refactor, behavior-preserving. 👍

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (3)
ark-client/src/boltz.rs (3)

3086-3128: ⚠️ Potential issue | 🟠 Major

Parse the full TLV stream before accepting this invoice.

LnInvoice::deserialize now relies on this helper as its BOLT12 validation boundary. Returning on the first type 168 lets payloads with a valid-looking payment_hash and trailing truncated or garbage TLVs deserialize successfully.

Suggested fix
-    let mut cursor = 0;
+    let mut cursor = 0;
+    let mut payment_hash = None;
     while cursor < data.len() {
         let (tlv_type, consumed) = read_bigsize(&data[cursor..]).map_err(|e| {
             Error::ad_hoc(format!("failed to read TLV type in bolt12 invoice: {e}"))
         })?;
         cursor += consumed;
@@
         if tlv_type == BOLT12_PAYMENT_HASH_TLV_TYPE {
             if tlv_len != 32 {
                 return Err(Error::ad_hoc(format!(
                     "unexpected bolt12 payment_hash length: expected 32, got {tlv_len}"
                 )));
             }
             let hash_bytes: [u8; 32] = data[cursor..end]
                 .try_into()
                 .map_err(|_| Error::ad_hoc("failed to convert payment_hash bytes"))?;
-            return Ok(sha256::Hash::from_byte_array(hash_bytes));
+            if payment_hash
+                .replace(sha256::Hash::from_byte_array(hash_bytes))
+                .is_some()
+            {
+                return Err(Error::ad_hoc("duplicate payment_hash TLV in bolt12 invoice"));
+            }
         }
 
         cursor = end;
     }
 
-    Err(Error::ad_hoc(
-        "payment_hash (TLV type 168) not found in bolt12 invoice",
-    ))
+    payment_hash.ok_or_else(|| {
+        Error::ad_hoc("payment_hash (TLV type 168) not found in bolt12 invoice")
+    })
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ark-client/src/boltz.rs` around lines 3086 - 3128, The code currently returns
immediately when encountering BOLT12_PAYMENT_HASH_TLV_TYPE inside this TLV
parsing loop, which allows invoices with a valid-looking payment_hash followed
by truncated or garbage TLVs to pass; change the logic in this parsing block
(used by LnInvoice::deserialize) to parse the entire TLV stream before accepting
the invoice: instead of returning on finding type 168, record the 32-byte
payment_hash (e.g. set a local Option or store bytes) and continue parsing until
cursor reaches the end (ensuring all TLV lengths/consumed checks via
read_bigsize and boundary checks still apply); after the loop, if the
payment_hash was found and the whole stream parsed successfully, construct and
return the sha256::Hash, otherwise return the existing "payment_hash ... not
found" error.

1982-1989: ⚠️ Potential issue | 🟠 Major

Validate the fetched invoice before returning it.

This public helper still treats any invoice string from bolt12_fetch as success. create_bolt12_submarine_swap re-checks it later, but direct callers cannot access the private validator and can persist garbage that only fails much later.

Suggested fix
         let response: Bolt12FetchInvoiceResponse = response
             .json()
             .await
             .map_err(|e| Error::ad_hoc(e.to_string()))
             .context("failed to deserialize bolt12 fetch response")?;
+        extract_bolt12_payment_hash(&response.invoice)
+            .context("bolt12_fetch returned invalid BOLT12 invoice")?;
 
         tracing::info!("Fetched BOLT12 invoice from offer");
         Ok(response.invoice)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ark-client/src/boltz.rs` around lines 1982 - 1989, The code returns
response.invoice unchecked; ensure the fetched invoice string is validated
before returning by reusing or extracting the same validation logic used in
create_bolt12_submarine_swap: call the existing private validator (or extract it
as a public/accessible helper, e.g., validate_bolt12_invoice) to parse/verify
the Bolt12 string from Bolt12FetchInvoiceResponse and return an error if
parsing/validation fails (non-empty, correct format, required fields present)
instead of returning raw response.invoice.

3134-3164: ⚠️ Potential issue | 🟡 Minor

Reject non-canonical BigSize encodings here.

Inputs like 0xfd 00 a8 are malformed BigSize values, but read_bigsize currently accepts them. Since this parser now backs deserialization-time validation, those malformed TLVs are still treated as valid invoices.

Suggested fix
         0xfd => {
             if data.len() < 3 {
                 return Err("unexpected end of data for 2-byte BigSize");
             }
             let val = u16::from_be_bytes([data[1], data[2]]) as u64;
+            if val < 0xfd {
+                return Err("non-canonical 2-byte BigSize");
+            }
             Ok((val, 3))
         }
         0xfe => {
             if data.len() < 5 {
                 return Err("unexpected end of data for 4-byte BigSize");
             }
             let val = u32::from_be_bytes([data[1], data[2], data[3], data[4]]) as u64;
+            if val < 0x1_0000 {
+                return Err("non-canonical 4-byte BigSize");
+            }
             Ok((val, 5))
         }
         0xff => {
             if data.len() < 9 {
                 return Err("unexpected end of data for 8-byte BigSize");
             }
             let val = u64::from_be_bytes([
                 data[1], data[2], data[3], data[4], data[5], data[6], data[7], data[8],
             ]);
+            if val < 0x1_0000_0000 {
+                return Err("non-canonical 8-byte BigSize");
+            }
             Ok((val, 9))
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ark-client/src/boltz.rs` around lines 3134 - 3164, read_bigsize currently
accepts non-canonical encodings (e.g., 0xfd 00 a8); update read_bigsize to
reject these by validating canonical ranges: in the 0xfd arm return Err when val
< 0xfd, in the 0xfe arm return Err when val < 0x10000, and in the 0xff arm
return Err when val < 0x1_0000_0000; keep the existing length checks and return
types but replace acceptance of too-small values with a clear Err (e.g.,
"non-canonical BigSize encoding") so malformed TLVs are rejected during
deserialization.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@ark-client/src/boltz.rs`:
- Around line 3086-3128: The code currently returns immediately when
encountering BOLT12_PAYMENT_HASH_TLV_TYPE inside this TLV parsing loop, which
allows invoices with a valid-looking payment_hash followed by truncated or
garbage TLVs to pass; change the logic in this parsing block (used by
LnInvoice::deserialize) to parse the entire TLV stream before accepting the
invoice: instead of returning on finding type 168, record the 32-byte
payment_hash (e.g. set a local Option or store bytes) and continue parsing until
cursor reaches the end (ensuring all TLV lengths/consumed checks via
read_bigsize and boundary checks still apply); after the loop, if the
payment_hash was found and the whole stream parsed successfully, construct and
return the sha256::Hash, otherwise return the existing "payment_hash ... not
found" error.
- Around line 1982-1989: The code returns response.invoice unchecked; ensure the
fetched invoice string is validated before returning by reusing or extracting
the same validation logic used in create_bolt12_submarine_swap: call the
existing private validator (or extract it as a public/accessible helper, e.g.,
validate_bolt12_invoice) to parse/verify the Bolt12 string from
Bolt12FetchInvoiceResponse and return an error if parsing/validation fails
(non-empty, correct format, required fields present) instead of returning raw
response.invoice.
- Around line 3134-3164: read_bigsize currently accepts non-canonical encodings
(e.g., 0xfd 00 a8); update read_bigsize to reject these by validating canonical
ranges: in the 0xfd arm return Err when val < 0xfd, in the 0xfe arm return Err
when val < 0x10000, and in the 0xff arm return Err when val < 0x1_0000_0000;
keep the existing length checks and return types but replace acceptance of
too-small values with a clear Err (e.g., "non-canonical BigSize encoding") so
malformed TLVs are rejected during deserialization.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b9a607d0-4c2d-42aa-9ce2-eb64970c6fcf

📥 Commits

Reviewing files that changed from the base of the PR and between 05f0a11 and f457840.

📒 Files selected for processing (1)
  • ark-client/src/boltz.rs

Copy link
Copy Markdown
Collaborator Author

@vincenzopalazzo vincenzopalazzo left a comment

Choose a reason for hiding this comment

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

Blocking issues found.

  • pay_bolt12_offer still funds a server-fetched invoice without proving that it belongs to the original offer. Boltz's own BOLT12 docs require verifying that binding before using the invoice.
  • fetch_bolt12_invoice returns arbitrary server output without even local BOLT12 validation.
  • extract_bolt12_payment_hash stops at the first payment_hash TLV, so malformed trailing TLVs still pass LnInvoice deserialization.

cargo test -p ark-client passes locally on the PR head.

@arkanaai
Copy link
Copy Markdown

arkanaai bot commented Mar 25, 2026

🔍 Arkana Review — rust-sdk#197 (updated)

Add BOLT12 swap support via Boltz API by @vincenzopalazzo

Summary

Introduces BOLT12 invoice and offer support for submarine swaps alongside existing BOLT11 in the Rust SDK. This is a significant feature addition (~780 lines) touching the core swap data model.

Key Changes

  1. LnInvoice enum — New Bolt11/Bolt12 enum replacing Bolt11Invoice on SubmarineSwapData.invoice. Uses serde(untagged) for backward-compatible deserialization (BOLT11 tried first, then BOLT12 validation).

  2. BOLT12 TLV parserextract_bolt12_payment_hash() manually parses bech32-decoded TLV stream to extract payment hash (type 168). Includes read_bigsize() implementing the Lightning BigSize spec with non-canonical encoding rejection.

  3. New public API methods:

    • fetch_bolt12_invoice() — Resolves offer → invoice via Boltz /lightning/BTC/bolt12_fetch
    • prepare_bolt12_offer_payment() / pay_bolt12_offer() — Submarine swap flow for BOLT12
    • register_bolt12_offer() / update_bolt12_webhook() — Reverse swap offer registration
  4. HTTP client hardening — All new reqwest::Client instances now use self.inner.timeout instead of default (no timeout). Existing BOLT11 swap creation paths also updated.

Security Analysis

Payment hash validation at deserializationLnInvoice::Bolt12 variant validates the invoice has an extractable payment hash during deserialization via extract_bolt12_payment_hash(). Corrupted/invalid invoices fail fast.

TLV parser safety — Bounds checking on all reads, non-canonical BigSize rejection, overflow protection via checked_add. The parser is minimal and auditable.

⚠️ BOLT12 invoice signing key NOT verified — Clearly documented as a caller responsibility. The fetch_bolt12_invoice doc comment and pay_bolt12_offer both note the trust model matches BOLT11 swaps (Boltz provides the invoice). The TODO comment about replacing Bolt12(String) with LDK's Bolt12Invoice for proper verification is well-placed.

⚠️ serde(untagged) ordering — Deserialization tries BOLT11 first, which is correct since BOLT11 parsing is strict (checksum + signature verification). A valid BOLT12 invoice will never accidentally parse as BOLT11. However, the reverse is also true — BOLT11 strings won't pass BOLT12 validation (wrong HRP). Good.

Backward compatibilitytest_ln_invoice_backward_compat_deserialization confirms existing stored SubmarineSwapData with bare BOLT11 strings deserializes correctly.

Timeout hardening — The reqwest::Client::builder().timeout() change is a welcome improvement that also fixes existing BOLT11 paths.

Test Coverage

  • BigSize parsing: canonical values, truncated input, non-canonical rejection ✅
  • BOLT12 payment hash: roundtrip, preceding TLVs, missing hash, wrong HRP, trailing truncated TLV, non-canonical BigSize ✅
  • LnInvoice serde: BOLT11 roundtrip, BOLT12 roundtrip, backward compat, garbage rejection ✅

Cross-Repo Impact

  • boltz-swap (TS): Fix submarine swap refunds failure boltz-swap#105 also improves VHTLC refund selection. Both SDKs are converging on more robust swap handling.
  • ts-sdk / wallet: Will need corresponding BOLT12 support when this lands. The SubmarineSwapData.invoice type change is a breaking change for any code deserializing swap data directly.

Suggestion

The Bolt12(String) variant stores the raw invoice string. Consider adding a payment_hash() method cache or storing the extracted hash alongside the string to avoid re-parsing on every access (currently payment_hash() re-decodes bech32 + re-parses TLV each call).

Overall: Well-structured addition with good documentation of trust boundaries and clear TODO for future LDK integration. The minimal TLV parser is a pragmatic choice that avoids pulling in heavy dependencies.

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.

1 participant