mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Update LND, Aperture, and Taproot Assets to revisions using the btcd v2 modules, and update lndclient to v0.21.0-3. Migrate Loop chain, transaction, and address types to their corresponding v2 packages. The lndclient release includes the migration from: https://github.com/lightninglabs/lndclient/pull/280 Taproot Assets is temporarily replaced with its btcd v2 revision because the v0.8 release branch has not adopted the new modules. This raises the minimum Go version to 1.26 and changes exported address types.
620 lines
17 KiB
Go
620 lines
17 KiB
Go
package loopd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
btcaddr "github.com/btcsuite/btcd/address/v2"
|
|
"github.com/btcsuite/btcd/btcutil/v2"
|
|
"github.com/btcsuite/btcd/txscript/v2"
|
|
"github.com/btcsuite/btcd/wire/v2"
|
|
"github.com/btcsuite/btclog/v2"
|
|
"github.com/lightninglabs/loop/loopdb"
|
|
"github.com/lightninglabs/loop/looprpc"
|
|
"github.com/lightninglabs/loop/swap"
|
|
"github.com/lightninglabs/loop/test"
|
|
"github.com/lightninglabs/loop/utils"
|
|
"github.com/lightningnetwork/lnd/chainntnfs"
|
|
"github.com/lightningnetwork/lnd/keychain"
|
|
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
|
|
"github.com/lightningnetwork/lnd/lntypes"
|
|
"github.com/lightningnetwork/lnd/lnwallet"
|
|
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// sweepHtlcTests is a collection of table tests for TestSweepHtlc.
|
|
var sweepHtlcTests = []struct {
|
|
name string
|
|
amount btcutil.Amount
|
|
satPerVByte uint32
|
|
minRelayFee chainfee.SatPerKWeight
|
|
expectErrMsg string
|
|
expectLogs []string
|
|
expectRegister bool
|
|
noSwap bool
|
|
publish bool
|
|
publishErr bool
|
|
modifyReq func(*looprpc.SweepHtlcRequest)
|
|
mutateSwap func(*loopdb.LoopOutContract)
|
|
mutateTxOut func(*wire.TxOut)
|
|
sendConf func(*test.ConfRegistration)
|
|
}{
|
|
{
|
|
name: "success low fee",
|
|
amount: 100_000,
|
|
satPerVByte: 10,
|
|
expectRegister: true,
|
|
expectLogs: []string{
|
|
"sweephtlc: start sweep for %v -> %v",
|
|
"sweephtlc: matched swap %v at height hint %v",
|
|
"sweephtlc: registering conf ntfn for %v hint=%v",
|
|
"sweephtlc: waiting for confirmation of %v",
|
|
"sweephtlc: funding confirmed at height %v",
|
|
"sweephtlc: swap hash validated for %v",
|
|
"sweephtlc: sweeping to %v with feerate %v sat/vbyte",
|
|
"sweephtlc: signing sweep spending %v",
|
|
"sweephtlc: witness assembled, tx size=%d vbytes",
|
|
},
|
|
},
|
|
{
|
|
name: "success low fee, publish",
|
|
amount: 100_000,
|
|
satPerVByte: 10,
|
|
expectRegister: true,
|
|
expectLogs: []string{
|
|
"sweephtlc: start sweep for %v -> %v",
|
|
"sweephtlc: matched swap %v at height hint %v",
|
|
"sweephtlc: registering conf ntfn for %v hint=%v",
|
|
"sweephtlc: waiting for confirmation of %v",
|
|
"sweephtlc: funding confirmed at height %v",
|
|
"sweephtlc: swap hash validated for %v",
|
|
"sweephtlc: sweeping to %v with feerate %v sat/vbyte",
|
|
"sweephtlc: signing sweep spending %v",
|
|
"sweephtlc: witness assembled, tx size=%d vbytes",
|
|
"sweephtlc: published sweep %v",
|
|
},
|
|
publish: true,
|
|
},
|
|
{
|
|
name: "publish failure reported",
|
|
amount: 100_000,
|
|
satPerVByte: 10,
|
|
expectRegister: true,
|
|
expectLogs: []string{
|
|
"sweephtlc: start sweep for %v -> %v",
|
|
"sweephtlc: matched swap %v at height hint %v",
|
|
"sweephtlc: registering conf ntfn for %v hint=%v",
|
|
"sweephtlc: waiting for confirmation of %v",
|
|
"sweephtlc: funding confirmed at height %v",
|
|
"sweephtlc: swap hash validated for %v",
|
|
"sweephtlc: sweeping to %v with feerate %v sat/vbyte",
|
|
"sweephtlc: signing sweep spending %v",
|
|
"sweephtlc: witness assembled, tx size=%d vbytes",
|
|
"sweephtlc: publish failed for %v: %v",
|
|
},
|
|
publish: true,
|
|
publishErr: true,
|
|
},
|
|
{
|
|
name: "fee clamped over ratio",
|
|
amount: 100_000,
|
|
satPerVByte: 200,
|
|
expectErrMsg: "fee exceeds",
|
|
expectRegister: true,
|
|
expectLogs: []string{
|
|
"sweephtlc: start sweep for %v -> %v",
|
|
"sweephtlc: matched swap %v at height hint %v",
|
|
"sweephtlc: registering conf ntfn for %v hint=%v",
|
|
"sweephtlc: waiting for confirmation of %v",
|
|
"sweephtlc: funding confirmed at height %v",
|
|
"sweephtlc: swap hash validated for %v",
|
|
"sweephtlc: sweeping to %v with feerate %v sat/vbyte",
|
|
},
|
|
},
|
|
{
|
|
name: "clamped below min relay",
|
|
amount: 10_000,
|
|
// Will clamp further.
|
|
satPerVByte: 5,
|
|
minRelayFee: chainfee.SatPerKWeight(1_000_000),
|
|
expectErrMsg: "fee too low for relay after clamp",
|
|
expectRegister: true,
|
|
expectLogs: []string{
|
|
"sweephtlc: start sweep for %v -> %v",
|
|
"sweephtlc: matched swap %v at height hint %v",
|
|
"sweephtlc: registering conf ntfn for %v hint=%v",
|
|
"sweephtlc: waiting for confirmation of %v",
|
|
"sweephtlc: funding confirmed at height %v",
|
|
"sweephtlc: swap hash validated for %v",
|
|
"sweephtlc: sweeping to %v with feerate %v sat/vbyte",
|
|
},
|
|
},
|
|
{
|
|
name: "missing outpoint",
|
|
amount: 100_000,
|
|
satPerVByte: 10,
|
|
expectErrMsg: "outpoint required",
|
|
expectLogs: []string{},
|
|
expectRegister: false,
|
|
modifyReq: func(req *looprpc.SweepHtlcRequest) {
|
|
req.Outpoint = ""
|
|
},
|
|
},
|
|
{
|
|
name: "missing htlc address",
|
|
amount: 100_000,
|
|
satPerVByte: 10,
|
|
expectErrMsg: "htlc_address required",
|
|
expectLogs: []string{},
|
|
expectRegister: false,
|
|
modifyReq: func(req *looprpc.SweepHtlcRequest) {
|
|
req.HtlcAddress = ""
|
|
},
|
|
},
|
|
{
|
|
name: "missing feerate",
|
|
amount: 100_000,
|
|
satPerVByte: 0,
|
|
expectErrMsg: "sat_per_vbyte required",
|
|
expectLogs: []string{},
|
|
expectRegister: false,
|
|
},
|
|
{
|
|
name: "invalid htlc address",
|
|
amount: 100_000,
|
|
satPerVByte: 10,
|
|
expectErrMsg: "invalid htlc_address",
|
|
expectLogs: []string{},
|
|
expectRegister: false,
|
|
modifyReq: func(req *looprpc.SweepHtlcRequest) {
|
|
req.HtlcAddress = "notanaddress"
|
|
},
|
|
},
|
|
{
|
|
name: "no matching swap",
|
|
amount: 100_000,
|
|
satPerVByte: 10,
|
|
expectErrMsg: "no matching swap",
|
|
expectRegister: false,
|
|
noSwap: true,
|
|
expectLogs: []string{},
|
|
},
|
|
{
|
|
name: "invalid initiation height",
|
|
amount: 100_000,
|
|
satPerVByte: 10,
|
|
expectErrMsg: "invalid initiation height",
|
|
expectRegister: false,
|
|
mutateSwap: func(contract *loopdb.LoopOutContract) {
|
|
contract.InitiationHeight = 0
|
|
},
|
|
expectLogs: []string{
|
|
"sweephtlc: start sweep for %v -> %v",
|
|
"sweephtlc: matched swap %v at height hint %v",
|
|
},
|
|
},
|
|
{
|
|
name: "conf ntfn error",
|
|
amount: 100_000,
|
|
satPerVByte: 10,
|
|
expectErrMsg: "conf ntfn",
|
|
expectRegister: true,
|
|
sendConf: func(reg *test.ConfRegistration) {
|
|
reg.ErrChan <- errors.New("boom")
|
|
},
|
|
expectLogs: []string{
|
|
"sweephtlc: start sweep for %v -> %v",
|
|
"sweephtlc: matched swap %v at height hint %v",
|
|
"sweephtlc: registering conf ntfn for %v hint=%v",
|
|
"sweephtlc: waiting for confirmation of %v",
|
|
"sweephtlc: conf ntfn error for %v: %v",
|
|
},
|
|
},
|
|
{
|
|
name: "outpoint script mismatch",
|
|
amount: 100_000,
|
|
satPerVByte: 10,
|
|
expectErrMsg: "outpoint script does not match HTLC address",
|
|
expectRegister: true,
|
|
mutateTxOut: func(txOut *wire.TxOut) {
|
|
txOut.PkScript = []byte{0x6a}
|
|
},
|
|
expectLogs: []string{
|
|
"sweephtlc: start sweep for %v -> %v",
|
|
"sweephtlc: matched swap %v at height hint %v",
|
|
"sweephtlc: registering conf ntfn for %v hint=%v",
|
|
"sweephtlc: waiting for confirmation of %v",
|
|
"sweephtlc: funding confirmed at height %v",
|
|
},
|
|
},
|
|
{
|
|
name: "fee exceeds htlc value",
|
|
amount: 100_000,
|
|
satPerVByte: 2_000_000,
|
|
expectErrMsg: "fee exceeds HTLC value",
|
|
expectRegister: true,
|
|
expectLogs: []string{
|
|
"sweephtlc: start sweep for %v -> %v",
|
|
"sweephtlc: matched swap %v at height hint %v",
|
|
"sweephtlc: registering conf ntfn for %v hint=%v",
|
|
"sweephtlc: waiting for confirmation of %v",
|
|
"sweephtlc: funding confirmed at height %v",
|
|
"sweephtlc: swap hash validated for %v",
|
|
"sweephtlc: sweeping to %v with feerate %v sat/vbyte",
|
|
},
|
|
},
|
|
{
|
|
name: "preimage mismatch",
|
|
amount: 100_000,
|
|
satPerVByte: 10,
|
|
expectErrMsg: "preimage does not match HTLC hash",
|
|
expectRegister: true,
|
|
modifyReq: func(req *looprpc.SweepHtlcRequest) {
|
|
req.Preimage = bytes.Repeat([]byte{9}, 32)
|
|
},
|
|
expectLogs: []string{
|
|
"sweephtlc: start sweep for %v -> %v",
|
|
"sweephtlc: matched swap %v at height hint %v",
|
|
"sweephtlc: registering conf ntfn for %v hint=%v",
|
|
"sweephtlc: waiting for confirmation of %v",
|
|
"sweephtlc: funding confirmed at height %v",
|
|
"sweephtlc: swap hash validated for %v",
|
|
},
|
|
},
|
|
{
|
|
name: "fallback to generated destination",
|
|
amount: 100_000,
|
|
satPerVByte: 10,
|
|
expectRegister: true,
|
|
mutateSwap: func(contract *loopdb.LoopOutContract) {
|
|
contract.DestAddr = nil
|
|
},
|
|
expectLogs: []string{
|
|
"sweephtlc: generated new destination address: %v",
|
|
"sweephtlc: start sweep for %v -> %v",
|
|
"sweephtlc: matched swap %v at height hint %v",
|
|
"sweephtlc: registering conf ntfn for %v hint=%v",
|
|
"sweephtlc: waiting for confirmation of %v",
|
|
"sweephtlc: funding confirmed at height %v",
|
|
"sweephtlc: swap hash validated for %v",
|
|
"sweephtlc: sweeping to %v with feerate %v sat/vbyte",
|
|
"sweephtlc: signing sweep spending %v",
|
|
"sweephtlc: witness assembled, tx size=%d vbytes",
|
|
},
|
|
},
|
|
}
|
|
|
|
// TestSweepHtlc runs a table of happy-path and fee-related rejection cases for
|
|
// the sweep helper.
|
|
func TestSweepHtlc(t *testing.T) {
|
|
// shortDelay is used to check that nothing is produced from a channel.
|
|
const shortDelay = 100 * time.Millisecond
|
|
|
|
for _, tc := range sweepHtlcTests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Catch leaked goroutines and constrain test time.
|
|
defer test.Guard(t)()
|
|
|
|
// Fresh logger per test to capture emitted formats.
|
|
logger := newFormatLogger()
|
|
setLogger(logger)
|
|
|
|
// Base mocks for wallet/notifier/signer.
|
|
lnd := test.NewMockLnd()
|
|
if tc.publishErr {
|
|
lnd.PublishHandler = func(ctx context.Context,
|
|
_ *wire.MsgTx, _ string) error {
|
|
|
|
return errors.New("publish-fail")
|
|
}
|
|
}
|
|
if tc.minRelayFee != 0 {
|
|
lnd.SetMinRelayFee(tc.minRelayFee)
|
|
}
|
|
store := loopdb.NewStoreMock(t)
|
|
|
|
preimage := lntypes.Preimage{1, 2, 3, 4}
|
|
swapHash := preimage.Hash()
|
|
|
|
_, senderPub := test.CreateKey(0)
|
|
_, receiverPub := test.CreateKey(1)
|
|
|
|
var senderKey, receiverKey [33]byte
|
|
copy(senderKey[:], senderPub.SerializeCompressed())
|
|
copy(receiverKey[:], receiverPub.SerializeCompressed())
|
|
|
|
htlcKeys := loopdb.HtlcKeys{
|
|
SenderScriptKey: senderKey,
|
|
ReceiverScriptKey: receiverKey,
|
|
ClientScriptKeyLocator: keychain.KeyLocator{
|
|
Family: keychain.KeyFamily(
|
|
swap.KeyFamily,
|
|
),
|
|
Index: 0,
|
|
},
|
|
}
|
|
|
|
swapContract := loopdb.SwapContract{
|
|
Preimage: preimage,
|
|
AmountRequested: tc.amount,
|
|
HtlcKeys: htlcKeys,
|
|
CltvExpiry: 500,
|
|
InitiationHeight: 123,
|
|
ProtocolVersion: loopdb.ProtocolVersionHtlcV2,
|
|
}
|
|
|
|
destAddr, err := btcaddr.NewAddressWitnessPubKeyHash(
|
|
bytes.Repeat([]byte{1}, 20), lnd.ChainParams,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
loopOut := &loopdb.LoopOut{
|
|
Loop: loopdb.Loop{
|
|
Hash: swapHash,
|
|
},
|
|
Contract: &loopdb.LoopOutContract{
|
|
SwapContract: swapContract,
|
|
DestAddr: destAddr,
|
|
},
|
|
}
|
|
|
|
// Store the swap unless this case disables it.
|
|
if tc.mutateSwap != nil {
|
|
tc.mutateSwap(loopOut.Contract)
|
|
}
|
|
if !tc.noSwap {
|
|
store.LoopOutSwaps[swapHash] = loopOut.Contract
|
|
}
|
|
|
|
// Build HTLC details and funding tx.
|
|
htlc, err := utils.GetHtlc(
|
|
swapHash, &loopOut.Contract.SwapContract,
|
|
lnd.ChainParams,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
fundingTx := wire.NewMsgTx(2)
|
|
txOut := &wire.TxOut{
|
|
Value: int64(
|
|
loopOut.Contract.AmountRequested,
|
|
),
|
|
PkScript: htlc.PkScript,
|
|
}
|
|
if tc.mutateTxOut != nil {
|
|
tc.mutateTxOut(txOut)
|
|
}
|
|
fundingTx.AddTxOut(txOut)
|
|
fundingHash := fundingTx.TxHash()
|
|
outpoint := wire.OutPoint{Hash: fundingHash, Index: 0}
|
|
|
|
ctx, cancel := context.WithTimeout(
|
|
t.Context(), 5*time.Second,
|
|
)
|
|
defer cancel()
|
|
|
|
// Drain signer requests to avoid blocking.
|
|
go func() {
|
|
select {
|
|
case <-lnd.SignOutputRawChannel:
|
|
|
|
case <-ctx.Done():
|
|
}
|
|
}()
|
|
|
|
pubChan := make(chan *wire.MsgTx, 1)
|
|
|
|
// If publish is requested, drain TxPublishChannel so
|
|
// the mock PublishTransaction does not block.
|
|
if tc.publish {
|
|
go func() {
|
|
select {
|
|
case tx := <-lnd.TxPublishChannel:
|
|
pubChan <- tx
|
|
|
|
case <-ctx.Done():
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Handle confirmation registration caused by the call.
|
|
if tc.expectRegister {
|
|
// Consume notifier registration.
|
|
go func() {
|
|
var reg *test.ConfRegistration
|
|
select {
|
|
case reg = <-lnd.RegisterConfChannel:
|
|
// Got registration.
|
|
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
|
|
// Either send an error or a
|
|
// confirmation.
|
|
if tc.sendConf != nil {
|
|
tc.sendConf(reg)
|
|
|
|
return
|
|
}
|
|
|
|
conf := &chainntnfs.TxConfirmation{
|
|
Tx: fundingTx,
|
|
}
|
|
reg.ConfChan <- conf
|
|
}()
|
|
}
|
|
|
|
// Build request with optional mutation.
|
|
req := &looprpc.SweepHtlcRequest{
|
|
Outpoint: outpoint.String(),
|
|
SatPerVbyte: tc.satPerVByte,
|
|
Publish: tc.publish,
|
|
HtlcAddress: htlc.Address.String(),
|
|
DestAddress: "",
|
|
Preimage: nil,
|
|
}
|
|
if tc.modifyReq != nil {
|
|
tc.modifyReq(req)
|
|
}
|
|
|
|
// Invoke sweepHtlc and forward the result.
|
|
resp, err := sweepHtlc(
|
|
ctx, req, lnd.ChainParams, store,
|
|
lnd.ChainNotifier, lnd.WalletKit,
|
|
lnd.Signer,
|
|
)
|
|
|
|
// Handle confirmation registration caused by the call
|
|
// when not expected.
|
|
if !tc.expectRegister {
|
|
select {
|
|
case reg := <-lnd.RegisterConfChannel:
|
|
t.Fatalf("unexpected registration: %+v",
|
|
reg)
|
|
|
|
case <-time.After(shortDelay):
|
|
}
|
|
}
|
|
|
|
// Make sure it produced the expected logs.
|
|
logs := logger.formats
|
|
if logs == nil {
|
|
logs = []string{}
|
|
}
|
|
require.Equal(t, tc.expectLogs, logs)
|
|
|
|
// Ensure all mock channels are drained.
|
|
defer require.NoError(t, lnd.IsDone())
|
|
|
|
// Error path.
|
|
if tc.expectErrMsg != "" {
|
|
require.ErrorContains(t, err, tc.expectErrMsg)
|
|
|
|
return
|
|
}
|
|
|
|
// Success path.
|
|
require.NoError(t, err)
|
|
|
|
// Parse the produced signed transaction.
|
|
require.NotEmpty(t, resp.SweepTx)
|
|
var sweepTx wire.MsgTx
|
|
err = sweepTx.Deserialize(bytes.NewReader(resp.SweepTx))
|
|
require.NoError(t, err)
|
|
require.Equal(
|
|
t, outpoint, sweepTx.TxIn[0].PreviousOutPoint,
|
|
)
|
|
require.NotEmpty(t, sweepTx.TxIn[0].Witness)
|
|
|
|
// Verify that the sweep uses the stored destination address and
|
|
// only falls back to wallet address generation when the swap
|
|
// record does not define one.
|
|
expectedAddr := loopOut.Contract.DestAddr
|
|
if req.DestAddress != "" {
|
|
expectedAddr, err = btcaddr.DecodeAddress(
|
|
req.DestAddress, lnd.ChainParams,
|
|
)
|
|
require.NoError(t, err)
|
|
}
|
|
if expectedAddr == nil {
|
|
expectedAddr, err = lnd.WalletKit.NextAddr(
|
|
ctx, lnwallet.DefaultAccountName,
|
|
walletrpc.AddressType_TAPROOT_PUBKEY, false,
|
|
)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
expectedPkScript, err := txscript.PayToAddrScript(
|
|
expectedAddr,
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, sweepTx.TxOut, 1)
|
|
require.Equal(
|
|
t, expectedPkScript, sweepTx.TxOut[0].PkScript,
|
|
)
|
|
|
|
if tc.publish {
|
|
// For publish=true we should see a
|
|
// publish (or a publish failure
|
|
// response which skips broadcast).
|
|
select {
|
|
case tx := <-pubChan:
|
|
require.NotNil(t, tx)
|
|
|
|
case <-time.After(shortDelay):
|
|
if !tc.publishErr {
|
|
t.Fatal("expected publish")
|
|
}
|
|
}
|
|
} else {
|
|
// For publish=false we should not
|
|
// publish.
|
|
select {
|
|
case <-lnd.TxPublishChannel:
|
|
t.Fatal("unexpected publish")
|
|
|
|
case <-time.After(shortDelay):
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// formatLogger captures format strings passed to the logger interface so we
|
|
// can assert on log invocations.
|
|
type formatLogger struct {
|
|
btclog.Logger
|
|
|
|
formats []string
|
|
}
|
|
|
|
// newFormatLogger builds a logger that records format strings while discarding
|
|
// actual log output.
|
|
func newFormatLogger() *formatLogger {
|
|
return &formatLogger{Logger: btclog.Disabled}
|
|
}
|
|
|
|
// record stores the raw format string.
|
|
func (f *formatLogger) record(format string) {
|
|
f.formats = append(f.formats, format)
|
|
}
|
|
|
|
// Tracef logs a trace and records its format.
|
|
func (f *formatLogger) Tracef(format string, params ...any) {
|
|
f.record(format)
|
|
f.Logger.Tracef(format, params...)
|
|
}
|
|
|
|
// Debugf logs a debug message and records its format.
|
|
func (f *formatLogger) Debugf(format string, params ...any) {
|
|
f.record(format)
|
|
f.Logger.Debugf(format, params...)
|
|
}
|
|
|
|
// Infof logs an info message and records its format.
|
|
func (f *formatLogger) Infof(format string, params ...any) {
|
|
f.record(format)
|
|
f.Logger.Infof(format, params...)
|
|
}
|
|
|
|
// Warnf logs a warning and records its format.
|
|
func (f *formatLogger) Warnf(format string, params ...any) {
|
|
f.record(format)
|
|
f.Logger.Warnf(format, params...)
|
|
}
|
|
|
|
// Errorf logs an error and records its format.
|
|
func (f *formatLogger) Errorf(format string, params ...any) {
|
|
f.record(format)
|
|
f.Logger.Errorf(format, params...)
|
|
}
|
|
|
|
// Criticalf logs a critical message and records its format.
|
|
func (f *formatLogger) Criticalf(format string, params ...any) {
|
|
f.record(format)
|
|
f.Logger.Criticalf(format, params...)
|
|
}
|