Skip to content

Prevent swap-out prepayment loss on opening tx failure - #452

Open
YusukeShimizu wants to merge 8 commits into
ElementsProject:masterfrom
YusukeShimizu:swap-out-opening-tx-precheck
Open

Prevent swap-out prepayment loss on opening tx failure#452
YusukeShimizu wants to merge 8 commits into
ElementsProject:masterfrom
YusukeShimizu:swap-out-opening-tx-precheck

Conversation

@YusukeShimizu

@YusukeShimizu YusukeShimizu commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #324

In a swap out, the taker pays the fee invoice (prepayment) before the maker constructs the opening transaction. If the maker's wallet cannot fund or sign the transaction at that point (locked wallet, insufficient funds to fund PSBT, unspendable coins), the swap is canceled after the prepayment was already paid, and the taker loses it.

Maker side: dry-run precheck before sending the fee invoice

When a swap-out request is received, the maker now funds and signs a throwaway opening transaction — without broadcasting it — before creating the fee invoice. If this fails, the swap is canceled before the taker pays anything. Implemented for all wallet backends:

  • LND: FundPsbt + FinalizePsbt; utxo leases are released afterwards
  • CLN: txprepare + txdiscard
  • Elements: fundrawtransaction + blind + sign (utxos are not locked)
  • LWK: build + sign the PSET, skip the broadcast

testmempoolaccept (the second proposal in the issue) was left out: LND and CLN expose no equivalent API, and the fund+sign dry run already catches the failure classes reported in the issue.

Taker side: flag peers after a prepayment loss

If a peer cancels after the fee invoice was paid but before the opening transaction was broadcast — the exact loss scenario of the issue — the peer is added to the suspicious peer list. Since outgoing swaps to suspicious peers are already refused, an automated setup loses at most one prepayment per misbehaving peer instead of one per attempt. removesuspiciouspeer undoes the entry.

