You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
loop/loopin_test.go

793 lines
19 KiB
Go

package loop
import (
"context"
"fmt"
"testing"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/test"
"github.com/lightninglabs/loop/utils"
"github.com/lightningnetwork/lnd/chainntnfs"
invpkg "github.com/lightningnetwork/lnd/invoices"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
)
var (
testLoopInRequest = LoopInRequest{
Amount: btcutil.Amount(50000),
MaxSwapFee: btcutil.Amount(1000),
HtlcConfTarget: 2,
Initiator: "test",
}
)
// TestLoopInSuccess tests the success scenario where the swap completes the
// happy flow.
func TestLoopInSuccess(t *testing.T) {
t.Run("stable protocol", func(t *testing.T) {
testLoopInSuccess(t)
})
t.Run("experimental protocol", func(t *testing.T) {
loopdb.EnableExperimentalProtocol()
defer loopdb.ResetCurrentProtocolVersion()
testLoopInSuccess(t)
})
}
func testLoopInSuccess(t *testing.T) {
defer test.Guard(t)()
ctx := newLoopInTestContext(t)
height := int32(600)
cfg := newSwapConfig(&ctx.lnd.LndServices, ctx.store, ctx.server)
expectedLastHop := &route.Vertex{0x02}
req := &testLoopInRequest
req.LastHop = expectedLastHop
initResult, err := newLoopInSwap(
context.Background(), cfg,
height, req,
)
require.NoError(t, err)
inSwap := initResult.swap
ctx.store.AssertLoopInStored()
errChan := make(chan error)
go func() {
err := inSwap.execute(context.Background(), ctx.cfg, height)
if err != nil {
log.Error(err)
}
errChan <- err
}()
swapInfo := <-ctx.statusChan
require.Equal(t, loopdb.StateInitiated, swapInfo.State)
// Check that the SwapInfo contains the provided last hop.
require.Equal(t, expectedLastHop, swapInfo.LastHop)
// Check that the SwapInfo does not contain an outgoing chan set.
require.Nil(t, swapInfo.OutgoingChanSet)
ctx.assertState(loopdb.StateHtlcPublished)
ctx.store.AssertLoopInState(loopdb.StateHtlcPublished)
// Expect htlc to be published.
htlcTx := <-ctx.lnd.SendOutputsChannel
// We expect our cost to use the mock fee rate we set for our conf
// target.
cost := loopdb.SwapCost{
Onchain: getTxFee(&htlcTx, test.DefaultMockFee.FeePerKVByte()),
}
// Expect the same state to be written again with the htlc tx hash
// and on chain fee.
state := ctx.store.AssertLoopInState(loopdb.StateHtlcPublished)
require.NotNil(t, state.HtlcTxHash)
require.Equal(t, cost, state.Cost)
// Expect register for htlc conf (only one, since the htlc is p2tr).
<-ctx.lnd.RegisterConfChannel
// Confirm htlc.
ctx.lnd.ConfChannel <- &chainntnfs.TxConfirmation{
Tx: &htlcTx,
}
// Client starts listening for spend of htlc.
<-ctx.lnd.RegisterSpendChannel
// Client starts listening for swap invoice updates.
ctx.assertSubscribeInvoice(ctx.server.swapHash)
// Server has already paid invoice before spending the htlc. Signal
// settled.
ctx.updateInvoiceState(49000, invpkg.ContractSettled)
// Swap is expected to move to the state InvoiceSettled
ctx.assertState(loopdb.StateInvoiceSettled)
ctx.store.AssertLoopInState(loopdb.StateInvoiceSettled)
// Server spends htlc.
successTx := wire.MsgTx{}
witness, err := inSwap.htlc.GenSuccessWitness(
[]byte{}, inSwap.contract.Preimage,
)
require.NoError(t, err)
successTx.AddTxIn(&wire.TxIn{
Witness: witness,
})
ctx.lnd.SpendChannel <- &chainntnfs.SpendDetail{
SpendingTx: &successTx,
SpenderInputIndex: 0,
}
ctx.assertState(loopdb.StateSuccess)
ctx.store.AssertLoopInState(loopdb.StateSuccess)
require.NoError(t, <-errChan)
}
// TestLoopInTimeout tests scenarios where the server doesn't sweep the htlc
// and the client is forced to reclaim the funds using the timeout tx.
func TestLoopInTimeout(t *testing.T) {
testAmt := int64(testLoopInRequest.Amount)
testCases := []struct {
name string
externalValue int64
}{
{
name: "internal htlc",
externalValue: 0,
},
{
name: "external htlc",
externalValue: testAmt,
},
{
name: "external htlc amount too high",
externalValue: testAmt + 1,
},
{
name: "external htlc amount too low",
externalValue: testAmt - 1,
},
}
for _, next := range []bool{false, true} {
next := next
for _, testCase := range testCases {
testCase := testCase
name := testCase.name
if next {
name += " experimental protocol"
}
t.Run(name, func(t *testing.T) {
if next {
loopdb.EnableExperimentalProtocol()
defer loopdb.ResetCurrentProtocolVersion()
}
testLoopInTimeout(t, testCase.externalValue)
})
}
}
}
func testLoopInTimeout(t *testing.T, externalValue int64) {
defer test.Guard(t)()
ctx := newLoopInTestContext(t)
height := int32(600)
cfg := newSwapConfig(&ctx.lnd.LndServices, ctx.store, ctx.server)
req := testLoopInRequest
if externalValue != 0 {
req.ExternalHtlc = true
}
initResult, err := newLoopInSwap(
context.Background(), cfg,
height, &req,
)
require.NoError(t, err)
inSwap := initResult.swap
ctx.store.AssertLoopInStored()
errChan := make(chan error)
go func() {
err := inSwap.execute(context.Background(), ctx.cfg, height)
if err != nil {
log.Error(err)
}
errChan <- err
}()
ctx.assertState(loopdb.StateInitiated)
ctx.assertState(loopdb.StateHtlcPublished)
ctx.store.AssertLoopInState(loopdb.StateHtlcPublished)
var (
htlcTx wire.MsgTx
cost loopdb.SwapCost
)
if externalValue == 0 {
// Expect htlc to be published.
htlcTx = <-ctx.lnd.SendOutputsChannel
cost = loopdb.SwapCost{
Onchain: getTxFee(
&htlcTx, test.DefaultMockFee.FeePerKVByte(),
),
}
// Expect the same state to be written again with the htlc tx
// hash and cost.
state := ctx.store.AssertLoopInState(loopdb.StateHtlcPublished)
require.NotNil(t, state.HtlcTxHash)
require.Equal(t, cost, state.Cost)
} else {
// Create an external htlc publish tx.
var pkScript []byte
if !IsTaprootSwap(&inSwap.SwapContract) {
pkScript = inSwap.htlcP2WSH.PkScript
} else {
pkScript = inSwap.htlcP2TR.PkScript
}
htlcTx = wire.MsgTx{
TxOut: []*wire.TxOut{
{
PkScript: pkScript,
Value: externalValue,
},
},
}
}
// Expect register for htlc conf.
<-ctx.lnd.RegisterConfChannel
// Confirm htlc.
ctx.lnd.ConfChannel <- &chainntnfs.TxConfirmation{
Tx: &htlcTx,
}
isInvalidAmt := externalValue != 0 && externalValue != int64(req.Amount)
handleHtlcExpiry(
t, ctx, inSwap, htlcTx, cost, errChan, isInvalidAmt,
)
}
func handleHtlcExpiry(t *testing.T, ctx *loopInTestContext, inSwap *loopInSwap,
htlcTx wire.MsgTx, cost loopdb.SwapCost, errChan chan error,
isInvalidAmount bool) {
if isInvalidAmount {
ctx.store.AssertLoopInState(loopdb.StateFailIncorrectHtlcAmt)
ctx.assertState(loopdb.StateFailIncorrectHtlcAmt)
}
// Client starts listening for spend of htlc.
<-ctx.lnd.RegisterSpendChannel
// Client starts listening for swap invoice updates.
ctx.assertSubscribeInvoice(ctx.server.swapHash)
// Let htlc expire.
ctx.blockEpochChan <- inSwap.LoopInContract.CltvExpiry
// Expect a signing request for the htlc tx output value.
signReq := <-ctx.lnd.SignOutputRawChannel
require.Equal(
t, htlcTx.TxOut[0].Value,
signReq.SignDescriptors[0].Output.Value,
"invalid signing amount",
)
// Expect timeout tx to be published.
timeoutTx := <-ctx.lnd.TxPublishChannel
// We can just get our sweep fee as we would in the swap code because
// our estimate is static.
fee, err := inSwap.sweeper.GetSweepFee(
context.Background(), inSwap.htlc.AddTimeoutToEstimator,
inSwap.timeoutAddr, TimeoutTxConfTarget,
)
require.NoError(t, err)
cost.Onchain += fee
// Confirm timeout tx.
ctx.lnd.SpendChannel <- &chainntnfs.SpendDetail{
SpendingTx: timeoutTx,
SpenderInputIndex: 0,
}
// Now that timeout tx has confirmed, the client should be able to
// safely cancel the swap invoice.
<-ctx.lnd.FailInvoiceChannel
// Signal that the invoice was canceled.
ctx.updateInvoiceState(0, invpkg.ContractCanceled)
var state loopdb.SwapStateData
if isInvalidAmount {
state = ctx.store.AssertLoopInState(
loopdb.StateFailIncorrectHtlcAmtSwept,
)
ctx.assertState(loopdb.StateFailIncorrectHtlcAmtSwept)
} else {
ctx.assertState(loopdb.StateFailTimeout)
state = ctx.store.AssertLoopInState(loopdb.StateFailTimeout)
}
require.Equal(t, cost, state.Cost)
require.NoError(t, <-errChan)
}
// TestLoopInResume tests resuming swaps in various states.
func TestLoopInResume(t *testing.T) {
storedVersion := []loopdb.ProtocolVersion{
loopdb.ProtocolVersionUnrecorded,
loopdb.ProtocolVersionHtlcV2,
loopdb.ProtocolVersionHtlcV3,
loopdb.ProtocolVersionMuSig2,
}
testCases := []struct {
name string
state loopdb.SwapState
expired bool
}{
{
name: "initiated",
state: loopdb.StateInitiated,
expired: false,
},
{
name: "initiated expired",
state: loopdb.StateInitiated,
expired: true,
},
{
name: "htlc published",
state: loopdb.StateHtlcPublished,
expired: false,
},
}
for _, next := range []bool{false, true} {
for _, version := range storedVersion {
version := version
for _, testCase := range testCases {
testCase := testCase
name := fmt.Sprintf(
"%v %v", testCase, version.String(),
)
if next {
name += " next protocol"
}
t.Run(name, func(t *testing.T) {
testLoopInResume(
t, testCase.state,
testCase.expired,
version,
)
})
}
}
}
}
func testLoopInResume(t *testing.T, state loopdb.SwapState, expired bool,
storedVersion loopdb.ProtocolVersion) {
defer test.Guard(t)()
ctxb := context.Background()
ctx := newLoopInTestContext(t)
cfg := newSwapConfig(&ctx.lnd.LndServices, ctx.store, ctx.server)
// Create sender and receiver keys.
_, senderPubKey := test.CreateKey(1)
_, receiverPubKey := test.CreateKey(2)
var senderKey, receiverKey [33]byte
copy(receiverKey[:], receiverPubKey.SerializeCompressed())
copy(senderKey[:], senderPubKey.SerializeCompressed())
contract := &loopdb.LoopInContract{
HtlcConfTarget: 2,
SwapContract: loopdb.SwapContract{
Preimage: testPreimage,
AmountRequested: 100000,
CltvExpiry: 744,
HtlcKeys: loopdb.HtlcKeys{
SenderScriptKey: senderKey,
SenderInternalPubKey: senderKey,
ReceiverScriptKey: receiverKey,
ReceiverInternalPubKey: receiverKey,
},
MaxSwapFee: 60000,
MaxMinerFee: 50000,
ProtocolVersion: storedVersion,
},
}
pendSwap := &loopdb.LoopIn{
Contract: contract,
Loop: loopdb.Loop{
Events: []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: state,
},
},
},
Hash: testPreimage.Hash(),
},
}
// If we have already published the htlc, we expect our cost to already
// be published.
var cost loopdb.SwapCost
if state == loopdb.StateHtlcPublished {
cost = loopdb.SwapCost{
Onchain: 999,
}
pendSwap.Loop.Events[0].Cost = cost
}
htlc, err := utils.GetHtlc(
testPreimage.Hash(), &contract.SwapContract,
cfg.lnd.ChainParams,
)
require.NoError(t, err)
err = ctx.store.CreateLoopIn(ctxb, testPreimage.Hash(), contract)
require.NoError(t, err)
inSwap, err := resumeLoopInSwap(context.Background(), cfg, pendSwap)
require.NoError(t, err)
var height int32
if expired {
height = 740
} else {
height = 600
}
errChan := make(chan error)
go func() {
err := inSwap.execute(context.Background(), ctx.cfg, height)
if err != nil {
log.Error(err)
}
errChan <- err
}()
defer func() {
require.NoError(t, <-errChan)
select {
case <-ctx.lnd.SendPaymentChannel:
t.Fatal("unexpected payment sent")
default:
}
select {
case <-ctx.lnd.SendOutputsChannel:
t.Fatal("unexpected tx published")
default:
}
}()
var htlcTx wire.MsgTx
if state == loopdb.StateInitiated {
ctx.assertState(loopdb.StateInitiated)
if expired {
ctx.assertState(loopdb.StateFailTimeout)
return
}
ctx.assertState(loopdb.StateHtlcPublished)
ctx.store.AssertLoopInState(loopdb.StateHtlcPublished)
// Expect htlc to be published.
htlcTx = <-ctx.lnd.SendOutputsChannel
cost = loopdb.SwapCost{
Onchain: getTxFee(
&htlcTx, test.DefaultMockFee.FeePerKVByte(),
),
}
// Expect the same state to be written again with the htlc tx
// hash.
state := ctx.store.AssertLoopInState(loopdb.StateHtlcPublished)
require.NotNil(t, state.HtlcTxHash)
} else {
ctx.assertState(loopdb.StateHtlcPublished)
htlcTx.AddTxOut(&wire.TxOut{
PkScript: htlc.PkScript,
Value: int64(contract.AmountRequested),
})
}
// Expect register for htlc conf.
<-ctx.lnd.RegisterConfChannel
// Confirm htlc.
ctx.lnd.ConfChannel <- &chainntnfs.TxConfirmation{
Tx: &htlcTx,
}
// Client starts listening for spend of htlc.
<-ctx.lnd.RegisterSpendChannel
// Client starts listening for swap invoice updates.
ctx.assertSubscribeInvoice(testPreimage.Hash())
// Server has already paid invoice before spending the htlc. Signal
// settled.
amtPaid := btcutil.Amount(49000)
ctx.updateInvoiceState(amtPaid, invpkg.ContractSettled)
// Swap is expected to move to the state InvoiceSettled
ctx.assertState(loopdb.StateInvoiceSettled)
ctx.store.AssertLoopInState(loopdb.StateInvoiceSettled)
// Server spends htlc.
successTx := wire.MsgTx{}
witness, err := htlc.GenSuccessWitness([]byte{}, testPreimage)
require.NoError(t, err)
successTx.AddTxIn(&wire.TxIn{
Witness: witness,
})
successTxHash := successTx.TxHash()
ctx.lnd.SpendChannel <- &chainntnfs.SpendDetail{
SpendingTx: &successTx,
SpenderTxHash: &successTxHash,
SpenderInputIndex: 0,
}
ctx.assertState(loopdb.StateSuccess)
finalState := ctx.store.AssertLoopInState(loopdb.StateSuccess)
// We expect our server fee to reflect as the difference between htlc
// value and invoice amount paid. We use our original on-chain cost, set
// earlier in the test, because we expect this value to be unchanged.
cost.Server = btcutil.Amount(htlcTx.TxOut[0].Value) - amtPaid
require.Equal(t, cost, finalState.Cost)
}
// TestAbandonPublishedHtlcState advances a loop-in swap to StateHtlcPublished,
// then abandons it and ensures that executing the same swap would not progress.
func TestAbandonPublishedHtlcState(t *testing.T) {
defer test.Guard(t)()
ctx := newLoopInTestContext(t)
height := int32(600)
cfg, err, inSwap := startNewLoopIn(t, ctx, height)
require.NoError(t, err)
advanceToPublishedHtlc(t, ctx)
// The client requests to abandon the published htlc state.
inSwap.abandonChan <- struct{}{}
// Ensure that the swap is now in the StateFailAbandoned state.
ctx.assertState(loopdb.StateFailAbandoned)
// Ensure that the swap is also in the StateFailAbandoned state in the
// database.
ctx.store.AssertLoopInState(loopdb.StateFailAbandoned)
// Ensure that the swap was abandoned and the execution stopped.
err = <-ctx.errChan
require.Error(t, err)
require.Contains(t, err.Error(), "swap hash abandoned by client")
// We re-instantiate the swap and ensure that it does not progress.
pendSwap := &loopdb.LoopIn{
Contract: &inSwap.LoopInContract,
Loop: loopdb.Loop{
Events: []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: inSwap.state,
},
},
},
Hash: testPreimage.Hash(),
},
}
resumedSwap, err := resumeLoopInSwap(
context.Background(), cfg, pendSwap,
)
require.NoError(t, err)
// Execute the abandoned swap.
go func() {
err := resumedSwap.execute(
context.Background(), ctx.cfg, height,
)
if err != nil {
log.Error(err)
}
ctx.errChan <- err
}()
// Ensure that the swap is still in the StateFailAbandoned state.
swapInfo := <-ctx.statusChan
require.Equal(t, loopdb.StateFailAbandoned, swapInfo.State)
// Ensure that the execution flagged the abandoned swap as finalized.
err = <-ctx.errChan
require.Error(t, err)
require.Equal(t, ErrSwapFinalized, err)
}
// TestAbandonSettledInvoiceState advances a loop-in swap to
// StateInvoiceSettled, then abandons it and ensures that executing the same
// swap would not progress.
func TestAbandonSettledInvoiceState(t *testing.T) {
defer test.Guard(t)()
ctx := newLoopInTestContext(t)
height := int32(600)
cfg, err, inSwap := startNewLoopIn(t, ctx, height)
require.NoError(t, err)
advanceToPublishedHtlc(t, ctx)
// Client starts listening for swap invoice updates.
ctx.assertSubscribeInvoice(ctx.server.swapHash)
// Server has already paid invoice before spending the htlc. Signal
// settled.
ctx.updateInvoiceState(49000, invpkg.ContractSettled)
// Swap is expected to move to the state InvoiceSettled
ctx.assertState(loopdb.StateInvoiceSettled)
ctx.store.AssertLoopInState(loopdb.StateInvoiceSettled)
// The client requests to abandon the published htlc state.
inSwap.abandonChan <- struct{}{}
// Ensure that the swap is now in the StateFailAbandoned state.
ctx.assertState(loopdb.StateFailAbandoned)
// Ensure that the swap is also in the StateFailAbandoned state in the
// database.
ctx.store.AssertLoopInState(loopdb.StateFailAbandoned)
// Ensure that the swap was abandoned and the execution stopped.
err = <-ctx.errChan
require.Error(t, err)
require.Contains(t, err.Error(), "swap hash abandoned by client")
// We re-instantiate the swap and ensure that it does not progress.
pendSwap := &loopdb.LoopIn{
Contract: &inSwap.LoopInContract,
Loop: loopdb.Loop{
Events: []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: inSwap.state,
},
},
},
Hash: testPreimage.Hash(),
},
}
resumedSwap, err := resumeLoopInSwap(context.Background(), cfg, pendSwap)
require.NoError(t, err)
// Execute the abandoned swap.
go func() {
err := resumedSwap.execute(
context.Background(), ctx.cfg, height,
)
if err != nil {
log.Error(err)
}
ctx.errChan <- err
}()
// Ensure that the swap is still in the StateFailAbandoned state.
swapInfo := <-ctx.statusChan
require.Equal(t, loopdb.StateFailAbandoned, swapInfo.State)
// Ensure that the execution flagged the abandoned swap as finalized.
err = <-ctx.errChan
require.Error(t, err)
require.Equal(t, ErrSwapFinalized, err)
}
func advanceToPublishedHtlc(t *testing.T, ctx *loopInTestContext) SwapInfo {
swapInfo := <-ctx.statusChan
require.Equal(t, loopdb.StateInitiated, swapInfo.State)
ctx.assertState(loopdb.StateHtlcPublished)
ctx.store.AssertLoopInState(loopdb.StateHtlcPublished)
// Expect htlc to be published.
htlcTx := <-ctx.lnd.SendOutputsChannel
// Expect the same state to be written again with the htlc tx hash
// and on chain fee.
ctx.store.AssertLoopInState(loopdb.StateHtlcPublished)
// Expect register for htlc conf (only one, since the htlc is p2tr).
<-ctx.lnd.RegisterConfChannel
// Confirm htlc.
ctx.lnd.ConfChannel <- &chainntnfs.TxConfirmation{
Tx: &htlcTx,
}
// Client starts listening for spend of htlc.
<-ctx.lnd.RegisterSpendChannel
return swapInfo
}
func startNewLoopIn(t *testing.T, ctx *loopInTestContext, height int32) (
*swapConfig, error, *loopInSwap) {
cfg := newSwapConfig(&ctx.lnd.LndServices, ctx.store, ctx.server)
req := &testLoopInRequest
initResult, err := newLoopInSwap(
context.Background(), cfg,
height, req,
)
require.NoError(t, err)
inSwap := initResult.swap
ctx.store.AssertLoopInStored()
go func() {
err := inSwap.execute(context.Background(), ctx.cfg, height)
if err != nil {
log.Error(err)
}
ctx.errChan <- err
}()
return cfg, err, inSwap
}