Skip to content

Commit 5d7b0ab

Browse files
committed
loopout: add unit test for the MuSig2 sweep case
1 parent 82b58e5 commit 5d7b0ab

File tree

6 files changed

+244
-37
lines changed

6 files changed

+244
-37
lines changed

client.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"sync/atomic"
1010
"time"
1111

12+
"github.com/btcsuite/btcd/btcec/v2"
13+
"github.com/btcsuite/btcd/btcec/v2/schnorr"
1214
"github.com/btcsuite/btcd/btcutil"
1315
"github.com/lightninglabs/aperture/lsat"
1416
"github.com/lightninglabs/lndclient"
@@ -158,6 +160,18 @@ func NewClient(dbDir string, cfg *ClientConfig) (*Client, func(), error) {
158160
totalPaymentTimeout: cfg.TotalPaymentTimeout,
159161
maxPaymentRetries: cfg.MaxPaymentRetries,
160162
cancelSwap: swapServerClient.CancelLoopOutSwap,
163+
verifySchnorrSig: func(pubKey *btcec.PublicKey, hash, sig []byte) error {
164+
schnorrSig, err := schnorr.ParseSignature(sig)
165+
if err != nil {
166+
return err
167+
}
168+
169+
if !schnorrSig.Verify(hash, pubKey) {
170+
return fmt.Errorf("invalid signature")
171+
}
172+
173+
return nil
174+
},
161175
})
162176

