diff --git a/client.go b/client.go index bec66a2..5e739c0 100644 --- a/client.go +++ b/client.go @@ -9,6 +9,8 @@ import ( "sync/atomic" "time" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/lightninglabs/aperture/lsat" "github.com/lightninglabs/lndclient" @@ -158,6 +160,18 @@ func NewClient(dbDir string, cfg *ClientConfig) (*Client, func(), error) { totalPaymentTimeout: cfg.TotalPaymentTimeout, maxPaymentRetries: cfg.MaxPaymentRetries, cancelSwap: swapServerClient.CancelLoopOutSwap, + verifySchnorrSig: func(pubKey *btcec.PublicKey, hash, sig []byte) error { + schnorrSig, err := schnorr.ParseSignature(sig) + if err != nil { + return err + } + + if !schnorrSig.Verify(hash, pubKey) { + return fmt.Errorf("invalid signature") + } + + return nil + }, }) client := &Client{ diff --git a/executor.go b/executor.go index 2ac94e7..d0ce0af 100644 --- a/executor.go +++ b/executor.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/sweep" @@ -31,6 +32,8 @@ type executorConfig struct { maxPaymentRetries int cancelSwap func(ctx context.Context, details *outCancelDetails) error + + verifySchnorrSig func(pubKey *btcec.PublicKey, hash, sig []byte) error } // executor is responsible for executing swaps. @@ -153,6 +156,7 @@ func (s *executor) run(mainCtx context.Context, totalPaymentTimout: s.executorConfig.totalPaymentTimeout, maxPaymentRetries: s.executorConfig.maxPaymentRetries, cancelSwap: s.executorConfig.cancelSwap, + verifySchnorrSig: s.executorConfig.verifySchnorrSig, }, height) if err != nil && err != context.Canceled { log.Errorf("Execute error: %v", err) diff --git a/loopout.go b/loopout.go index 6fff4bd..0e58f38 100644 --- a/loopout.go +++ b/loopout.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -96,6 +96,7 @@ type executeConfig struct { totalPaymentTimout time.Duration maxPaymentRetries int cancelSwap func(context.Context, *outCancelDetails) error + verifySchnorrSig func(pubKey *btcec.PublicKey, hash, sig []byte) error } // loopOutInitResult contains information about a just-initiated loop out swap. @@ -1437,15 +1438,13 @@ func (s *loopOutSwap) createMuSig2SweepTxn( // To be sure that we're good, parse and validate that the combined // signature is indeed valid for the sig hash and the internal pubkey. - sig, err := schnorr.ParseSignature(finalSig) + err = s.executeConfig.verifySchnorrSig( + htlc.TaprootKey, sigHash, finalSig, + ) if err != nil { return nil, err } - if !sig.Verify(sigHash, htlc.TaprootKey) { - return nil, fmt.Errorf("invalid combined signature") - } - // Now that we know the signature is correct, we can fill it in to our // witness. sweepTx.TxIn[0].Witness = wire.TxWitness{ diff --git a/loopout_test.go b/loopout_test.go index 5af0ee9..ea41d10 100644 --- a/loopout_test.go +++ b/loopout_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" @@ -87,12 +88,13 @@ func testLoopOutPaymentParameters(t *testing.T) { go func() { err := swap.execute(swapCtx, &executeConfig{ - statusChan: statusChan, - sweeper: sweeper, - blockEpochChan: blockEpochChan, - timerFactory: timerFactory, - loopOutMaxParts: maxParts, - cancelSwap: server.CancelLoopOutSwap, + statusChan: statusChan, + sweeper: sweeper, + blockEpochChan: blockEpochChan, + timerFactory: timerFactory, + loopOutMaxParts: maxParts, + cancelSwap: server.CancelLoopOutSwap, + verifySchnorrSig: mockVerifySchnorrSigFail, }, height) if err != nil { log.Error(err) @@ -209,11 +211,12 @@ func testLateHtlcPublish(t *testing.T) { errChan := make(chan error) go func() { err := swap.execute(context.Background(), &executeConfig{ - statusChan: statusChan, - sweeper: sweeper, - blockEpochChan: blockEpochChan, - timerFactory: timerFactory, - cancelSwap: server.CancelLoopOutSwap, + statusChan: statusChan, + sweeper: sweeper, + blockEpochChan: blockEpochChan, + timerFactory: timerFactory, + cancelSwap: server.CancelLoopOutSwap, + verifySchnorrSig: mockVerifySchnorrSigFail, }, height) if err != nil { log.Error(err) @@ -320,11 +323,12 @@ func testCustomSweepConfTarget(t *testing.T) { errChan := make(chan error) go func() { err := swap.execute(context.Background(), &executeConfig{ - statusChan: statusChan, - blockEpochChan: blockEpochChan, - timerFactory: timerFactory, - sweeper: sweeper, - cancelSwap: server.CancelLoopOutSwap, + statusChan: statusChan, + blockEpochChan: blockEpochChan, + timerFactory: timerFactory, + sweeper: sweeper, + cancelSwap: server.CancelLoopOutSwap, + verifySchnorrSig: mockVerifySchnorrSigFail, }, ctx.Lnd.Height) if err != nil { log.Error(err) @@ -550,11 +554,12 @@ func testPreimagePush(t *testing.T) { errChan := make(chan error) go func() { err := swap.execute(context.Background(), &executeConfig{ - statusChan: statusChan, - blockEpochChan: blockEpochChan, - timerFactory: timerFactory, - sweeper: sweeper, - cancelSwap: server.CancelLoopOutSwap, + statusChan: statusChan, + blockEpochChan: blockEpochChan, + timerFactory: timerFactory, + sweeper: sweeper, + cancelSwap: server.CancelLoopOutSwap, + verifySchnorrSig: mockVerifySchnorrSigFail, }, ctx.Lnd.Height) if err != nil { log.Error(err) @@ -783,10 +788,11 @@ func testExpiryBeforeReveal(t *testing.T) { errChan := make(chan error) go func() { err := swap.execute(context.Background(), &executeConfig{ - statusChan: statusChan, - blockEpochChan: blockEpochChan, - timerFactory: timerFactory, - sweeper: sweeper, + statusChan: statusChan, + blockEpochChan: blockEpochChan, + timerFactory: timerFactory, + sweeper: sweeper, + verifySchnorrSig: mockVerifySchnorrSigFail, }, ctx.Lnd.Height) if err != nil { log.Error(err) @@ -909,11 +915,12 @@ func testFailedOffChainCancelation(t *testing.T) { errChan := make(chan error) go func() { cfg := &executeConfig{ - statusChan: statusChan, - sweeper: sweeper, - blockEpochChan: blockEpochChan, - timerFactory: timerFactory, - cancelSwap: server.CancelLoopOutSwap, + statusChan: statusChan, + sweeper: sweeper, + blockEpochChan: blockEpochChan, + timerFactory: timerFactory, + cancelSwap: server.CancelLoopOutSwap, + verifySchnorrSig: mockVerifySchnorrSigFail, } err := swap.execute(context.Background(), cfg, ctx.Lnd.Height) @@ -1011,3 +1018,174 @@ func testFailedOffChainCancelation(t *testing.T) { require.Equal(t, state.State, loopdb.StateFailOffchainPayments) require.NoError(t, <-errChan) } + +// TestLoopOutMuSig2Sweep tests the loop out sweep flow when the MuSig2 signing +// process is successful. +func TestLoopOutMuSig2Sweep(t *testing.T) { + defer test.Guard(t)() + + // TODO(bhandras): remove when MuSig2 is default. + loopdb.EnableExperimentalProtocol() + defer loopdb.ResetCurrentProtocolVersion() + + lnd := test.NewMockLnd() + ctx := test.NewContext(t, lnd) + server := newServerMock(lnd) + + testReq := *testRequest + testReq.SweepConfTarget = 10 + testReq.Expiry = ctx.Lnd.Height + testLoopOutMinOnChainCltvDelta + + // We set our mock fee estimate for our target sweep confs to be our + // max miner fee * 2. With MuSig2 we still expect that the client will + // publish the sweep but with the fee clamped to the maximum allowed + // miner fee as the preimage is revealed before the sweep txn is + // published. + ctx.Lnd.SetFeeEstimate( + testReq.SweepConfTarget, chainfee.SatPerKWeight( + testReq.MaxMinerFee*2, + ), + ) + + cfg := newSwapConfig( + &lnd.LndServices, newStoreMock(t), server, + ) + + initResult, err := newLoopOutSwap( + context.Background(), cfg, ctx.Lnd.Height, &testReq, + ) + require.NoError(t, err) + swap := initResult.swap + + // Set up the required dependencies to execute the swap. + sweeper := &sweep.Sweeper{Lnd: &lnd.LndServices} + blockEpochChan := make(chan interface{}) + statusChan := make(chan SwapInfo) + expiryChan := make(chan time.Time) + timerFactory := func(_ time.Duration) <-chan time.Time { + return expiryChan + } + + errChan := make(chan error) + + // Mock a successful signature verify to make sure we don't fail + // creating the MuSig2 sweep. + mockVerifySchnorrSigSuccess := func(pubKey *btcec.PublicKey, hash, + sig []byte) error { + + return nil + } + + go func() { + err := swap.execute(context.Background(), &executeConfig{ + statusChan: statusChan, + blockEpochChan: blockEpochChan, + timerFactory: timerFactory, + sweeper: sweeper, + cancelSwap: server.CancelLoopOutSwap, + verifySchnorrSig: mockVerifySchnorrSigSuccess, + }, ctx.Lnd.Height) + if err != nil { + log.Error(err) + } + errChan <- err + }() + + // The swap should be found in its initial state. + cfg.store.(*storeMock).assertLoopOutStored() + state := <-statusChan + require.Equal(t, loopdb.StateInitiated, state.State) + + // We'll then pay both the swap and prepay invoice, which should trigger + // the server to publish the on-chain HTLC. + signalSwapPaymentResult := ctx.AssertPaid(swapInvoiceDesc) + signalPrepaymentResult := ctx.AssertPaid(prepayInvoiceDesc) + + signalSwapPaymentResult(nil) + signalPrepaymentResult(nil) + + // Notify the confirmation notification for the HTLC. + ctx.AssertRegisterConf(false, defaultConfirmations) + + blockEpochChan <- ctx.Lnd.Height + 1 + + htlcTx := wire.NewMsgTx(2) + htlcTx.AddTxOut(&wire.TxOut{ + Value: int64(swap.AmountRequested), + PkScript: swap.htlc.PkScript, + }) + + ctx.NotifyConf(htlcTx) + + // The client should then register for a spend of the HTLC and attempt + // to sweep it using the custom confirmation target. + ctx.AssertRegisterSpendNtfn(swap.htlc.PkScript) + + // Assert that we made a query to track our payment, as required for + // preimage push tracking. + trackPayment := ctx.AssertTrackPayment() + + // Tick the expiry channel, we are still using our client confirmation + // target at this stage which has fees higher than our max acceptable + // fee. We do not expect a sweep attempt at this point. Since our + // preimage is not revealed, we also do not expect a preimage push. + expiryChan <- testTime + + // When using taproot htlcs the flow is different as we do reveal the + // preimage before sweeping in order for the server to trust us with + // our MuSig2 signing attempts. + cfg.store.(*storeMock).assertLoopOutState( + loopdb.StatePreimageRevealed, + ) + status := <-statusChan + require.Equal( + t, status.State, loopdb.StatePreimageRevealed, + ) + + preimage := <-server.preimagePush + require.Equal(t, swap.Preimage, preimage) + + // We expect the sweep tx to have been published. + ctx.ReceiveTx() + + // Since we don't have a reliable mechanism to non-intrusively avoid + // races by setting the fee estimate too soon, let's sleep here a bit + // to ensure the first sweep fails. + time.Sleep(500 * time.Millisecond) + + // Now we decrease our fees for the swap's confirmation target to less + // than the maximum miner fee. + ctx.Lnd.SetFeeEstimate(testReq.SweepConfTarget, chainfee.SatPerKWeight( + testReq.MaxMinerFee/2, + )) + + // Now when we report a new block and tick our expiry fee timer, and + // fees are acceptably low so we expect our sweep to be published. + blockEpochChan <- ctx.Lnd.Height + 2 + expiryChan <- testTime + + preimage = <-server.preimagePush + require.Equal(t, swap.Preimage, preimage) + + // We expect the sweep tx to have been published. + sweepTx := ctx.ReceiveTx() + + // This time, we send a payment succeeded update into our payment stream + // to reflect that the server received our preimage push and settled off + // chain. + trackPayment.Updates <- lndclient.PaymentStatus{ + State: lnrpc.Payment_SUCCEEDED, + } + + // Make sure our sweep tx has a single witness indicating keyspend. + require.Len(t, sweepTx.TxIn[0].Witness, 1) + + // Finally, we put this swap out of its misery and notify a successful + // spend our our sweepTx and assert that the swap succeeds. + ctx.NotifySpend(sweepTx, 0) + + cfg.store.(*storeMock).assertLoopOutState(loopdb.StateSuccess) + status = <-statusChan + require.Equal(t, status.State, loopdb.StateSuccess) + require.NoError(t, <-errChan) +} diff --git a/test/signer_mock.go b/test/signer_mock.go index 16a725d..e7e0261 100644 --- a/test/signer_mock.go +++ b/test/signer_mock.go @@ -116,7 +116,7 @@ func (s *mockSigner) MuSig2Sign(context.Context, [32]byte, [32]byte, func (s *mockSigner) MuSig2CombineSig(context.Context, [32]byte, [][]byte) (bool, []byte, error) { - return false, nil, nil + return true, nil, nil } // MuSig2Cleanup removes a session from memory to free up resources. diff --git a/testcontext_test.go b/testcontext_test.go index c846572..afc6a90 100644 --- a/testcontext_test.go +++ b/testcontext_test.go @@ -2,9 +2,11 @@ package loop import ( "context" + "fmt" "testing" "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" @@ -40,6 +42,15 @@ type testContext struct { stop func() } +// mockVerifySchnorrSigFail is used to simulate failed taproot keyspend +// signature verification. If passed to the executeConfig we'll test an +// uncooperative server and will fall back to scriptspend sweep. +func mockVerifySchnorrSigFail(pubKey *btcec.PublicKey, hash, + sig []byte) error { + + return fmt.Errorf("invalid sig") +} + func newSwapClient(config *clientConfig) *Client { sweeper := &sweep.Sweeper{ Lnd: config.LndServices, @@ -53,6 +64,7 @@ func newSwapClient(config *clientConfig) *Client { sweeper: sweeper, createExpiryTimer: config.CreateExpiryTimer, cancelSwap: config.Server.CancelLoopOutSwap, + verifySchnorrSig: mockVerifySchnorrSigFail, }) return &Client{