A peerswap maker only checks its flat onchain balance before sending
the fee invoice for a swap out. A locked wallet or unspendable coins
only surface when the opening transaction is funded, after the taker
has already paid the prepayment (issue ElementsProject#324).

Add PrecheckTransaction to the liquid wallet interface. The Elements
implementation funds, blinds and signs a throwaway transaction
without broadcasting it; fundrawtransaction does not lock utxos, so
nothing needs to be released. The LWK implementation builds and signs
the PSET and skips the broadcast. The tx template construction and
the fund-and-finalize steps are extracted into helpers shared with
the broadcast path.
The swap layer needs a wallet-level dry run of the opening
transaction so a maker can detect wallet problems before the taker
pays the fee invoice (issue ElementsProject#324).

Add PrecheckOpeningTransaction to LiquidOnChain, which derives the
confidential opening address and delegates to the liquid wallet's
PrecheckTransaction. The address derivation is extracted into a
helper shared with CreateOpeningTransaction.
A maker running lnd only notices funding problems such as
insufficient spendable coins or a failing signer when the opening
transaction is created, after the taker has already paid the fee
invoice (issue ElementsProject#324).

Add PrecheckOpeningTransaction, which funds and finalizes the same
psbt as the real opening transaction but never publishes it. Utxos
leased by FundPsbt are released again afterwards; a failed release is
only logged since the lease expires on its own.
A maker running cln only notices funding problems such as
insufficient spendable coins when the opening transaction is
prepared, after the taker has already paid the fee invoice
(issue ElementsProject#324).

Add PrecheckOpeningTransaction, which runs txprepare with the same
opening address and amount as the real opening transaction and then
releases the input reservation with txdiscard. A failed discard is
only logged since the reservation expires on its own.
A swap-out maker sends the fee invoice after checking only its flat
onchain balance. When its wallet is locked or coin selection fails,
the opening transaction fails after the taker paid the prepayment,
and the taker loses that payment (issue ElementsProject#324).

Require PrecheckOpeningTransaction from the onchain wallet and run it
in CreateSwapOutFromRequestAction before the fee invoice is created.
The dry run funds and signs a throwaway opening transaction with a
placeholder payment hash, since the claim preimage does not exist
yet; on failure the swap is canceled before the taker pays anything.
When a swap-out peer cancels after the fee invoice was paid but
before the opening transaction is broadcast, the sender loses the
prepayment. Repeated swap attempts against such a peer, e.g. from an
automated job, lose the prepayment on every attempt (issue ElementsProject#324).

Wrap the swap-out sender's canceled state with an action that adds
the peer to the suspicious peer list iff the prepayment was paid, no
opening transaction was received, and the cancel came from the peer.
Outgoing swaps to suspicious peers are already refused, so at most
one prepayment is lost per peer; removesuspiciouspeer undoes the
entry.
The opening transaction precheck on the maker side only had unit
coverage with a stubbed wallet. A regression in the real wallet
integration (coin selection, input release) would go unnoticed by the
existing e2e suites, which always run with spendable maker funds.

Add cln-cln and lnd-lnd swap-out tests that drain the maker's coins
into an unconfirmed change output, so the flat balance check passes
while funding the opening transaction fails. They assert that both
sides cancel with the precheck reason and that the taker never paid
the fee invoice. The tests are wired into the misc_2 CI shard.
@YusukeShimizu
YusukeShimizu marked this pull request as ready for review July 4, 2026 20:39
The original Liquid failure in issue ElementsProject#324 involved an encrypted maker
wallet that remained visible to balance checks but could not sign the
opening transaction. Existing integration coverage only exercised
Bitcoin coin-selection failures.

Add a CLN/CLN Liquid scenario that encrypts and locks the maker wallet,
then verifies that the swap cancels before the taker pays the fee
invoice. Include the scenario in the misc_2 CI shard.
@silenzara

Copy link
Copy Markdown

I just tested this on LND v0.21.2-beta (latest version) and it seems to be working as expected. I used a setup where a node had two 0.02 BTC utxo's. One confirmed and the other unconfirmed.

On the master version of peerswap I tried to initiate a 0.04 swapout with this test node. This failed but I still paid the prepayment:

Node A:
> pscli-test --rpchost localhost:42030 swapout --sat_amt 4000000 --asset btc --channel_id x

2026/08/21 07:06:31 [INFO] [Swap:748492b948a3d98ab16c626dcf70cee7ad6abd5d8048f12f5acd58b67e959cce] Paid Feeinvoice of 420 sats
2026/08/21 07:06:31 [DEBUG] [Messenger] From: 0270a26e0772d0cde73d870cbadf632c5377f83ed02d979f866dca2e8c49ab8551 got msgtype: a45f for swap: 748492b948a3d98ab16c626dcf70cee7ad6abd5d8048f12f5acd58b67e959cce
2026/08/21 07:06:31 [DEBUG] [FSM] event:id: 748492b948a3d98ab16c626dcf70cee7ad6abd5d8048f12f5acd58b67e959cce, Event_OnCancelReceived on State_SwapOutSender_AwaitTxBroadcastedMessage
2026/08/21 07:06:31 [INFO] [Swap:748492b948a3d98ab16c626dcf70cee7ad6abd5d8048f12f5acd58b67e959cce] Swap canceled. Reason: rpc error: code = Unknown desc = wallet couldn't fund PSBT: error creating funding TX: insufficient funds available to construct transaction
2026/08/21 07:06:31 [INFO] [Swap:748492b948a3d98ab16c626dcf70cee7ad6abd5d8048f12f5acd58b67e959cce] Warning: Paid swap-out prepayment, but swap canceled before receiving opening transaction

Node B:
2026/08/21 07:06:28 [DEBUG] [PaymentWatcher] Swap: 748492b948a3d98ab16c626dcf70cee7ad6abd5d8048f12f5acd58b67e959cce: Add new payment watcher for payreq lnbc4200n1p4g0785pp5grwkleck64n4lxjerpuzn0x0c3lc940cj4r995kkeggcsvtfd0hqdyhwpjk2unnwashqgrzw33jqen9v5srjv358ymnx7pjxue8svfqxu6rsdpexf3rjdpcvyekgwfcv93rzdnrxcerverrvcmnqcm9v5mkzepkv93xgdty8qcrgwrxxyexvdtpvdjr2wrzxcmk2wf4893kxegcqzysxqzjcsp5ly5m0t2sjqrgt322l3h7xpan0926gevw4he7dj7w39xmnsf4j6qs9qxpqysgqtjrrr7y2vla64gc464fctl29hw9hcjcpjcpwj0gflsklwn847ffxvu8qzfzwe924jvjd3rvnw2cj4z6dtw9vk0zuyxtjtefntlu288sp6hhser of type "fee"
2026/08/21 07:06:29 [DEBUG] [PaymentWatcher] Swap: 748492b948a3d98ab16c626dcf70cee7ad6abd5d8048f12f5acd58b67e959cce: Calling paymentCallback
2026/08/21 07:06:29 [DEBUG] [FSM] event:id: 748492b948a3d98ab16c626dcf70cee7ad6abd5d8048f12f5acd58b67e959cce, Event_OnFeeInvoicePaid on State_SwapOutReceiver_AwaitFeeInvoicePayment
2026/08/21 07:06:29 [DEBUG] [grpc_conn]: grpc_retry attempt: 0, got err: rpc error: code = Unknown desc = wallet couldn't fund PSBT: error creating funding TX: insufficient funds available to construct transaction
2026/08/21 07:06:29 [INFO] [FSM] Action failure rpc error: code = Unknown desc = wallet couldn't fund PSBT: error creating funding TX: insufficient funds available to construct transaction
2026/08/21 07:06:29 [DEBUG] [FSM] event:id: 748492b948a3d98ab16c626dcf70cee7ad6abd5d8048f12f5acd58b67e959cce, Event_ActionFailed on State_SwapOutReceiver_BroadcastOpeningTx
2026/08/21 07:06:29 [DEBUG] [FSM] Canceling because of rpc error: code = Unknown desc = wallet couldn't fund PSBT: error creating funding TX: insufficient funds available to construct transaction
2026/08/21 07:06:29 [DEBUG] [FSM] event:id: 748492b948a3d98ab16c626dcf70cee7ad6abd5d8048f12f5acd58b67e959cce, Event_ActionSucceeded on State_SendCancel
2026/08/21 07:06:29 [INFO] [Swap:748492b948a3d98ab16c626dcf70cee7ad6abd5d8048f12f5acd58b67e959cce] Swap canceled. Reason: rpc error: code = Unknown desc = wallet couldn't fund PSBT: error creating funding TX: insufficient funds available to construct transaction

I then switched to the pr branch of peerswap on both nodes and tried the same thing:

Node A:
> pscli-test --rpchost localhost:42030 swapout --sat_amt 4000000 --asset btc --channel_id x

2026/08/21 07:52:20 [DEBUG] [FSM] event:id: e3a673ea6da5b4fcbe76478f262d30fd019ed1c475e99aa14eb4ec441467c5b2, Event_OnSwapOutStarted on 
2026/08/21 07:52:20 [INFO] [Swap:e3a673ea6da5b4fcbe76478f262d30fd019ed1c475e99aa14eb4ec441467c5b2] Start new swap-out: peer: 0270a26e0772d0cde73d870cbadf632c5377f83ed02d979f866dca2e8c49ab8551 chanId: 924973:272:1 initiator: 03a93b87bf9f052b8e862d51ebbac4ce5e97b5f4137563cd5128548d7f5978dda9 amount 4000000
2026/08/21 07:52:20 [DEBUG] [FSM] event:id: e3a673ea6da5b4fcbe76478f262d30fd019ed1c475e99aa14eb4ec441467c5b2, Event_ActionSucceeded on State_SwapOutSender_CreateSwap
2026/08/21 07:52:20 [DEBUG] [FSM] event:id: e3a673ea6da5b4fcbe76478f262d30fd019ed1c475e99aa14eb4ec441467c5b2, Event_ActionSucceeded on State_SwapOutSender_SendRequest
2026/08/21 07:52:20 [DEBUG] [Messenger] From: 0270a26e0772d0cde73d870cbadf632c5377f83ed02d979f866dca2e8c49ab8551 got msgtype: a45f for swap: e3a673ea6da5b4fcbe76478f262d30fd019ed1c475e99aa14eb4ec441467c5b2
2026/08/21 07:52:20 unknown message type: 42079
2026/08/21 07:52:20 [DEBUG] [FSM] event:id: e3a673ea6da5b4fcbe76478f262d30fd019ed1c475e99aa14eb4ec441467c5b2, Event_OnCancelReceived on State_SwapOutSender_AwaitAgreement
2026/08/21 07:52:20 [INFO] [Swap:e3a673ea6da5b4fcbe76478f262d30fd019ed1c475e99aa14eb4ec441467c5b2] Swap canceled. Reason: opening transaction precheck failed: rpc error: code = Unknown desc = wallet couldn't fund PSBT: error creating funding TX: insufficient funds available to construct transaction

Node B:
2026/08/21 07:52:20 [DEBUG] [Messenger] From: 03a93b87bf9f052b8e862d51ebbac4ce5e97b5f4137563cd5128548d7f5978dda9 got msgtype: a457 for swap: e3a673ea6da5b4fcbe76478f262d30fd019ed1c475e99aa14eb4ec441467c5b2
2026/08/21 07:52:20 unknown message type: 42071
2026/08/21 07:52:20 [DEBUG] [FSM] event:id: e3a673ea6da5b4fcbe76478f262d30fd019ed1c475e99aa14eb4ec441467c5b2, Event_OnSwapOutRequestReceived on 
2026/08/21 07:52:20 [DEBUG] Using a fee rate of 1.20 sat/vb for a total fee of 420
2026/08/21 07:52:20 [DEBUG] [grpc_conn]: grpc_retry attempt: 0, got err: rpc error: code = Unknown desc = wallet couldn't fund PSBT: error creating funding TX: insufficient funds available to construct transaction
2026/08/21 07:52:20 [INFO] [FSM] Action failure opening transaction precheck failed: rpc error: code = Unknown desc = wallet couldn't fund PSBT: error creating funding TX: insufficient funds available to construct transaction
2026/08/21 07:52:20 [DEBUG] [FSM] event:id: e3a673ea6da5b4fcbe76478f262d30fd019ed1c475e99aa14eb4ec441467c5b2, Event_ActionFailed on State_SwapOutReceiver_CreateSwap
2026/08/21 07:52:20 [DEBUG] [FSM] Canceling because of opening transaction precheck failed: rpc error: code = Unknown desc = wallet couldn't fund PSBT: error creating funding TX: insufficient funds available to construct transaction
2026/08/21 07:52:20 [DEBUG] [FSM] event:id: e3a673ea6da5b4fcbe76478f262d30fd019ed1c475e99aa14eb4ec441467c5b2, Event_ActionSucceeded on State_SendCancel
2026/08/21 07:52:20 [INFO] [Swap:e3a673ea6da5b4fcbe76478f262d30fd019ed1c475e99aa14eb4ec441467c5b2] Swap canceled. Reason: opening transaction precheck failed: rpc error: code = Unknown desc = wallet couldn't fund PSBT: error creating funding TX: insufficient funds available to construct transaction

No prepayment was sent this time and the swap request failed cleanly.

I then tried another scenario when I manually locked the confirmed utxo with lncli wallet leaseoutput which blocks the utxo from being used. I then tried to initiate a swapout with amount 0.02 BTC and this one also failed cleanly.

So to summarize, I tested to two most common scenarios and in both cases the result was as expected. Nice 👍

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.

fees get paid even opening tx for swap fails

2 participants