163177
client := &Client{

executor.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"sync/atomic"
99
"time"
1010

11+
"github.com/btcsuite/btcd/btcec/v2"
1112
"github.com/lightninglabs/lndclient"
1213
"github.com/lightninglabs/loop/loopdb"
1314
"github.com/lightninglabs/loop/sweep"
@@ -31,6 +32,8 @@ type executorConfig struct {
3132
maxPaymentRetries int
3233

3334
cancelSwap func(ctx context.Context, details *outCancelDetails) error
35+
36+
verifySchnorrSig func(pubKey *btcec.PublicKey, hash, sig []byte) error
3437
}
3538

3639
// executor is responsible for executing swaps.
@@ -153,6 +156,7 @@ func (s *executor) run(mainCtx context.Context,
153156
totalPaymentTimout: s.executorConfig.totalPaymentTimeout,
154157
maxPaymentRetries: s.executorConfig.maxPaymentRetries,
155158
cancelSwap: s.executorConfig.cancelSwap,
159+
verifySchnorrSig: s.executorConfig.verifySchnorrSig,
156160
}, height)
157161
if err != nil && err != context.Canceled {
158162
log.Errorf("Execute error: %v", err)

loopout.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
"sync"
1111
"time"
1212

13-
"github.com/btcsuite/btcd/btcec/v2/schnorr"
13+
"github.com/btcsuite/btcd/btcec/v2"
1414
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
1515
"github.com/btcsuite/btcd/btcutil"
1616
"github.com/btcsuite/btcd/chaincfg/chainhash"
@@ -96,6 +96,7 @@ type executeConfig struct {
9696
totalPaymentTimout time.Duration
9797
maxPaymentRetries int
9898
cancelSwap func(context.Context, *outCancelDetails) error
99+
verifySchnorrSig func(pubKey *btcec.PublicKey, hash, sig []byte) error
99100
}
100101

101102
// loopOutInitResult contains information about a just-initiated loop out swap.
@@ -1437,15 +1438,13 @@ func (s *loopOutSwap) createMuSig2SweepTxn(
14371438

14381439
// To be sure that we're good, parse and validate that the combined
14391440
// signature is indeed valid for the sig hash and the internal pubkey.
1440-
sig, err := schnorr.ParseSignature(finalSig)
1441+
err = s.executeConfig.verifySchnorrSig(
1442+
htlc.TaprootKey, sigHash, finalSig,
1443+
)
14411444
if err != nil {
14421445
return nil, err
14431446
}
14441447

1445-
if !sig.Verify(sigHash, htlc.TaprootKey) {
1446-
return nil, fmt.Errorf("invalid combined signature")
1447-
}
1448-
14491448
// Now that we know the signature is correct, we can fill it in to our
14501449
// witness.
14511450
sweepTx.TxIn[0].Witness = wire.TxWitness{

loopout_test.go

Lines changed: 208 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"time"
1010

1111
"github.com/btcsuite/btcd/blockchain"
12+
"github.com/btcsuite/btcd/btcec/v2"
1213
"github.com/btcsuite/btcd/btcutil"
1314
"github.com/btcsuite/btcd/wire"
1415
"github.com/lightninglabs/lndclient"
@@ -87,12 +88,13 @@ func testLoopOutPaymentParameters(t *testing.T) {
8788

8889
go func() {
8990
err := swap.execute(swapCtx, &executeConfig{
90-
statusChan: statusChan,
91-
sweeper: sweeper,
92-
blockEpochChan: blockEpochChan,
93-
timerFactory: timerFactory,
94-
loopOutMaxParts: maxParts,
95-
cancelSwap: server.CancelLoopOutSwap,
91+
statusChan: statusChan,
92+
sweeper: sweeper,
93+
blockEpochChan: blockEpochChan,
94+
timerFactory: timerFactory,
95+
loopOutMaxParts: maxParts,
96+
cancelSwap: server.CancelLoopOutSwap,
97+
verifySchnorrSig: mockVerifySchnorrSigFail,
9698
}, height)
9799
if err != nil {
98100
log.Error(err)
@@ -209,11 +211,12 @@ func testLateHtlcPublish(t *testing.T) {
209211
errChan := make(chan error)
210212
go func() {
211213
err := swap.execute(context.Background(), &executeConfig{
212-
statusChan: statusChan,
213-
sweeper: sweeper,
214-
blockEpochChan: blockEpochChan,
215-
timerFactory: timerFactory,
216-
cancelSwap: server.CancelLoopOutSwap,
214+
statusChan: statusChan,
215+
sweeper: sweeper,
216+
blockEpochChan: blockEpochChan,
217+
timerFactory: timerFactory,
218+
cancelSwap: server.CancelLoopOutSwap,
219+
verifySchnorrSig: mockVerifySchnorrSigFail,
217220
}, height)
218221
if err != nil {
219222
log.Error(err)
@@ -320,11 +323,12 @@ func testCustomSweepConfTarget(t *testing.T) {
320323
errChan := make(chan error)
321324
go func() {
322325
err := swap.execute(context.Background(), &executeConfig{
323-
statusChan: statusChan,
324-
blockEpochChan: blockEpochChan,
325-
timerFactory: timerFactory,
326-
sweeper: sweeper,
327-
cancelSwap: server.CancelLoopOutSwap,
326+
statusChan: statusChan,
327+
blockEpochChan: blockEpochChan,
328+
timerFactory: timerFactory,
329+
sweeper: sweeper,
330+
cancelSwap: server.CancelLoopOutSwap,
331+
verifySchnorrSig: mockVerifySchnorrSigFail,
328332
}, ctx.Lnd.Height)
329333
if err != nil {
330334
log.Error(err)
@@ -550,11 +554,12 @@ func testPreimagePush(t *testing.T) {
550554
errChan := make(chan error)
551555
go func() {
552556
err := swap.execute(context.Background(), &executeConfig{
553-
statusChan: statusChan,
554-
blockEpochChan: blockEpochChan,
555-
timerFactory: timerFactory,
556-
sweeper: sweeper,
557-
cancelSwap: server.CancelLoopOutSwap,
557+
statusChan: statusChan,
558+
blockEpochChan: blockEpochChan,
559+
timerFactory: timerFactory,
560+
sweeper: sweeper,
561+
cancelSwap: server.CancelLoopOutSwap,
562+
verifySchnorrSig: mockVerifySchnorrSigFail,
558563
}, ctx.Lnd.Height)
559564
if err != nil {
560565
log.Error(err)
@@ -783,10 +788,11 @@ func testExpiryBeforeReveal(t *testing.T) {
783788
errChan := make(chan error)
784789
go func() {
785790
err := swap.execute(context.Background(), &executeConfig{
786-
statusChan: statusChan,
787-
blockEpochChan: blockEpochChan,
788-
timerFactory: timerFactory,
789-
sweeper: sweeper,
791+
statusChan: statusChan,
792+
blockEpochChan: blockEpochChan,
793+
timerFactory: timerFactory,
794+
sweeper: sweeper,
795+
verifySchnorrSig: mockVerifySchnorrSigFail,
790796
}, ctx.Lnd.Height)
791797
if err != nil {
792798
log.Error(err)
@@ -909,11 +915,12 @@ func testFailedOffChainCancelation(t *testing.T) {
909915
errChan := make(chan error)
910916
go func() {
911917
cfg := &executeConfig{
912-
statusChan: statusChan,
913-
sweeper: sweeper,
914-
blockEpochChan: blockEpochChan,
915-
timerFactory: timerFactory,
916-
cancelSwap: server.CancelLoopOutSwap,
918+
statusChan: statusChan,
919+
sweeper: sweeper,
920+
blockEpochChan: blockEpochChan,
921+
timerFactory: timerFactory,
922+
cancelSwap: server.CancelLoopOutSwap,
923+
verifySchnorrSig: mockVerifySchnorrSigFail,
917924
}
918925

919926
err := swap.execute(context.Background(), cfg, ctx.Lnd.Height)
@@ -1011,3 +1018,174 @@ func testFailedOffChainCancelation(t *testing.T) {
10111018
require.Equal(t, state.State, loopdb.StateFailOffchainPayments)
10121019
require.NoError(t, <-errChan)
10131020
}
1021+
1022+
// TestLoopOutMuSig2Sweep tests the loop out sweep flow when the MuSig2 signing
1023+
// process is successful.
1024+
func TestLoopOutMuSig2Sweep(t *testing.T) {
1025+
defer test.Guard(t)()
1026+
1027+
// TODO(bhandras): remove when MuSig2 is default.
1028+
loopdb.EnableExperimentalProtocol()
1029+
defer loopdb.ResetCurrentProtocolVersion()
1030+
1031+
lnd := test.NewMockLnd()
1032+
ctx := test.NewContext(t, lnd)
1033+
server := newServerMock(lnd)
1034+
1035+
testReq := *testRequest
1036+
testReq.SweepConfTarget = 10
1037+
testReq.Expiry = ctx.Lnd.Height + testLoopOutMinOnChainCltvDelta
1038+
1039+
// We set our mock fee estimate for our target sweep confs to be our
1040+
// max miner fee * 2. With MuSig2 we still expect that the client will
1041+
// publish the sweep but with the fee clamped to the maximum allowed
1042+
// miner fee as the preimage is revealed before the sweep txn is
1043+
// published.
1044+
ctx.Lnd.SetFeeEstimate(
1045+
testReq.SweepConfTarget, chainfee.SatPerKWeight(
1046+
testReq.MaxMinerFee*2,
1047+
),
1048+
)
1049+
1050+
cfg := newSwapConfig(
1051+
&lnd.LndServices, newStoreMock(t), server,
1052+
)
1053+
1054+
initResult, err := newLoopOutSwap(
1055+
context.Background(), cfg, ctx.Lnd.Height, &testReq,
1056+
)
1057+
require.NoError(t, err)
1058+
swap := initResult.swap
1059+
1060+
// Set up the required dependencies to execute the swap.
1061+
sweeper := &sweep.Sweeper{Lnd: &lnd.LndServices}
1062+
blockEpochChan := make(chan interface{})
1063+
statusChan := make(chan SwapInfo)
1064+
expiryChan := make(chan time.Time)
1065+
timerFactory := func(_ time.Duration) <-chan time.Time {
1066+
return expiryChan
1067+
}
1068+
1069+
errChan := make(chan error)
1070+
1071+
// Mock a successful signature verify to make sure we don't fail
1072+
// creating the MuSig2 sweep.
1073+
mockVerifySchnorrSigSuccess := func(pubKey *btcec.PublicKey, hash,
1074+
sig []byte) error {
1075+
1076+
return nil
1077+
}
1078+
1079+
go func() {
1080+
err := swap.execute(context.Background(), &executeConfig{
1081+
statusChan: statusChan,
1082+
blockEpochChan: blockEpochChan,
1083+
timerFactory: timerFactory,
1084+
sweeper: sweeper,
1085+
cancelSwap: server.CancelLoopOutSwap,
1086+
verifySchnorrSig: mockVerifySchnorrSigSuccess,
1087+
}, ctx.Lnd.Height)
1088+
if err != nil {
1089+
log.Error(err)
1090+
}
1091+
errChan <- err
1092+
}()
1093+
1094+
// The swap should be found in its initial state.
1095+
cfg.store.(*storeMock).assertLoopOutStored()
1096+
state := <-statusChan
1097+
require.Equal(t, loopdb.StateInitiated, state.State)
1098+
1099+
// We'll then pay both the swap and prepay invoice, which should trigger
1100+
// the server to publish the on-chain HTLC.
1101+
signalSwapPaymentResult := ctx.AssertPaid(swapInvoiceDesc)
1102+
signalPrepaymentResult := ctx.AssertPaid(prepayInvoiceDesc)
1103+
1104+
signalSwapPaymentResult(nil)
1105+
signalPrepaymentResult(nil)
1106+
1107+
// Notify the confirmation notification for the HTLC.
1108+
ctx.AssertRegisterConf(false, defaultConfirmations)
1109+
1110+
blockEpochChan <- ctx.Lnd.Height + 1
1111+
1112+
htlcTx := wire.NewMsgTx(2)
1113+
htlcTx.AddTxOut(&wire.TxOut{
1114+
Value: int64(swap.AmountRequested),
1115+
PkScript: swap.htlc.PkScript,
1116+
})
1117+
1118+
ctx.NotifyConf(htlcTx)
1119+
1120+
// The client should then register for a spend of the HTLC and attempt
1121+
// to sweep it using the custom confirmation target.
1122+
ctx.AssertRegisterSpendNtfn(swap.htlc.PkScript)
1123+
1124+
// Assert that we made a query to track our payment, as required for
1125+
// preimage push tracking.
1126+
trackPayment := ctx.AssertTrackPayment()
1127+
1128+
// Tick the expiry channel, we are still using our client confirmation
1129+
// target at this stage which has fees higher than our max acceptable
1130+
// fee. We do not expect a sweep attempt at this point. Since our
1131+
// preimage is not revealed, we also do not expect a preimage push.
1132+
expiryChan <- testTime
1133+
1134+
// When using taproot htlcs the flow is different as we do reveal the
1135+
// preimage before sweeping in order for the server to trust us with
1136+
// our MuSig2 signing attempts.
1137+
cfg.store.(*storeMock).assertLoopOutState(
1138+
loopdb.StatePreimageRevealed,
1139+
)
1140+
status := <-statusChan
1141+
require.Equal(
1142+
t, status.State, loopdb.StatePreimageRevealed,
1143+
)
1144+
1145+
preimage := <-server.preimagePush
1146+
require.Equal(t, swap.Preimage, preimage)
1147+
1148+
// We expect the sweep tx to have been published.
1149+
ctx.ReceiveTx()
1150+
1151+
// Since we don't have a reliable mechanism to non-intrusively avoid
1152+
// races by setting the fee estimate too soon, let's sleep here a bit
1153+
// to ensure the first sweep fails.
1154+
time.Sleep(500 * time.Millisecond)
1155+
1156+
// Now we decrease our fees for the swap's confirmation target to less
1157+
// than the maximum miner fee.
1158+
ctx.Lnd.SetFeeEstimate(testReq.SweepConfTarget, chainfee.SatPerKWeight(
1159+
testReq.MaxMinerFee/2,
1160+
))
1161+
1162+
// Now when we report a new block and tick our expiry fee timer, and
1163+
// fees are acceptably low so we expect our sweep to be published.
1164+
blockEpochChan <- ctx.Lnd.Height + 2
1165+
expiryChan <- testTime
1166+
1167+
preimage = <-server.preimagePush
1168+
require.Equal(t, swap.Preimage, preimage)
1169+
1170+
// We expect the sweep tx to have been published.
1171+
sweepTx := ctx.ReceiveTx()
1172+
1173+
// This time, we send a payment succeeded update into our payment stream
1174+
// to reflect that the server received our preimage push and settled off
1175+
// chain.
1176+
trackPayment.Updates <- lndclient.PaymentStatus{
1177+
State: lnrpc.Payment_SUCCEEDED,
1178+
}
1179+
1180+
// Make sure our sweep tx has a single witness indicating keyspend.
1181+
require.Len(t, sweepTx.TxIn[0].Witness, 1)
1182+
1183+
// Finally, we put this swap out of its misery and notify a successful
1184+
// spend our our sweepTx and assert that the swap succeeds.
1185+
ctx.NotifySpend(sweepTx, 0)
1186+
1187+
cfg.store.(*storeMock).assertLoopOutState(loopdb.StateSuccess)
1188+
status = <-statusChan
1189+
require.Equal(t, status.State, loopdb.StateSuccess)
1190+
require.NoError(t, <-errChan)
1191+
}

test/signer_mock.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ func (s *mockSigner) MuSig2Sign(context.Context, [32]byte, [32]byte,
116116
func (s *mockSigner) MuSig2CombineSig(context.Context, [32]byte,
117117
[][]byte) (bool, []byte, error) {
118118

119-
return false, nil, nil
119+
return true, nil, nil
120120
}
121121

122122
// MuSig2Cleanup removes a session from memory to free up resources.

0 commit comments

Comments
 (0)