Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ test-matrix-liquid_lndlnd: test-bins
# Consolidated misc tests including CLN/LND-specific invariants and setup checks
.PHONY: test-matrix-misc
test-matrix-misc: test-bins
${INTEGRATION_TEST_ENV} go test ${INTEGRATION_TEST_OPTS} -run '^(Test_OnlyOneActiveSwapPerChannelCln|Test_OnlyOneActiveSwapPerChannelLnd|Test_GrpcReconnectStream|Test_GrpcRetryRequest|Test_RestoreFromPassedCSV|Test_Recover_PassedSwap_BTC|Test_Recover_PassedSwap_LBTC|Test_ClnConfig|Test_ClnPluginConfigFile|Test_ClnPluginConfigFile_DoesNotExist|Test_ClnPluginConfig_ElementsAuthCookie|Test_ClnPluginConfig_DisableLiquid|Test_CLNLiquidSetup|Test_ClnCln_ExcessiveAmount|Test_ClnCln_StuckChannels|Test_LndLnd_ExcessiveAmount|Test_Wumbo|Test_Cln_HtlcMaximum|Test_Cln_Premium|Test_Cln_shutdown|Test_ClnCln_Poll)$$' ./test
${INTEGRATION_TEST_ENV} go test ${INTEGRATION_TEST_OPTS} -run '^(Test_OnlyOneActiveSwapPerChannelCln|Test_OnlyOneActiveSwapPerChannelLnd|Test_GrpcReconnectStream|Test_GrpcRetryRequest|Test_RestoreFromPassedCSV|Test_Recover_PassedSwap_BTC|Test_Recover_PassedSwap_LBTC|Test_ClnConfig|Test_ClnPluginConfigFile|Test_ClnPluginConfigFile_DoesNotExist|Test_ClnPluginConfig_ElementsAuthCookie|Test_ClnPluginConfig_DisableLiquid|Test_CLNLiquidSetup|Test_ClnCln_ExcessiveAmount|Test_ClnCln_StuckChannels|Test_LndLnd_ExcessiveAmount|Test_Wumbo|Test_Cln_HtlcMaximum|Test_Cln_Premium|Test_Cln_shutdown|Test_ClnCln_Poll|Test_ClnCln_SwapOutPrecheck|Test_LndLnd_SwapOutPrecheck|Test_ClnCln_ElementsSwapOutPrecheckLockedWallet)$$' ./test

# Sharded misc tests to reduce single-job runtime in CI
.PHONY: test-matrix-misc_1
Expand All @@ -203,7 +203,7 @@ test-matrix-misc_1: test-bins

.PHONY: test-matrix-misc_2
test-matrix-misc_2: test-bins
${INTEGRATION_TEST_ENV} go test ${INTEGRATION_TEST_OPTS} -run '^(Test_GrpcReconnectStream|Test_GrpcRetryRequest|Test_RestoreFromPassedCSV|Test_Recover_PassedSwap_BTC|Test_Recover_PassedSwap_LBTC)$$' ./test
${INTEGRATION_TEST_ENV} go test ${INTEGRATION_TEST_OPTS} -run '^(Test_GrpcReconnectStream|Test_GrpcRetryRequest|Test_RestoreFromPassedCSV|Test_Recover_PassedSwap_BTC|Test_Recover_PassedSwap_LBTC|Test_ClnCln_SwapOutPrecheck|Test_LndLnd_SwapOutPrecheck|Test_ClnCln_ElementsSwapOutPrecheckLockedWallet)$$' ./test

.PHONY: test-matrix-misc_3
test-matrix-misc_3: test-bins
Expand Down
26 changes: 26 additions & 0 deletions clightning/clightning_wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/elementsproject/glightning/glightning"
"github.com/elementsproject/peerswap/lightning"
"github.com/elementsproject/peerswap/log"
"github.com/elementsproject/peerswap/onchain"
"github.com/elementsproject/peerswap/swap"
"github.com/elementsproject/peerswap/version"
Expand Down Expand Up @@ -60,6 +61,31 @@ func (cl *ClightningClient) CreateOpeningTransaction(swapParams *swap.OpeningPar
return sendRes.SignedTx, addr, sendRes.TxId, fee, vout, nil
}

