Skip to content

Add submarine swap refund (lightning_refund) - #134

Merged
andycreed0x merged 11 commits into
developfrom
feat/submarine-swap-refund
Sep 29, 2026
Merged

andycreed0x merged 11 commits into
developfrom
feat/submarine-swap-refund

Conversation

@andycreed0x

Copy link
Copy Markdown
Collaborator

Purpose

Failed Lightning sends leave L-BTC locked in a taproot swap output with no way back — this PR adds lightning_refund to reclaim it, cooperatively with the provider (Indra/Boltz) where possible and unilaterally otherwise. Verified against two real, previously-stuck mainnet swaps: both recovered their funds on chain during development.

Description

pay_invoice discarded claimPublicKey, blindingKey, the lockup address and swapTree from the provider's create response, so no swap could ever be refunded. This PR persists that data going forward and adds --claim-public-key/--blinding-key overrides so a swap created before this lands can still be refunded once the provider hands those values back.

The crypto (src/aqua/boltz_refund.py) reconstructs the lockup's taproot swap tree from its public parameters and spends it two ways: a MuSig2 key-path signature cosigned by the provider (immediate), or the refund leaf's script path once the timeout block passes (works with no provider involvement). libwally computes the Elements key-path sighash but hardcodes Bitcoin's tapleaf version inside its own script-path variant, so the script path uses a hand-rolled sighash instead — pinned against wally byte-for-byte in tests (fed the same leaf version wally hardcodes) and against the real on-chain scriptPubKey of both recovered swaps. MuSig2 comes from a vendored BIP-327 reference implementation (src/aqua/_bip327.py, BSD-3-Clause), validated against the official BIP-327 test vectors, since neither wallycore nor coincurve expose it.

Full write-up in docs/REFUND.md.

Main Changes

  • ✨ Added lightning_refund MCP tool / aqua lightning refund --swap-id <id> — recovers a failed send swap's locked L-BTC, cooperative-first with an automatic unilateral fallback after the timeout
  • ✨ Added src/aqua/boltz_refund.py — swap tree reconstruction, Elements taproot sighash (manual, cross-checked against wally), refund transaction construction/blinding, cooperative + unilateral signing flows
  • ➕ Vendored BIP-327 MuSig2 reference implementation (src/aqua/_bip327.py), validated against the official test vectors
  • ♻️ pay_invoice now persists claim_public_key, blinding_key, lockup_address, swap_tree from the provider response instead of discarding them; new refund_txid field records a completed refund
  • ✨ New local swap status refunded (mapped from transaction.refunded); lightning_transaction_status now reports refund_info.refundable/lockup_address/refund_txid
  • 🐛 A stale provider status can no longer overwrite a locally recorded refund (providers keep reporting the original failure long after the refund is on chain)
  • ✨ Added decode_bolt11_payment_hash — the claim leaf commits to the invoice's payment hash, previously never extracted
  • ✨ Added BoltzClient.post_refund_signature / get_chain_fees / broadcast_transaction; WalletManager.get_block_height / get_transaction_hex / broadcast_raw_tx — inherited by Indra automatically
  • 📝 Added docs/REFUND.md; cross-referenced from README.md, docs/CONFIG.md, and both AGENTS.md files (new invariant: the refund key is random, not seed-derived, so losing the swap record loses the funds)
  • ✅ Tests: BIP-327 official vectors, swap-tree/sighash/control-block correctness against real recovered swaps, refund flow guards (already-refunded, receive swap, unfunded, legacy overrides, network-failure propagation, spent-lockup detection), endpoint contracts

Checklist

  • No hardcoded values (they should go in constants.py, .env, or our database)
  • Added/updated tests (if necessary)
  • Added/updated relevant documentation (if necessary)

@andycreed0x
andycreed0x marked this pull request as ready for review September 21, 2026 16:37
Comment thread src/aqua/_bip327.py

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.

@coelhogonzalo I'd like your opinion on this. Claude decided to use the reference implementation of Musig2 since there aren't libs that includes it for Python (I checked with Wallycore and LWK).
it's tested and works, but I'd like to find a more ironed option.

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.

Hey, sorry for the delay reviewing this. Why not libsecp256k1? (coincurve 21.0.0, already in pyproject, exposes the full secp256k1_musig_* API via its cffi handle) I mean, I think the reference implementation is OK but it seems like libsecp256k1 is more mature and feels like the safe bet. Your call, might be too much effort for little gain.

