diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 99a53ef5..f6e0c9fe 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1365,6 +1365,17 @@ func (s *swapClientServer) StopDaemon(ctx context.Context, return &looprpc.StopDaemonResponse{}, nil } +// SweepHtlc spends a Loop HTLC output using the success path and a known +// preimage. +func (s *swapClientServer) SweepHtlc(ctx context.Context, + req *looprpc.SweepHtlcRequest) (*looprpc.SweepHtlcResponse, error) { + + return sweepHtlc( + ctx, req, s.lnd.ChainParams, s.impl.Store, + s.lnd.ChainNotifier, s.lnd.WalletKit, s.lnd.Signer, + ) +} + // GetLiquidityParams gets our current liquidity manager's parameters. func (s *swapClientServer) GetLiquidityParams(_ context.Context, _ *looprpc.GetLiquidityParamsRequest) (*looprpc.LiquidityParameters, diff --git a/loopd/sweep_htlc.go b/loopd/sweep_htlc.go new file mode 100644 index 00000000..c613b5d7 --- /dev/null +++ b/loopd/sweep_htlc.go @@ -0,0 +1,402 @@ +package loopd + +import ( + "bytes" + "context" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/labels" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/swap" + "github.com/lightninglabs/loop/sweep" + "github.com/lightninglabs/loop/utils" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/input" + "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" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// loopOutStore abstracts the minimal store API needed to look up loop-out +// swaps. +type loopOutStore interface { + // FetchLoopOutSwaps returns all loop-out swaps currently in the store. + FetchLoopOutSwaps(ctx context.Context) ([]*loopdb.LoopOut, error) +} + +// htlcChainNotifier defines the minimal notifier API to watch for a tx +// confirmation. +type htlcChainNotifier interface { + RegisterConfirmationsNtfn(ctx context.Context, txid *chainhash.Hash, + pkScript []byte, numConfs, heightHint int32, + opts ...lndclient.NotifierOption) ( + chan *chainntnfs.TxConfirmation, chan error, error) +} + +// htlcWallet abstracts the wallet calls used for sweeping. +type htlcWallet interface { + // NextAddr derives the next address from the given account and type. + NextAddr(ctx context.Context, account string, + addrType walletrpc.AddressType, + change bool) (btcutil.Address, error) + + // PublishTransaction broadcasts the transaction with the given label. + PublishTransaction(ctx context.Context, tx *wire.MsgTx, + label string) error + + // MinRelayFee returns the current minimum relay fee in sat/kw. + MinRelayFee(ctx context.Context) (chainfee.SatPerKWeight, error) +} + +// htlcSigner signs the success path spend. +type htlcSigner interface { + SignOutputRaw(ctx context.Context, tx *wire.MsgTx, + signDescriptors []*lndclient.SignDescriptor, + prevOutputs []*wire.TxOut) ([][]byte, error) +} + +// sweepHtlc spends a Loop HTLC output using the success path and a known +// preimage. +func sweepHtlc(ctx context.Context, req *looprpc.SweepHtlcRequest, + chainParams *chaincfg.Params, store loopOutStore, + notifier htlcChainNotifier, wallet htlcWallet, + signer htlcSigner) (*looprpc.SweepHtlcResponse, error) { + + // Make sure that the request has all required inputs. + if req.Outpoint == "" { + return nil, status.Error(codes.InvalidArgument, + "outpoint required") + } + if req.HtlcAddress == "" { + return nil, status.Error(codes.InvalidArgument, + "htlc_address required") + } + if req.SatPerVbyte == 0 { + return nil, status.Error(codes.InvalidArgument, + "sat_per_vbyte required") + } + + // Parse the inputs. + htlcAddr, err := btcutil.DecodeAddress( + req.HtlcAddress, chainParams, + ) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, + "invalid htlc_address: %v", err) + } + + htlcPkScript, err := txscript.PayToAddrScript(htlcAddr) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, + "invalid htlc_address script: %v", err) + } + + htlcOutpoint, err := wire.NewOutPointFromString(req.Outpoint) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + // Destination address: honor a provided override or derive a fresh + // wallet address from the default account. + var sweepAddr btcutil.Address + if req.DestAddress != "" { + sweepAddr, err = btcutil.DecodeAddress( + req.DestAddress, chainParams, + ) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, + "invalid dest_address: %v", err) + } + } else { + sweepAddr, err = wallet.NextAddr( + ctx, lnwallet.DefaultAccountName, + walletrpc.AddressType_TAPROOT_PUBKEY, + false, + ) + if err != nil { + return nil, status.Errorf(codes.Internal, + "derive sweep address: %v", err) + } + infof("sweephtlc: generated new destination address: %v", + sweepAddr.EncodeAddress()) + } + + sweepPkScript, err := txscript.PayToAddrScript(sweepAddr) + if err != nil { + return nil, err + } + + infof("sweephtlc: start sweep for %v -> %v", req.Outpoint, + sweepAddr.EncodeAddress()) + + // Locate the loop-out swap whose HTLC script matches the outpoint so + // we can obtain keys and the stored preimage. + swaps, err := store.FetchLoopOutSwaps(ctx) + if err != nil { + return nil, err + } + + var ( + targetSwap *loopdb.LoopOut + targetHtlc *swap.Htlc + ) + + for _, swp := range swaps { + htlc, htlcErr := utils.GetHtlc( + swp.Hash, &swp.Contract.SwapContract, + chainParams, + ) + if htlcErr != nil { + return nil, htlcErr + } + + if bytes.Equal(htlc.PkScript, htlcPkScript) { + targetSwap = swp + targetHtlc = htlc + break + } + } + + if targetSwap == nil || targetHtlc == nil { + return nil, status.Error(codes.NotFound, + "no matching swap HTLC found") + } + + infof("sweephtlc: matched swap %v at height hint %v", + targetSwap.Hash, targetSwap.Contract.InitiationHeight) + + if targetSwap.Contract.InitiationHeight <= 0 { + return nil, status.Errorf(codes.InvalidArgument, + "invalid initiation height %d", + targetSwap.Contract.InitiationHeight) + } + + // Wait for a confirmation so we can read the full transaction even if + // it's not in our wallet. + infof("sweephtlc: registering conf ntfn for %v hint=%v", + req.Outpoint, targetSwap.Contract.InitiationHeight) + confChan, errChan, err := notifier.RegisterConfirmationsNtfn( + ctx, &htlcOutpoint.Hash, htlcPkScript, 1, + targetSwap.Contract.InitiationHeight, + ) + if err != nil { + return nil, status.Errorf(codes.Internal, + "register conf ntfn: %v", err) + } + + var ( + htlcTxOut *wire.TxOut + fundingTx *wire.MsgTx + ) + + infof("sweephtlc: waiting for confirmation of %v", req.Outpoint) + select { + case conf := <-confChan: + fundingTx = conf.Tx + infof("sweephtlc: funding confirmed at height %v", + conf.BlockHeight) + + case ntfnErr := <-errChan: + infof("sweephtlc: conf ntfn error for %v: %v", + req.Outpoint, ntfnErr) + + return nil, status.Errorf(codes.Internal, + "conf ntfn: %v", ntfnErr) + + case <-ctx.Done(): + infof("sweephtlc: context done waiting for %v: %v", + req.Outpoint, ctx.Err()) + + return nil, status.Errorf(codes.DeadlineExceeded, + "waiting for transaction details") + } + + if int(htlcOutpoint.Index) >= len(fundingTx.TxOut) { + return nil, status.Errorf(codes.InvalidArgument, + "vout %d out of range", htlcOutpoint.Index) + } + + htlcTxOut = fundingTx.TxOut[htlcOutpoint.Index] + + if !bytes.Equal(htlcTxOut.PkScript, htlcPkScript) { + return nil, status.Error(codes.InvalidArgument, + "outpoint script does not match HTLC address") + } + + infof("sweephtlc: swap hash validated for %v", req.Outpoint) + + // Pick a preimage: prefer the caller-provided override, otherwise use + // the swap's stored preimage. + var preimage lntypes.Preimage + if len(req.Preimage) > 0 { + preimage, err = lntypes.MakePreimage(req.Preimage) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, + "invalid preimage: %v", err) + } + } else { + preimage = targetSwap.Contract.Preimage + } + + if preimage.Hash() != targetHtlc.Hash { + return nil, status.Error(codes.InvalidArgument, + "preimage does not match HTLC hash") + } + + infof("sweephtlc: sweeping to %v with feerate %v sat/vbyte", + sweepAddr.EncodeAddress(), req.SatPerVbyte) + + // Estimate fee for the success-path spend weight. + var estimator input.TxWeightEstimator + err = targetHtlc.AddSuccessToEstimator(&estimator) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, + "failed to estimate tx input weight: %v", err) + } + err = sweep.AddOutputEstimate(&estimator, sweepAddr) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, + "failed to estimate tx output weight: %v", err) + } + + // Convert the requested fee rate to sat/kw for fee computation. + feeRate := chainfee.SatPerVByte(req.SatPerVbyte).FeePerKWeight() + fee := feeRate.FeeForWeightRoundUp(estimator.Weight()) + + // Make sure the fee is fine. + htlcValue := btcutil.Amount(htlcTxOut.Value) + if htlcValue <= fee { + return nil, status.Error(codes.InvalidArgument, + "fee exceeds HTLC value") + } + + minRelayFeeRate, err := wallet.MinRelayFee(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, + "min relay fee: %v", err) + } + + fee, clamped, err := utils.ClampSweepFee( + fee, htlcValue, utils.MaxFeeToAmountRatio, minRelayFeeRate, + estimator.Weight(), + ) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, + "fee too low for relay after clamp: %v", err) + } + if clamped { + return nil, status.Errorf(codes.InvalidArgument, + "fee exceeds %.0f%% of HTLC value; lower sat_per_vbyte", + utils.MaxFeeToAmountRatio*100, + ) + } + + // Build the sweep transaction spending the HTLC via the success path. + sweepTx := wire.NewMsgTx(2) + sweepTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: *htlcOutpoint, + Sequence: targetHtlc.SuccessSequence(), + }) + sweepTx.AddTxOut(&wire.TxOut{ + PkScript: sweepPkScript, + Value: int64(htlcValue - fee), + }) + + infof("sweephtlc: signing sweep spending %v", req.Outpoint) + + prevOut := &wire.TxOut{ + Value: int64(htlcValue), + PkScript: targetHtlc.PkScript, + } + signDesc := lndclient.SignDescriptor{ + WitnessScript: targetHtlc.SuccessScript(), + Output: prevOut, + HashType: targetHtlc.SigHash(), + InputIndex: 0, + KeyDesc: keychain.KeyDescriptor{ + KeyLocator: targetSwap.Contract.HtlcKeys. + ClientScriptKeyLocator, + }, + } + if targetHtlc.Version == swap.HtlcV3 { + signDesc.SignMethod = input.TaprootScriptSpendSignMethod + } + + // Sign the HTLC spend. + rawSigs, err := signer.SignOutputRaw( + ctx, sweepTx, []*lndclient.SignDescriptor{&signDesc}, + []*wire.TxOut{prevOut}, + ) + if err != nil { + return nil, err + } + sig := rawSigs[0] + + infof("sweephtlc: witness assembled, tx size=%d vbytes", + sweepTx.SerializeSize()) + + // Assemble the success witness using the signature and preimage. + witness, err := targetHtlc.GenSuccessWitness(sig, preimage) + if err != nil { + return nil, err + } + sweepTx.TxIn[0].Witness = witness + + var rawBuf bytes.Buffer + err = sweepTx.Serialize(&rawBuf) + if err != nil { + return nil, err + } + rawTx := rawBuf.Bytes() + + // Optionally publish immediately if requested; otherwise caller can + // broadcast the signed tx themselves. + if req.Publish { + err = wallet.PublishTransaction( + ctx, sweepTx, + labels.LoopOutSweepSuccess(targetSwap.Hash.String()), + ) + if err != nil { + errorf("sweephtlc: publish failed for %v: %v", + req.Outpoint, err) + + return &looprpc.SweepHtlcResponse{ + SweepTx: rawTx, + FeeSats: uint64(fee), + Publish: &looprpc.SweepHtlcResponse_Failed{ + Failed: &looprpc.PublishFailed{ + Error: err.Error(), + }, + }, + }, nil + } + + infof("sweephtlc: published sweep %v", sweepTx.TxHash()) + } + + resp := &looprpc.SweepHtlcResponse{ + SweepTx: rawTx, + FeeSats: uint64(fee), + } + if req.Publish { + resp.Publish = &looprpc.SweepHtlcResponse_Published{ + Published: &looprpc.PublishSucceeded{}, + } + } else { + resp.Publish = &looprpc.SweepHtlcResponse_NotRequested{ + NotRequested: &looprpc.PublishNotRequested{}, + } + } + + return resp, nil +} diff --git a/loopd/sweep_htlc_test.go b/loopd/sweep_htlc_test.go new file mode 100644 index 00000000..3829066e --- /dev/null +++ b/loopd/sweep_htlc_test.go @@ -0,0 +1,581 @@ +package loopd + +import ( + "bytes" + "context" + "errors" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "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/lntypes" + "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: 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", + }, + }, + { + name: "success low fee, publish", + amount: 100_000, + satPerVByte: 10, + expectRegister: true, + 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", + "sweephtlc: published sweep %v", + }, + publish: true, + }, + { + name: "publish failure reported", + amount: 100_000, + satPerVByte: 10, + expectRegister: true, + 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", + "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: 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", + }, + }, + { + 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: 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", + }, + }, + { + 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{ + "sweephtlc: generated new destination address: %v", + "sweephtlc: start sweep for %v -> %v", + }, + }, + { + 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: generated new destination address: %v", + "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: 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: 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: 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", + }, + }, + { + name: "fee exceeds htlc value", + amount: 100_000, + satPerVByte: 2_000_000, + expectErrMsg: "fee exceeds HTLC value", + expectRegister: true, + 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", + }, + }, + { + 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: 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", + }, + }, +} + +// 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 := btcutil.NewAddressWitnessPubKeyHash( + make([]byte, 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) + + 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 ...interface{}) { + f.record(format) + f.Logger.Tracef(format, params...) +} + +// Debugf logs a debug message and records its format. +func (f *formatLogger) Debugf(format string, params ...interface{}) { + f.record(format) + f.Logger.Debugf(format, params...) +} + +// Infof logs an info message and records its format. +func (f *formatLogger) Infof(format string, params ...interface{}) { + f.record(format) + f.Logger.Infof(format, params...) +} + +// Warnf logs a warning and records its format. +func (f *formatLogger) Warnf(format string, params ...interface{}) { + f.record(format) + f.Logger.Warnf(format, params...) +} + +// Errorf logs an error and records its format. +func (f *formatLogger) Errorf(format string, params ...interface{}) { + f.record(format) + f.Logger.Errorf(format, params...) +} + +// Criticalf logs a critical message and records its format. +func (f *formatLogger) Criticalf(format string, params ...interface{}) { + f.record(format) + f.Logger.Criticalf(format, params...) +}