mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Carry the accepted quote into each request, persist it, and reject invoices above that limit. Preserve compatibility for requests that omit the cap while distinguishing an explicit zero.
599 lines
15 KiB
Go
599 lines
15 KiB
Go
package instantout
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"time"
|
|
|
|
"github.com/btcsuite/btcd/btcec/v2"
|
|
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
|
|
"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/fsm"
|
|
"github.com/lightninglabs/loop/instantout/reservation"
|
|
"github.com/lightninglabs/loop/loopdb"
|
|
"github.com/lightninglabs/loop/swap"
|
|
"github.com/lightninglabs/loop/utils"
|
|
"github.com/lightningnetwork/lnd/input"
|
|
"github.com/lightningnetwork/lnd/keychain"
|
|
"github.com/lightningnetwork/lnd/lntypes"
|
|
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
|
|
)
|
|
|
|
const muSig2CleanupTimeout = 5 * time.Second
|
|
|
|
// InstantOut holds the necessary information to execute an instant out swap.
|
|
type InstantOut struct {
|
|
// SwapHash is the hash of the swap.
|
|
SwapHash lntypes.Hash
|
|
|
|
// swapPreimage is the preimage that is used for the swap.
|
|
swapPreimage lntypes.Preimage
|
|
|
|
// State is the current state of the swap.
|
|
State fsm.StateType
|
|
|
|
// CltvExpiry is the expiry of the swap.
|
|
CltvExpiry int32
|
|
|
|
// outgoingChanSet optionally specifies the short channel ids of the
|
|
// channels that may be used to loop out.
|
|
outgoingChanSet loopdb.ChannelSet
|
|
|
|
// Reservations are the Reservations that are used in as inputs for the
|
|
// instant out swap.
|
|
Reservations []*reservation.Reservation
|
|
|
|
// protocolVersion is the version of the protocol that is used for the
|
|
// swap.
|
|
protocolVersion ProtocolVersion
|
|
|
|
// initiationHeight is the height at which the swap was initiated.
|
|
initiationHeight int32
|
|
|
|
// Value is the amount that is swapped.
|
|
Value btcutil.Amount
|
|
|
|
// MaxSwapFee is the maximum off-chain swap fee accepted by the client.
|
|
MaxSwapFee btcutil.Amount
|
|
|
|
// keyLocator is the key locator that is used for the swap.
|
|
keyLocator keychain.KeyLocator
|
|
|
|
// clientPubkey is the pubkey of the client that is used for the swap.
|
|
clientPubkey *btcec.PublicKey
|
|
|
|
// serverPubkey is the pubkey of the server that is used for the swap.
|
|
serverPubkey *btcec.PublicKey
|
|
|
|
// swapInvoice is the invoice that is used for the swap.
|
|
swapInvoice string
|
|
|
|
// htlcFeeRate is the fee rate that is used for the htlc transaction.
|
|
htlcFeeRate chainfee.SatPerKWeight
|
|
|
|
// sweepAddress is the address that is used to sweep the funds to.
|
|
sweepAddress btcutil.Address
|
|
|
|
// finalizedHtlcTx is the finalized htlc transaction that is used in the
|
|
// non-cooperative path for the instant out swap.
|
|
finalizedHtlcTx *wire.MsgTx
|
|
|
|
// SweepTxHash is the hash of the sweep transaction.
|
|
SweepTxHash *chainhash.Hash
|
|
|
|
// FinalizedSweeplessSweepTx is the transaction that is used to sweep
|
|
// the funds in the cooperative path.
|
|
FinalizedSweeplessSweepTx *wire.MsgTx
|
|
|
|
// sweepConfirmationHeight is the height at which the sweep
|
|
// transaction was confirmed.
|
|
sweepConfirmationHeight uint32
|
|
}
|
|
|
|
// getHtlc returns the swap.htlc for the instant out.
|
|
func (i *InstantOut) getHtlc(chainParams *chaincfg.Params) (*swap.Htlc, error) {
|
|
return swap.NewHtlcV2(
|
|
i.CltvExpiry, pubkeyTo33ByteSlice(i.serverPubkey),
|
|
pubkeyTo33ByteSlice(i.clientPubkey), i.SwapHash, chainParams,
|
|
)
|
|
}
|
|
|
|
// createMusig2Session creates a musig2 session for the instant out.
|
|
func (i *InstantOut) createMusig2Session(ctx context.Context,
|
|
signer lndclient.SignerClient) ([]*input.MuSig2SessionInfo,
|
|
[][]byte, error) {
|
|
|
|
// Create the htlc musig2 context.
|
|
musig2Sessions := make([]*input.MuSig2SessionInfo, len(i.Reservations))
|
|
clientNonces := make([][]byte, len(i.Reservations))
|
|
|
|
// Create the sessions and nonces from the reservations.
|
|
for idx, reservation := range i.Reservations {
|
|
session, err := reservation.Musig2CreateSession(ctx, signer)
|
|
if err != nil {
|
|
cleanupErr := cleanupMuSig2Sessions(
|
|
ctx, signer, musig2Sessions[:idx],
|
|
)
|
|
return nil, nil, errors.Join(err, cleanupErr)
|
|
}
|
|
|
|
musig2Sessions[idx] = session
|
|
clientNonces[idx] = session.PublicNonce[:]
|
|
}
|
|
|
|
return musig2Sessions, clientNonces, nil
|
|
}
|
|
|
|
// cleanupMuSig2Sessions removes completed or abandoned MuSig2 sessions from
|
|
// lnd. Cleanup uses a bounded context that survives cancellation of the swap
|
|
// action that created the sessions.
|
|
func cleanupMuSig2Sessions(ctx context.Context, signer lndclient.SignerClient,
|
|
sessions []*input.MuSig2SessionInfo) error {
|
|
|
|
cleanupCtx, cancel := context.WithTimeout(
|
|
context.WithoutCancel(ctx), muSig2CleanupTimeout,
|
|
)
|
|
defer cancel()
|
|
|
|
var cleanupErr error
|
|
for _, session := range sessions {
|
|
if session == nil {
|
|
continue
|
|
}
|
|
|
|
err := signer.MuSig2Cleanup(cleanupCtx, session.SessionID)
|
|
if err != nil {
|
|
cleanupErr = errors.Join(cleanupErr, err)
|
|
}
|
|
}
|
|
|
|
return cleanupErr
|
|
}
|
|
|
|
// getInputReservations returns the input reservations for the instant out.
|
|
func (i *InstantOut) getInputReservations() (InputReservations, error) {
|
|
if len(i.Reservations) == 0 {
|
|
return nil, errors.New("no reservations")
|
|
}
|
|
|
|
inputs := make(InputReservations, len(i.Reservations))
|
|
for idx, reservation := range i.Reservations {
|
|
pkScript, err := reservation.GetPkScript()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
inputs[idx] = InputReservation{
|
|
Outpoint: *reservation.Outpoint,
|
|
Value: reservation.Value,
|
|
PkScript: pkScript,
|
|
}
|
|
}
|
|
|
|
return inputs, nil
|
|
}
|
|
|
|
// createHtlcTransaction creates the htlc transaction for the instant out.
|
|
func (i *InstantOut) createHtlcTransaction(network *chaincfg.Params,
|
|
minRelayFeeRate chainfee.SatPerKWeight) (*wire.MsgTx, error) {
|
|
|
|
if network == nil {
|
|
return nil, errors.New("no network provided")
|
|
}
|
|
|
|
inputReservations, err := i.getInputReservations()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// First Create the tx.
|
|
msgTx := wire.NewMsgTx(2)
|
|
|
|
// add the reservation inputs
|
|
for _, reservation := range inputReservations {
|
|
msgTx.AddTxIn(&wire.TxIn{
|
|
PreviousOutPoint: reservation.Outpoint,
|
|
})
|
|
}
|
|
|
|
// Estimate the fee
|
|
weight := htlcWeight(len(inputReservations))
|
|
fee := i.htlcFeeRate.FeeForWeight(weight)
|
|
|
|
// We cap the fee at 20% of the deposit value.
|
|
_, clamped, err := utils.ClampSweepFee(
|
|
fee, i.Value, utils.MaxFeeToAmountRatio, minRelayFeeRate,
|
|
weight,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if clamped {
|
|
return nil, errors.New("fee is higher than 20% of " +
|
|
"sweep value")
|
|
}
|
|
|
|
htlc, err := i.getHtlc(network)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create the sweep output
|
|
sweepOutput := &wire.TxOut{
|
|
Value: int64(i.Value) - int64(fee),
|
|
PkScript: htlc.PkScript,
|
|
}
|
|
|
|
msgTx.AddTxOut(sweepOutput)
|
|
|
|
return msgTx, nil
|
|
}
|
|
|
|
// createSweeplessSweepTx creates the sweepless sweep transaction for the
|
|
// instant out.
|
|
func (i *InstantOut) createSweeplessSweepTx(feerate,
|
|
minRelayFeeRate chainfee.SatPerKWeight) (*wire.MsgTx, error) {
|
|
|
|
inputReservations, err := i.getInputReservations()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// First Create the tx.
|
|
msgTx := wire.NewMsgTx(2)
|
|
|
|
// add the reservation inputs
|
|
for _, reservation := range inputReservations {
|
|
msgTx.AddTxIn(&wire.TxIn{
|
|
PreviousOutPoint: reservation.Outpoint,
|
|
})
|
|
}
|
|
|
|
// Estimate the fee
|
|
weight := sweeplessSweepWeight(len(inputReservations))
|
|
fee := feerate.FeeForWeight(weight)
|
|
_, clamped, err := utils.ClampSweepFee(
|
|
fee, i.Value, utils.MaxFeeToAmountRatio, minRelayFeeRate,
|
|
weight,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if clamped {
|
|
return nil, errors.New("fee is higher than 20% of " +
|
|
"sweep value")
|
|
}
|
|
|
|
pkscript, err := txscript.PayToAddrScript(i.sweepAddress)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create the sweep output
|
|
sweepOutput := &wire.TxOut{
|
|
Value: int64(i.Value) - int64(fee),
|
|
PkScript: pkscript,
|
|
}
|
|
|
|
msgTx.AddTxOut(sweepOutput)
|
|
|
|
return msgTx, nil
|
|
}
|
|
|
|
// signMusig2Tx adds the server nonces to the musig2 sessions and signs the
|
|
// transaction.
|
|
func (i *InstantOut) signMusig2Tx(ctx context.Context,
|
|
signer lndclient.SignerClient, tx *wire.MsgTx,
|
|
musig2sessions []*input.MuSig2SessionInfo,
|
|
counterPartyNonces [][66]byte) ([][]byte, error) {
|
|
|
|
inputs, err := i.getInputReservations()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tx == nil {
|
|
return nil, errors.New("transaction is nil")
|
|
}
|
|
if len(tx.TxIn) != len(inputs) {
|
|
return nil, fmt.Errorf("invalid number of transaction inputs: "+
|
|
"expected %d, got %d", len(inputs), len(tx.TxIn))
|
|
}
|
|
if len(musig2sessions) != len(inputs) {
|
|
return nil, fmt.Errorf("invalid number of MuSig2 sessions: "+
|
|
"expected %d, got %d", len(inputs), len(musig2sessions))
|
|
}
|
|
if len(counterPartyNonces) != len(inputs) {
|
|
return nil, fmt.Errorf("invalid number of server nonces: "+
|
|
"expected %d, got %d", len(inputs), len(counterPartyNonces))
|
|
}
|
|
|
|
prevOutFetcher := inputs.GetPrevoutFetcher()
|
|
sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher)
|
|
sigs := make([][]byte, len(inputs))
|
|
|
|
for idx, reservation := range inputs {
|
|
if musig2sessions[idx] == nil {
|
|
return nil, fmt.Errorf("MuSig2 session %d is nil", idx)
|
|
}
|
|
|
|
if !reflect.DeepEqual(tx.TxIn[idx].PreviousOutPoint,
|
|
reservation.Outpoint) {
|
|
|
|
return nil, fmt.Errorf("tx input does not match " +
|
|
"reservation")
|
|
}
|
|
|
|
taprootSigHash, err := txscript.CalcTaprootSignatureHash(
|
|
sigHashes, txscript.SigHashDefault,
|
|
tx, idx, prevOutFetcher,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var digest [32]byte
|
|
copy(digest[:], taprootSigHash)
|
|
|
|
// Register the server's nonce before attempting to create our
|
|
// partial signature.
|
|
haveAllNonces, err := signer.MuSig2RegisterNonces(
|
|
ctx, musig2sessions[idx].SessionID,
|
|
[][musig2.PubNonceSize]byte{counterPartyNonces[idx]},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Sanity check that we have all the nonces.
|
|
if !haveAllNonces {
|
|
return nil, fmt.Errorf("invalid MuSig2 session: " +
|
|
"nonces missing")
|
|
}
|
|
|
|
// Since our MuSig2 session has all nonces, we can now create
|
|
// the local partial signature by signing the sig hash.
|
|
sig, err := signer.MuSig2Sign(
|
|
ctx, musig2sessions[idx].SessionID, digest, false,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sigs[idx] = sig
|
|
}
|
|
|
|
return sigs, nil
|
|
}
|
|
|
|
// finalizeMusig2Transaction creates the finalized transactions for either
|
|
// the htlc or the cooperative close.
|
|
func (i *InstantOut) finalizeMusig2Transaction(ctx context.Context,
|
|
signer lndclient.SignerClient,
|
|
musig2Sessions []*input.MuSig2SessionInfo,
|
|
tx *wire.MsgTx, serverSigs [][]byte) (*wire.MsgTx, error) {
|
|
|
|
inputs, err := i.getInputReservations()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tx == nil {
|
|
return nil, errors.New("transaction is nil")
|
|
}
|
|
if len(tx.TxIn) != len(inputs) {
|
|
return nil, fmt.Errorf("invalid number of transaction inputs: "+
|
|
"expected %d, got %d", len(inputs), len(tx.TxIn))
|
|
}
|
|
if len(musig2Sessions) != len(inputs) {
|
|
return nil, fmt.Errorf("invalid number of MuSig2 sessions: "+
|
|
"expected %d, got %d", len(inputs), len(musig2Sessions))
|
|
}
|
|
if len(serverSigs) != len(inputs) {
|
|
return nil, fmt.Errorf("invalid number of server signatures: "+
|
|
"expected %d, got %d", len(inputs), len(serverSigs))
|
|
}
|
|
|
|
prevOutFetcher := inputs.GetPrevoutFetcher()
|
|
sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher)
|
|
|
|
for idx := range inputs {
|
|
if musig2Sessions[idx] == nil {
|
|
return nil, fmt.Errorf("MuSig2 session %d is nil", idx)
|
|
}
|
|
|
|
haveAllSigs, finalSig, err := signer.MuSig2CombineSig(
|
|
ctx, musig2Sessions[idx].SessionID,
|
|
[][]byte{serverSigs[idx]},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !haveAllSigs {
|
|
return nil, fmt.Errorf("missing sigs")
|
|
}
|
|
|
|
// lnd removes a MuSig2 session automatically once all signatures
|
|
// have been combined. Clear the local entry so the caller's deferred
|
|
// cleanup only targets sessions abandoned on an error path.
|
|
musig2Sessions[idx] = nil
|
|
|
|
tx.TxIn[idx].Witness = wire.TxWitness{finalSig}
|
|
|
|
vm, err := txscript.NewEngine(
|
|
inputs[idx].PkScript, tx, idx,
|
|
txscript.StandardVerifyFlags, nil, sigHashes,
|
|
int64(inputs[idx].Value), prevOutFetcher,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to verify final MuSig2 "+
|
|
"signature for input %d: %w", idx, err)
|
|
}
|
|
if err := vm.Execute(); err != nil {
|
|
return nil, fmt.Errorf("invalid final MuSig2 signature "+
|
|
"for input %d: %w", idx, err)
|
|
}
|
|
}
|
|
|
|
return tx, nil
|
|
}
|
|
|
|
// generateHtlcSweepTx creates the htlc sweep transaction for the instant out.
|
|
func (i *InstantOut) generateHtlcSweepTx(ctx context.Context,
|
|
signer lndclient.SignerClient, feeRate chainfee.SatPerKWeight,
|
|
network *chaincfg.Params, blockheight uint32) (
|
|
*wire.MsgTx, error) {
|
|
|
|
if network == nil {
|
|
return nil, errors.New("no network provided")
|
|
}
|
|
|
|
if i.finalizedHtlcTx == nil {
|
|
return nil, errors.New("no finalized htlc tx")
|
|
}
|
|
|
|
htlc, err := i.getHtlc(network)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Create the sweep transaction.
|
|
sweepTx := wire.NewMsgTx(2)
|
|
sweepTx.LockTime = blockheight
|
|
|
|
var weightEstimator input.TxWeightEstimator
|
|
weightEstimator.AddP2TROutput()
|
|
|
|
err = htlc.AddSuccessToEstimator(&weightEstimator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
htlcHash := i.finalizedHtlcTx.TxHash()
|
|
|
|
// Add the htlc input.
|
|
sweepTx.AddTxIn(&wire.TxIn{
|
|
PreviousOutPoint: wire.OutPoint{
|
|
Hash: htlcHash,
|
|
Index: 0,
|
|
},
|
|
SignatureScript: htlc.SigScript,
|
|
Sequence: htlc.SuccessSequence(),
|
|
})
|
|
|
|
// Add the sweep output.
|
|
sweepPkScript, err := txscript.PayToAddrScript(i.sweepAddress)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fee := feeRate.FeeForWeight(weightEstimator.Weight())
|
|
|
|
htlcOutValue := i.finalizedHtlcTx.TxOut[0].Value
|
|
output := &wire.TxOut{
|
|
Value: htlcOutValue - int64(fee),
|
|
PkScript: sweepPkScript,
|
|
}
|
|
|
|
sweepTx.AddTxOut(output)
|
|
|
|
signDesc := lndclient.SignDescriptor{
|
|
WitnessScript: htlc.SuccessScript(),
|
|
Output: &wire.TxOut{
|
|
Value: htlcOutValue,
|
|
PkScript: htlc.PkScript,
|
|
},
|
|
HashType: htlc.SigHash(),
|
|
InputIndex: 0,
|
|
KeyDesc: keychain.KeyDescriptor{
|
|
KeyLocator: i.keyLocator,
|
|
},
|
|
}
|
|
|
|
rawSigs, err := signer.SignOutputRaw(
|
|
ctx, sweepTx, []*lndclient.SignDescriptor{&signDesc}, nil,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sign output error: %v", err)
|
|
}
|
|
sig := rawSigs[0]
|
|
|
|
// Add witness stack to the tx input.
|
|
sweepTx.TxIn[0].Witness, err = htlc.GenSuccessWitness(
|
|
sig, i.swapPreimage,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return sweepTx, nil
|
|
}
|
|
|
|
// htlcWeight returns the weight for the htlc transaction.
|
|
func htlcWeight(numInputs int) lntypes.WeightUnit {
|
|
var weightEstimator input.TxWeightEstimator
|
|
for range numInputs {
|
|
weightEstimator.AddTaprootKeySpendInput(
|
|
txscript.SigHashDefault,
|
|
)
|
|
}
|
|
|
|
weightEstimator.AddP2WSHOutput()
|
|
|
|
return weightEstimator.Weight()
|
|
}
|
|
|
|
// sweeplessSweepWeight returns the weight for the sweepless sweep transaction.
|
|
func sweeplessSweepWeight(numInputs int) lntypes.WeightUnit {
|
|
var weightEstimator input.TxWeightEstimator
|
|
for range numInputs {
|
|
weightEstimator.AddTaprootKeySpendInput(
|
|
txscript.SigHashDefault,
|
|
)
|
|
}
|
|
|
|
weightEstimator.AddP2TROutput()
|
|
|
|
return weightEstimator.Weight()
|
|
}
|
|
|
|
// pubkeyTo33ByteSlice converts a pubkey to a 33 byte slice.
|
|
func pubkeyTo33ByteSlice(pubkey *btcec.PublicKey) [33]byte {
|
|
var pubkeyBytes [33]byte
|
|
copy(pubkeyBytes[:], pubkey.SerializeCompressed())
|
|
|
|
return pubkeyBytes
|
|
}
|
|
|
|
// toNonces converts a byte slice to a 66 byte slice.
|
|
func toNonces(nonces [][]byte) ([][66]byte, error) {
|
|
res := make([][66]byte, 0, len(nonces))
|
|
for _, n := range nonces {
|
|
nonce, err := byteSliceTo66ByteSlice(n)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res = append(res, nonce)
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// byteSliceTo66ByteSlice converts a byte slice to a 66 byte slice.
|
|
func byteSliceTo66ByteSlice(b []byte) ([66]byte, error) {
|
|
if len(b) != 66 {
|
|
return [66]byte{}, fmt.Errorf("invalid byte slice length")
|
|
}
|
|
|
|
var res [66]byte
|
|
copy(res[:], b)
|
|
|
|
return res, nil
|
|
}
|