Options, ranked:

  1. Bind libsecp256k1 MuSig through coincurve (recommended, . Zero new dependencies. Audited, constant-time. Keeps the vendored file only for test vectors, or drops it. Cost: roughly 150 to 250 lines of cffi glue and careful memory handling.
  2. Keep the reference implementation. Acceptable today because the refund key is ephemeral and per-swap, so a timing leak has limited value. This is a real argument, but "reference code in production" is a hard sell in review.

Base automatically changed from feat/indra-swap-provider to develop September 22, 2026 14:28
@andycreed0x
andycreed0x changed the base branch from develop to main September 22, 2026 14:30
@andycreed0x
andycreed0x changed the base branch from main to develop September 22, 2026 14:31
Needed for cooperative submarine swap refunds: neither wallycore nor
coincurve expose MuSig2. Trimmed to the algorithm code (dropped the
file-based test-vector scaffolding) and validated here against the
official BIP-327 vectors instead.
Reconstructs a Boltz v2 lockup's taproot swap tree from its public
parameters and spends it two ways: cooperatively via a MuSig2 key-path
signature (immediate, needs the provider's cosign), or unilaterally via
the refund leaf's script path once the timeout block passes.

libwally computes the Elements key-path sighash but hardcodes Bitcoin's
tapleaf version inside its own script-path variant, so the script path
gets a hand-rolled sighash instead. Both are pinned against real,
recovered mainnet swaps: the reconstructed scriptPubKey matches what the
lockup output actually carries on chain, and the manual sighash matches
wally's own key-path result byte for byte (and, fed the same leaf
version wally hardcodes, its script-path result too).

decode_bolt11_payment_hash is the other missing primitive: the claim
leaf commits to the invoice's payment hash, which nothing in the repo
extracted before.
lightning_refund (CLI: aqua lightning refund --swap-id <id>) recovers
the L-BTC a failed send swap locked up, cooperatively where the
provider allows it and unilaterally otherwise. Works with both
providers (Indra/Boltz).

pay_invoice now persists claimPublicKey, blindingKey, the lockup
address and swapTree from the provider's create response — previously
discarded, which meant no swap could ever be refunded. --claim-public-
key/--blinding-key let a swap created before this land still be
refunded, once the provider hands those back.

LightningManager.refund_send_swap validates the swap (send, not
already refunded, has a lockup to spend), refreshes its remote status
without swallowing network failures, and only refunds swaps the
provider actually reports as failed. A completed refund is recorded as
a new "refunded" status with its own txid; refund_info in
lightning_transaction_status now reports refundable/lockup_address/
refund_txid so an agent can tell a swap needs one without guessing.

A broadcast refund is final, so a stale provider status (many keep
reporting the original failure long after) can no longer overwrite a
locally recorded refund.

WalletManager gains the three chain primitives the refund needs and
nothing else touched: get_block_height, get_transaction_hex and
broadcast_raw_tx.
docs/REFUND.md covers the cooperative/unilateral paths, what a swap
record needs to be refundable, the legacy-swap override, and the
implementation notes worth knowing before touching boltz_refund.py.
Cross-referenced from README, docs/CONFIG.md and both AGENTS.md files;
the root AGENTS.md gets a new invariant about the refund key never
being seed-derived.
Multi-line comments and docstrings that exceeded the project's length
conventions are compressed to 1-3 line pointers; content that carried
durable reasoning (nonce-reuse risk, the dry-run address quirk) moved
into docs/REFUND.md first. No logic changes.
Matches the module it documents. Updated every pointer to it across
README, AGENTS.md, docs/CONFIG.md, and the code comments/docstrings in
boltz_refund.py, lightning.py, tools.py and _bip327.py.
"Lightning refund" reads as if any Lightning payment could be reversed;
this only ever refunds a failed submarine swap's on-chain lockup. Same
scope change in the doc's title. Updated every pointer across README,
AGENTS.md, docs/CONFIG.md, and the code comments/docstrings.
@rocket-morgan
rocket-morgan force-pushed the feat/submarine-swap-refund branch from 778e8ad to 09e9986 Compare September 22, 2026 15:21
The fee draft carried a single 64-byte dummy witness, but the unilateral
refund spends the script path with [sig, refund_leaf, control_block],
so its fee underpaid the requested rate. Also reword the cooperative
failure message to not blame the provider for local errors.

@TomasCast TomasCast 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.

Looks good, but check the comments before merging

Comment thread src/aqua/bolt11.py
Comment on lines +14 to 15
_TAG_PAYMENT_HASH = 1 # 'p'
_TAG_DESCRIPTION = 13 # 'd'

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.

Just for curiosity, what does the comments like "# 'p'" mean? is this intentional?

@andycreed0x andycreed0x Sep 28, 2026 •

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.

p (payment hash), d (description) , x (expiry)

Comment thread src/aqua/lightning.py
Comment on lines +565 to +577
claim_pubkey = claim_public_key or swap.claim_public_key
blinding = blinding_key or swap.blinding_key
if not claim_pubkey or not blinding:
missing = []
if not claim_pubkey:
missing.append("claim_public_key")
if not blinding:
missing.append("blinding_key")
raise ValueError(
f"Swap {swap_id} predates local storage of {' and '.join(missing)}. "
"Ask the provider for the swap's keys and pass them explicitly: "
f"aqua lightning refund --swap-id {swap_id} "
"--claim-public-key <hex> --blinding-key <hex>"

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.

blindingKey is optional, but at the same time it's needed for bulding the refund. If the provider returns an incomplete answer, the code sends the lockup anyways and leaves the funds impsossible to refund automatically. Right?

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.

Good catch, you're right. Fixed

Comment thread src/aqua/lightning.py Outdated
Comment on lines +559 to +563
if not swap.lockup_txid:
raise ValueError(
f"Swap {swap_id} was never funded (no lockup transaction); there is "
"nothing to refund."
)

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.

The swap record is saved before the lockup is sent, but lockup_txid is written only after broadcast. If the process dies between those two steps, the provider may already hold the lockup and return its transaction hex, yet refund still aborts before consulting that response and incorrectly claims the swap was never funded.

The provider status lookup should happen before requiring lockup_txid; that field is only needed as a network fallback.

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.

Agreed, fixed. refund_send_swap no longer requires lockup_txid up front

) != elements_taproot_sighash(*self._args(), GENESIS_BLOCK_HASH["testnet"])


class TestFindAndUnblindLockup:

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.

This was found by cursor agent, I guess it makes sense:
Missing positive coverage for the full confidential refund path. find_and_unblind_lockup only has a negative test. There are also no direct tests of refund_submarine_swap; the manager tests mock it out entirely.

CI would not catch regressions in a successful unblind / asset byte order, cooperative → unilateral fallback, spent-lockup vs wait-for-timeout, or fee-rate fallback and broadcast.

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

@coelhogonzalo coelhogonzalo 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.

Approved. I couldn't find anything worth raising. I verified my doubts talking with Fable.

Comment thread src/aqua/boltz.py

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.

Maybe worth renaming boltz into something generic at some point... Maybe not. In my humble opinion, I think we can keep the "boltz" reference in some places since they did stablish the API contract we are using... Just something to think about. I wouldn't change anything for now.

@andycreed0x
andycreed0x merged commit 379781a into develop Sep 29, 2026
1 check passed
@andycreed0x
andycreed0x deleted the feat/submarine-swap-refund branch September 29, 2026 01:22
andycreed0x added a commit that referenced this pull request Oct 1, 2026
…'url' argument (#138)

* 🐛 fix: lightning_refund chain helpers after the Airavata backend switch

get_block_height, get_transaction_hex and broadcast_raw_tx (added in #134)
still called _get_client(network), whose signature #131 changed to
(network, url), so every refund raised TypeError.

Route them through the backend fallback, reuse the idempotent _broadcast,
and read tx hex over HTTP for Esplora backends: lwk's EsploraClient has no
get_tx, and every default Liquid backend is Esplora.

Closes #137

* 🐛 fix: get_transaction_hex Electrum misses and non-hex 200 bodies

Electrum: lwk's ElectrumClient.get_tx never returns None; a missing tx
raises LwkError ("missing transaction", probed live on Blockstream's
Liquid Electrum). The old None check was dead, so a miss escaped as a
raw LwkError instead of the "Transaction ... not found" ValueError.
Recognize the miss and treat it like an Esplora 404. The test now raises
what lwk really raises instead of returning None.

Esplora: a 200 whose body is not tx hex (a proxy HTML page, a JSON
error, an empty or non-UTF-8 body) was returned as the lockup hex and
failed later inside wally.tx_from_hex, without trying the next backend.
Validate the body, record the backend as failed and fall back.
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.

4 participants