// PrecheckOpeningTransaction prepares (but never broadcasts) a throwaway
// opening transaction to verify that the wallet can fund it right now. The
// input reservation taken by txprepare is released via txdiscard; if the
// discard fails the reservation expires on its own.
func (cl *ClightningClient) PrecheckOpeningTransaction(swapParams *swap.OpeningParams) error {
addr, err := cl.bitcoinChain.CreateOpeningAddress(swapParams, onchain.BitcoinCsv)
if err != nil {
return err
}
outputs := []*glightning.Outputs{
{
Address: addr,
Satoshi: swapParams.Amount,
},
}
prepRes, err := cl.glightning.PrepareTx(outputs, &glightning.FeeRate{Directive: glightning.Urgent}, nil)
if err != nil {
return err
}
if _, err := cl.glightning.DiscardTx(prepRes.TxId); err != nil {
log.Infof("precheck: txdiscard for %s failed: %v", prepRes.TxId, err)
}
return nil
}

func (cl *ClightningClient) CreatePreimageSpendingTransaction(swapParams *swap.OpeningParams, claimParams *swap.ClaimParams) (txId, txHex, address string, err error) {

_, vout, err := cl.bitcoinChain.GetVoutAndVerify(claimParams.OpeningTxHex, swapParams)
Expand Down
42 changes: 42 additions & 0 deletions lnd/lnd_wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/elementsproject/peerswap/lightning"
"github.com/elementsproject/peerswap/log"
"github.com/elementsproject/peerswap/onchain"
"github.com/elementsproject/peerswap/swap"
"github.com/lightningnetwork/lnd/lnrpc"
Expand Down Expand Up @@ -79,6 +80,47 @@ func (l *Client) CreateOpeningTransaction(swapParams *swap.OpeningParams) (rawTx
return rawTxHex, addr, openingTx.TxHash().String(), fee, vout, nil
}

// PrecheckOpeningTransaction funds and signs (but never broadcasts) a
// throwaway opening transaction to verify that the wallet can construct it
// right now. Utxos leased by FundPsbt are released before returning; if a
// release fails the lease expires on its own after the lnd default lock
// duration.
func (l *Client) PrecheckOpeningTransaction(swapParams *swap.OpeningParams) error {
addr, err := l.bitcoinOnChain.CreateOpeningAddress(swapParams, onchain.BitcoinCsv)
if err != nil {
return err
}

fundPsbtTemplate := &walletrpc.TxTemplate{
Outputs: map[string]uint64{
addr: swapParams.Amount,
},
}
fundRes, err := l.walletClient.FundPsbt(l.ctx, &walletrpc.FundPsbtRequest{
Template: &walletrpc.FundPsbtRequest_Raw{Raw: fundPsbtTemplate},
Fees: &walletrpc.FundPsbtRequest_TargetConf{TargetConf: 3},
})
if err != nil {
return err
}
defer func() {
for _, lease := range fundRes.LockedUtxos {
_, rerr := l.walletClient.ReleaseOutput(l.ctx, &walletrpc.ReleaseOutputRequest{
Id: lease.Id,
Outpoint: lease.Outpoint,
})
if rerr != nil {
log.Infof("precheck: could not release utxo lease: %v", rerr)
}
}
}()

_, err = l.walletClient.FinalizePsbt(l.ctx, &walletrpc.FinalizePsbtRequest{
FundedPsbt: fundRes.FundedPsbt,
})
return err
}

func (l *Client) CreatePreimageSpendingTransaction(swapParams *swap.OpeningParams, claimParams *swap.ClaimParams) (string, string, string, error) {
_, vout, err := l.bitcoinOnChain.GetVoutAndVerify(claimParams.OpeningTxHex, swapParams)
if err != nil {
Expand Down
30 changes: 30 additions & 0 deletions lwk/lwkwallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,36 @@ func (r *LWKRpcWallet) CreateAndBroadcastTransaction(swapParams *swap.OpeningPar
return broadcasted.Txid, hex, 0, nil
}

// PrecheckTransaction funds and signs (but never broadcasts) a throwaway
// version of the opening transaction to verify that the wallet can construct
// it right now.
func (r *LWKRpcWallet) PrecheckTransaction(swapParams *swap.OpeningParams, _ []byte) error {
ctx, cancel := context.WithTimeout(context.Background(), defaultContextTimeout)
defer cancel()
feerate := r.getFeeSatPerVByte(ctx).getValue() * kb
fundedTx, err := r.lwkClient.send(ctx, &sendRequest{
Addressees: []*unvalidatedAddressee{
{
Address: swapParams.OpeningAddress,
Satoshi: swapParams.Amount,
},
},
WalletName: r.c.GetWalletName(),
FeeRate: &feerate,
EnableCtDiscount: true,
})
if err != nil {
return fmt.Errorf("failed to fund transaction: %w", err)
}
if _, err := r.lwkClient.sign(ctx, &signRequest{
SignerName: r.c.GetSignerName(),
Pset: fundedTx.Pset,
}); err != nil {
return fmt.Errorf("failed to sign transaction: %w", err)
}
return nil
}

// GetBalance returns the balance in sats
func (r *LWKRpcWallet) GetBalance() (Satoshi, error) {
ctx, cancel := context.WithTimeout(context.Background(), defaultContextTimeout)
Expand Down
38 changes: 28 additions & 10 deletions onchain/liquid.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,7 @@ func (l *LiquidOnChain) GetOnchainBalance() (uint64, error) {
}

func (l *LiquidOnChain) CreateOpeningTransaction(swapParams *swap.OpeningParams) (txHex, address, txid string, fee uint64, vout uint32, err error) {
redeemScript, err := ParamsToTxScript(swapParams, LiquidCsv)
if err != nil {
return "", "", "", 0, 0, err
}
scriptPubKey := []byte{0x00, 0x20}
witnessProgram := sha256.Sum256(redeemScript)
scriptPubKey = append(scriptPubKey, witnessProgram[:]...)

redeemPayment, _ := payment.FromScript(scriptPubKey, l.network, swapParams.BlindingKey.PubKey())
blindedScriptAddr, err := redeemPayment.ConfidentialWitnessScriptHash()
blindedScriptAddr, err := l.deriveOpeningAddress(swapParams)
if err != nil {
return "", "", "", 0, 0, err
}
Expand All @@ -79,6 +70,33 @@ func (l *LiquidOnChain) CreateOpeningTransaction(swapParams *swap.OpeningParams)
return txHex, blindedScriptAddr, txId, fee, vout, nil
}

// PrecheckOpeningTransaction funds and signs (but never broadcasts) a
// throwaway opening transaction to verify that the wallet can construct it
// right now.
func (l *LiquidOnChain) PrecheckOpeningTransaction(swapParams *swap.OpeningParams) error {
blindedScriptAddr, err := l.deriveOpeningAddress(swapParams)
if err != nil {
return err
}
swapParams.OpeningAddress = blindedScriptAddr
return l.liquidWallet.PrecheckTransaction(swapParams, l.asset)
}

// deriveOpeningAddress computes the confidential p2wsh address of the
// opening output for the given swap params.
func (l *LiquidOnChain) deriveOpeningAddress(swapParams *swap.OpeningParams) (string, error) {
redeemScript, err := ParamsToTxScript(swapParams, LiquidCsv)
if err != nil {
return "", err
}
scriptPubKey := []byte{0x00, 0x20}
witnessProgram := sha256.Sum256(redeemScript)
scriptPubKey = append(scriptPubKey, witnessProgram[:]...)

redeemPayment, _ := payment.FromScript(scriptPubKey, l.network, swapParams.BlindingKey.PubKey())
return redeemPayment.ConfidentialWitnessScriptHash()
}

// feeAmountPlaceholder is a placeholder for the fee amount
const feeAmountPlaceholder = uint64(500)

Expand Down
48 changes: 48 additions & 0 deletions swap/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,34 @@ func (c *CreateSwapOutFromRequestAction) Execute(services *SwapServices, swap *S
return swap.HandleError(errors.New("insufficient walletbalance"))
}

// Dry-run the opening transaction (fund and sign, but never broadcast)
// so that wallet problems like a locked wallet or unspendable coins
// cancel the swap before the peer pays the fee invoice (issue #324).
// The payment hash is a placeholder as the claim preimage does not
// exist yet; it only shapes the never-broadcast output script.
var blindingKey *btcec.PrivateKey
if swap.GetChain() == l_btc_chain {
blindingKeyBytes, err := hex.DecodeString(swap.BlindingKeyHex)
if err != nil {
return swap.HandleError(err)
}
blindingKey, _ = btcec.PrivKeyFromBytes(blindingKeyBytes)
}
dummyPreimage, err := lightning.GetPreimage()
if err != nil {
return swap.HandleError(err)
}
err = wallet.PrecheckOpeningTransaction(&OpeningParams{
TakerPubkey: swap.GetTakerPubkey(),
MakerPubkey: hex.EncodeToString(swap.GetPrivkey().PubKey().SerializeCompressed()),
ClaimPaymentHash: dummyPreimage.Hash().String(),
Amount: swap.GetOpeningTXAmount(),
BlindingKey: blindingKey,
})
if err != nil {
return swap.HandleError(fmt.Errorf("opening transaction precheck failed: %v", err))
}

// Construct memo
memo := fmt.Sprintf("peerswap %s %s %s %s", swap.GetChain(), INVOICE_FEE, swap.GetScidInBoltFormat(), swap.GetId())

Expand Down Expand Up @@ -908,3 +936,23 @@ func (c *AddSuspiciousPeerAction) Execute(services *SwapServices, swap *SwapData
log.Infof("added peer %s to suspicious peer list", swap.PeerNodeId)
return c.next.Execute(services, swap)
}

// AddSuspiciousPeerOnPrepaymentLossAction adds the peer to the suspicious
// peer list iff the swap-out sender paid the prepayment (fee invoice) and
// the peer then canceled before broadcasting the opening transaction, i.e.
// the prepayment is lost (issue #324). All other cancel paths pass through
// untouched.
type AddSuspiciousPeerOnPrepaymentLossAction struct {
next Action
}

func (c *AddSuspiciousPeerOnPrepaymentLossAction) Execute(services *SwapServices, swap *SwapData) EventType {
if swap.FeePreimage != "" && swap.OpeningTxBroadcasted == nil && swap.Cancel != nil {
if err := services.policy.AddToSuspiciousPeerList(swap.PeerNodeId); err != nil {
log.Infof("error adding peer %s to suspicious peer list: %v", swap.PeerNodeId, err)
} else {
log.Infof("added peer %s to suspicious peer list: peer canceled swap-out after the prepayment was paid", swap.PeerNodeId)
}
}
return c.next.Execute(services, swap)
}
6 changes: 6 additions & 0 deletions swap/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ type Validator interface {
type Wallet interface {
SetLabel(txID, address, label string) error
CreateOpeningTransaction(swapParams *OpeningParams) (unpreparedTxHex, address, txid string, fee uint64, vout uint32, err error)
// PrecheckOpeningTransaction funds and signs (but never broadcasts) a
// throwaway opening transaction to verify that the wallet can construct
// it right now, e.g. that it is not locked and has enough spendable
// coins. Any inputs reserved during the check are released before
// returning.
PrecheckOpeningTransaction(swapParams *OpeningParams) error
CreatePreimageSpendingTransaction(swapParams *OpeningParams, claimParams *ClaimParams) (txId, txHex, address string, err error)
CreateCsvSpendingTransaction(swapParams *OpeningParams, claimParams *ClaimParams) (txId, txHex, address string, error error)
CreateCoopSpendingTransaction(swapParams *OpeningParams, claimParams *ClaimParams, takerSigner Signer) (txId, txHex, address string, error error)
Expand Down
44 changes: 44 additions & 0 deletions swap/swap_out_receiver_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package swap

import (
"errors"
"fmt"
"testing"

Expand Down Expand Up @@ -34,6 +35,12 @@ func Test_SwapOutReceiverValidSwap(t *testing.T) {
assert.NotEqual(t, "", swapFSM.Data.SwapOutRequest.Pubkey)
assert.NotEqual(t, "", swapFSM.Data.SwapOutAgreement.Pubkey)

// The opening transaction was prechecked before the fee invoice was sent.
bitcoinWallet := swapServices.bitcoinWallet.(*dummyChain)
assert.EqualValues(t, 1, bitcoinWallet.precheckOpeningTxCalled)
assert.Equal(t, swapAmount, bitcoinWallet.precheckOpeningTxParams.Amount)
assert.Equal(t, takerPubkeyHash, bitcoinWallet.precheckOpeningTxParams.TakerPubkey)

_, err = swapFSM.SendEvent(Event_OnFeeInvoicePaid, nil)
if err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -183,6 +190,43 @@ func Test_SwapOutReceiverInsufficientBalance(t *testing.T) {

}

// Test_SwapOutReceiver_PrecheckFailed checks that the swap is canceled before
// the fee invoice is sent if the opening transaction precheck fails, so that
// the peer never pays a prepayment for a swap that can not be performed.
func Test_SwapOutReceiver_PrecheckFailed(t *testing.T) {
swapAmount := uint64(100000)
swapId := NewSwapId()
_, peer, takerPubkeyHash, _, chanId := getTestParams()

msgChan := make(chan PeerMessage)

swapServices := getSwapServices(t, msgChan)
swapServices.bitcoinWallet.(*dummyChain).precheckOpeningTxErr =
errors.New("wallet couldn't fund PSBT: insufficient funds available to construct transaction")

swapFSM := newSwapOutReceiverFSM(swapId, swapServices, peer)

_, err := swapFSM.SendEvent(Event_OnSwapOutRequestReceived, &SwapOutRequestMessage{
Amount: swapAmount,
Scid: chanId,
SwapId: swapId,
Pubkey: takerPubkeyHash,
Network: "mainnet",
ProtocolVersion: PEERSWAP_PROTOCOL_VERSION,
})
if err != nil {
t.Fatal(err)
}

// No fee invoice was created; the only message sent to the peer is the
// cancel with the precheck failure as the reason.
assert.Nil(t, swapFSM.Data.SwapOutAgreement)
msg := <-msgChan
assert.Equal(t, messages.MESSAGETYPE_CANCELED, msg.MessageType())
assert.Equal(t, State_SwapCanceled, swapFSM.Data.GetCurrentState())
assert.Contains(t, swapFSM.Data.GetCancelMessage(), "opening transaction precheck failed")
}

// Test_SwapOutReceiver_PeerIsSuspicious checks that a swap request is rejected
// if the peer is on the suspicious peer list.
func Test_SwapOutReceiver_PeerIsSuspicious(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion swap/swap_out_sender.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ func getSwapOutSenderStates() States {
},
},
State_SwapCanceled: {
Action: &CancelAction{},
Action: &AddSuspiciousPeerOnPrepaymentLossAction{next: &CancelAction{}},
},
State_ClaimedPreimage: {
Action: &NoOpDoneAction{},
Expand Down
Loading
Loading