diff --git a/loopout.go b/loopout.go index eb46d63..801abbd 100644 --- a/loopout.go +++ b/loopout.go @@ -597,7 +597,7 @@ func (s *loopOutSwap) sweep(ctx context.Context, // close to the expiration height, in which case we'll use the default // if it is better than what the client provided. confTarget := s.SweepConfTarget - if s.CltvExpiry-s.height >= DefaultSweepConfTargetDelta && + if s.CltvExpiry-s.height <= DefaultSweepConfTargetDelta && confTarget > DefaultSweepConfTarget { confTarget = DefaultSweepConfTarget } diff --git a/loopout_test.go b/loopout_test.go index 97493a6..c491e26 100644 --- a/loopout_test.go +++ b/loopout_test.go @@ -6,6 +6,9 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcutil" "github.com/lightninglabs/loop/lndclient" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/sweep" @@ -99,3 +102,166 @@ func TestLateHtlcPublish(t *testing.T) { t.Fatal(err) } } + +// TestCustomSweepConfTarget ensures we are able to sweep a Loop Out HTLC with a +// custom confirmation target. +func TestCustomSweepConfTarget(t *testing.T) { + defer test.Guard(t)() + + lnd := test.NewMockLnd() + ctx := test.NewContext(t, lnd) + + // Use the highest sweep confirmation target before we attempt to use + // the default. + testRequest.SweepConfTarget = testLoopOutOnChainCltvDelta - + DefaultSweepConfTargetDelta - 1 + + // Set up custom fee estimates such that the lower confirmation target + // yields a much higher fee rate. + ctx.Lnd.SetFeeEstimate(testRequest.SweepConfTarget, 250) + ctx.Lnd.SetFeeEstimate(DefaultSweepConfTarget, 10000) + + cfg := &swapConfig{ + lnd: &lnd.LndServices, + store: newStoreMock(t), + server: newServerMock(), + } + swap, err := newLoopOutSwap( + context.Background(), cfg, ctx.Lnd.Height, testRequest, + ) + if err != nil { + t.Fatal(err) + } + + // Set up the required dependencies to execute the swap. + // + // TODO: create test context similar to loopInTestContext. + sweeper := &sweep.Sweeper{Lnd: &lnd.LndServices} + blockEpochChan := make(chan interface{}) + statusChan := make(chan SwapInfo) + expiryChan := make(chan time.Time) + timerFactory := func(expiry time.Duration) <-chan time.Time { + return expiryChan + } + + errChan := make(chan error) + go func() { + err := swap.execute(context.Background(), &executeConfig{ + statusChan: statusChan, + blockEpochChan: blockEpochChan, + timerFactory: timerFactory, + sweeper: sweeper, + }, ctx.Lnd.Height) + if err != nil { + logger.Error(err) + } + errChan <- err + }() + + // The swap should be found in its initial state. + cfg.store.(*storeMock).assertLoopOutStored() + state := <-statusChan + if state.State != loopdb.StateInitiated { + t.Fatal("unexpected 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() + + blockEpochChan <- int32(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) + + expiryChan <- time.Now() + + cfg.store.(*storeMock).assertLoopOutState(loopdb.StatePreimageRevealed) + status := <-statusChan + if status.State != loopdb.StatePreimageRevealed { + t.Fatalf("expected state %v, got %v", + loopdb.StatePreimageRevealed, status.State) + } + + // assertSweepTx performs some sanity checks on a sweep transaction to + // ensure it was constructed correctly. + assertSweepTx := func(expConfTarget int32) *wire.MsgTx { + t.Helper() + + sweepTx := ctx.ReceiveTx() + if sweepTx.TxIn[0].PreviousOutPoint.Hash != htlcTx.TxHash() { + t.Fatalf("expected sweep tx to spend %v, got %v", + htlcTx.TxHash(), sweepTx.TxIn[0].PreviousOutPoint) + } + + // The fee used for the sweep transaction is an estimate based + // on the maximum witness size, so we should expect to see a + // lower fee when using the actual witness size of the + // transaction. + fee := btcutil.Amount( + htlcTx.TxOut[0].Value - sweepTx.TxOut[0].Value, + ) + + weight := blockchain.GetTransactionWeight(btcutil.NewTx(sweepTx)) + feeRate, err := ctx.Lnd.WalletKit.EstimateFee( + context.Background(), expConfTarget, + ) + if err != nil { + t.Fatalf("unable to retrieve fee estimate: %v", err) + } + minFee := feeRate.FeeForWeight(weight) + maxFee := btcutil.Amount(float64(minFee) * 1.1) + + if fee < minFee && fee > maxFee { + t.Fatalf("expected sweep tx to have fee between %v-%v, "+ + "got %v", minFee, maxFee, fee) + } + + return sweepTx + } + + // The sweep should have a fee that corresponds to the custom + // confirmation target. + sweepTx := assertSweepTx(testRequest.SweepConfTarget) + + // We'll then notify the height at which we begin using the default + // confirmation target. + defaultConfTargetHeight := ctx.Lnd.Height + testLoopOutOnChainCltvDelta - + DefaultSweepConfTargetDelta + blockEpochChan <- int32(defaultConfTargetHeight) + expiryChan <- time.Now() + + // We should expect to see another sweep using the higher fee since the + // spend hasn't been confirmed yet. + sweepTx = assertSweepTx(DefaultSweepConfTarget) + + // Notify the spend so that the swap reaches its final state. + ctx.NotifySpend(sweepTx, 0) + + cfg.store.(*storeMock).assertLoopOutState(loopdb.StateSuccess) + status = <-statusChan + if status.State != loopdb.StateSuccess { + t.Fatalf("expected state %v, got %v", loopdb.StateSuccess, + status.State) + } + + if err := <-errChan; err != nil { + t.Fatal(err) + } +} diff --git a/store_mock_test.go b/store_mock_test.go index ea28dda..317c864 100644 --- a/store_mock_test.go +++ b/store_mock_test.go @@ -205,6 +205,15 @@ func (s *storeMock) assertLoopOutStored() { } } +func (s *storeMock) assertLoopOutState(expectedState loopdb.SwapState) { + s.t.Helper() + + state := <-s.loopOutUpdateChan + if state.State != expectedState { + s.t.Fatalf("expected state %v, got %v", expectedState, state) + } +} + func (s *storeMock) assertLoopInStored() { s.t.Helper()