diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go new file mode 100644 index 00000000..4318c4b6 --- /dev/null +++ b/staticaddr/loopin/loopin.go @@ -0,0 +1,590 @@ +package loopin + +import ( + "context" + "errors" + "fmt" + "reflect" + "sync" + "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/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/swap" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/zpay32" +) + +// StaticAddressLoopIn represents the in-memory loop-in information. +type StaticAddressLoopIn struct { + // SwapHash is the hashed preimage of the swap invoice. It represents + // the primary identifier of the swap. + SwapHash lntypes.Hash + + // SwapPreimage is the preimage that is used for the swap. + SwapPreimage lntypes.Preimage + + // HtlcCltvExpiry is the expiry of the swap. + HtlcCltvExpiry int32 + + // MaxSwapFee is the swap fee in sats that the user accepted when + // initiating the swap. It is the upper limit for the QuotedSwapFee. + MaxSwapFee btcutil.Amount + + // InitiationHeight is the height at which the swap was initiated. + InitiationHeight uint32 + + // InitiationTime is the time at which the swap was initiated. + InitiationTime time.Time + + // ProtocolVersion is the protocol version of the static address. + ProtocolVersion version.AddressProtocolVersion + + // Label contains an optional label for the swap. + Label string + + // Htlc key fields. + + // 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 + + // HtlcKeyLocator is the locator of the server's htlc key. + HtlcKeyLocator keychain.KeyLocator + + // Static address loop-in fields. + + // SwapInvoice is the invoice that needs to be paid by the server to + // complete the loop-in swap. + SwapInvoice string + + // LastHop is an optional parameter that specifies the last hop to be + // used for a loop in swap. + LastHop []byte + + // The swap payment timeout allows the user to specify an upper limit + // for the amount of time the server is allowed to take to fulfill the + // off-chain swap payment. If the timeout is reached the swap will be + // aborted on the server side and the client can retry the swap with + // different parameters. + PaymentTimeoutSeconds uint32 + + // QuotedSwapFee is the swap fee in sats that the server returned in the + // swap quote. + QuotedSwapFee btcutil.Amount + + // The outpoints in the format txid:vout that are part of the loop-in + // swap. + DepositOutpoints []string + + // state is the current state of the swap. + state fsm.StateType + + // Non-persistent convenience fields. + + // Initiator is an optional identification string that will be appended + // to the user agent string sent to the server to give information about + // the usage of loop. This initiator part is meant for user interfaces + // to add their name to give the full picture of the binary used + // (loopd, lit) and the method used for triggering the swap + // (loop cli, autolooper, lit ui, other 3rd party ui). + Initiator string + + // Private indicates whether the destination node should be considered + // private. In which case, loop will generate hop hints to assist with + // probing and payment. + Private bool + + // Optional route hints to reach the destination through private + // channels. + RouteHints [][]zpay32.HopHint + + // Deposits are the deposits that are part of the loop-in swap. They + // implicitly carry the swap amount. + Deposits []*deposit.Deposit + + // AddressParams are the parameters of the address that is used for the + // swap. + AddressParams *address.Parameters + + // Address is the address script that is used for the swap. + Address *script.StaticAddress + + // HTLC fields. + + // HtlcTxFeeRate is the fee rate that is used for the htlc transaction. + HtlcTxFeeRate chainfee.SatPerKWeight + + // HtlcTxHighFeeRate is the fee rate that is used for the htlc + // transaction. + HtlcTxHighFeeRate chainfee.SatPerKWeight + + // HtlcTxExtremelyHighFeeRate is the fee rate that is used for the htlc + // transaction. + HtlcTxExtremelyHighFeeRate chainfee.SatPerKWeight + + // HtlcTimeoutSweepTxHash is the hash of the htlc timeout sweep tx. + HtlcTimeoutSweepTxHash *chainhash.Hash + + // HtlcTimeoutSweepAddress + HtlcTimeoutSweepAddress btcutil.Address + + mu sync.Mutex +} + +func (l *StaticAddressLoopIn) getHtlc(chainParams *chaincfg.Params) (*swap.Htlc, + error) { + + return swap.NewHtlcV2( + l.HtlcCltvExpiry, pubkeyTo33ByteSlice(l.ClientPubkey), + pubkeyTo33ByteSlice(l.ServerPubkey), l.SwapHash, chainParams, + ) +} + +// createMusig2Sessions creates a musig2 session for a number of deposits. +func (l *StaticAddressLoopIn) createMusig2Sessions(ctx context.Context, + signer lndclient.SignerClient) ([]*input.MuSig2SessionInfo, [][]byte, + error) { + + musig2Sessions := make([]*input.MuSig2SessionInfo, len(l.Deposits)) + clientNonces := make([][]byte, len(l.Deposits)) + + // Create the sessions and nonces from the deposits. + for i := 0; i < len(l.Deposits); i++ { + session, err := l.createMusig2Session(ctx, signer) + if err != nil { + return nil, nil, err + } + + musig2Sessions[i] = session + clientNonces[i] = session.PublicNonce[:] + } + + return musig2Sessions, clientNonces, nil +} + +// Musig2CreateSession creates a musig2 session for the deposit. +func (l *StaticAddressLoopIn) createMusig2Session(ctx context.Context, + signer lndclient.SignerClient) (*input.MuSig2SessionInfo, error) { + + signers := [][]byte{ + l.AddressParams.ClientPubkey.SerializeCompressed(), + l.AddressParams.ServerPubkey.SerializeCompressed(), + } + + expiryLeaf := l.Address.TimeoutLeaf + + rootHash := expiryLeaf.TapHash() + + return signer.MuSig2CreateSession( + ctx, input.MuSig2Version100RC2, &l.AddressParams.KeyLocator, + signers, lndclient.MuSig2TaprootTweakOpt(rootHash[:], false), + ) +} + +// createSweeplessSweepTx creates the sweepless sweep transaction that the +// server wishes to publish. It spends the deposit outpoints to a +// server-specified sweep address. +func (l *StaticAddressLoopIn) createSweeplessSweepTx(address btcutil.Address, + feeRate chainfee.SatPerKWeight) (*wire.MsgTx, error) { + // Create the tx. + msgTx := wire.NewMsgTx(2) + + // Add the deposit inputs to the transaction in the order the server + // signed them. + outpoints := l.Outpoints() + for _, outpoint := range outpoints { + msgTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: outpoint, + }) + } + + // Calculate tx fee for the server provided fee rate. + weight, err := sweeplessSweepWeight(len(outpoints), address) + if err != nil { + return nil, err + } + fee := feeRate.FeeForWeight(weight) + + pkscript, err := txscript.PayToAddrScript(address) + if err != nil { + return nil, err + } + + // Create the sweep output. + sweepOutput := &wire.TxOut{ + Value: int64(l.TotalDepositAmount()) - int64(fee), + PkScript: pkscript, + } + + msgTx.AddTxOut(sweepOutput) + + return msgTx, nil +} + +// sweeplessSweepWeight returns weight units for a sweepless sweep transaction +// with N taproot inputs and one sweep output. +func sweeplessSweepWeight(numInputs int, + sweepAddress btcutil.Address) (lntypes.WeightUnit, error) { + + var weightEstimator input.TxWeightEstimator + for i := 0; i < numInputs; i++ { + weightEstimator.AddTaprootKeySpendInput( + txscript.SigHashDefault, + ) + } + + // Get the weight of the sweep output. + switch sweepAddress.(type) { + case *btcutil.AddressWitnessPubKeyHash: + weightEstimator.AddP2WKHOutput() + + case *btcutil.AddressTaproot: + weightEstimator.AddP2TROutput() + + default: + return 0, fmt.Errorf("invalid sweep address type %T", + sweepAddress) + } + + return weightEstimator.Weight(), nil +} + +// signMusig2Tx adds the server nonces to the musig2 sessions and signs the +// transaction. +func (l *StaticAddressLoopIn) signMusig2Tx(ctx context.Context, + tx *wire.MsgTx, signer lndclient.SignerClient, + musig2sessions []*input.MuSig2SessionInfo, + counterPartyNonces [][musig2.PubNonceSize]byte) ([][]byte, error) { + + prevOuts, err := l.toPrevOuts(l.Deposits, l.AddressParams.PkScript) + if err != nil { + return nil, err + } + prevOutFetcher := txscript.NewMultiPrevOutFetcher(prevOuts) + + outpoints := l.Outpoints() + sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher) + sigs := make([][]byte, len(outpoints)) + + for idx, outpoint := range outpoints { + if !reflect.DeepEqual(tx.TxIn[idx].PreviousOutPoint, + outpoint) { + + return nil, fmt.Errorf("tx input does not match " + + "deposits") + } + + 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 +} + +// createHtlcTx creates the transaction that spend the deposit outpoints into +// a htlc outpoint. +func (l *StaticAddressLoopIn) createHtlcTx(chainParams *chaincfg.Params, + feeRate chainfee.SatPerKWeight, maxFeePercentage float64) (*wire.MsgTx, + error) { + + // First Create the tx. + msgTx := wire.NewMsgTx(2) + + // Add the deposit inputs to the transaction in the order the server + // signed them. + outpoints := l.Outpoints() + for _, outpoint := range outpoints { + msgTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: outpoint, + }) + } + + // Calculate htlc tx fee for server provided fee rate. + weight := l.htlcWeight() + fee := feeRate.FeeForWeight(weight) + + // Check if the server breaches our fee limits. + amt := float64(l.TotalDepositAmount()) + feeLimit := btcutil.Amount(amt * maxFeePercentage) + + if fee > feeLimit { + return nil, fmt.Errorf("htlc tx fee %v exceeds max fee %v", + fee, feeLimit) + } + + htlc, err := l.getHtlc(chainParams) + if err != nil { + return nil, err + } + + pkscript, err := txscript.PayToAddrScript(htlc.Address) + if err != nil { + return nil, err + } + + // Create the sweep output + sweepOutput := &wire.TxOut{ + Value: int64(l.TotalDepositAmount()) - int64(fee), + PkScript: pkscript, + } + + msgTx.AddTxOut(sweepOutput) + + return msgTx, nil +} + +// isHtlcTimedOut returns true if the htlc's cltv expiry is reached. In this +// case the client is able to broadcast a timeout refund transaction that can be +// included in blocks greater than height. +func (l *StaticAddressLoopIn) isHtlcTimedOut(height int32) bool { + return height >= l.HtlcCltvExpiry +} + +// htlcWeight returns the weight for the htlc transaction. +func (l *StaticAddressLoopIn) htlcWeight() lntypes.WeightUnit { + var weightEstimator input.TxWeightEstimator + for i := 0; i < len(l.Deposits); i++ { + weightEstimator.AddTaprootKeySpendInput( + txscript.SigHashDefault, + ) + } + + weightEstimator.AddP2WSHOutput() + + return weightEstimator.Weight() +} + +// createHtlcSweepTx creates the htlc sweep transaction for the timeout path of +// the loop-in htlc. +func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context, + signer lndclient.SignerClient, sweepAddress btcutil.Address, + feeRate chainfee.SatPerKWeight, network *chaincfg.Params, + blockHeight uint32, maxFeePercentage float64) (*wire.MsgTx, error) { + + if network == nil { + return nil, errors.New("no network provided") + } + + htlc, err := l.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 + } + + htlcTx, err := l.createHtlcTx( + network, l.HtlcTxFeeRate, maxFeePercentage, + ) + if err != nil { + return nil, err + } + + // Add the htlc input. + sweepTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: htlcTx.TxHash(), + Index: 0, + }, + SignatureScript: htlc.SigScript, + Sequence: htlc.SuccessSequence(), + }) + + // Add the sweep output. + sweepPkScript, err := txscript.PayToAddrScript(sweepAddress) + if err != nil { + return nil, err + } + + fee := feeRate.FeeForWeight(weightEstimator.Weight()) + + htlcOutValue := htlcTx.TxOut[0].Value + output := &wire.TxOut{ + Value: htlcOutValue - int64(fee), + PkScript: sweepPkScript, + } + + sweepTx.AddTxOut(output) + + signDesc := lndclient.SignDescriptor{ + WitnessScript: htlc.TimeoutScript(), + Output: &wire.TxOut{ + Value: htlcOutValue, + PkScript: htlc.PkScript, + }, + HashType: htlc.SigHash(), + InputIndex: 0, + KeyDesc: keychain.KeyDescriptor{ + KeyLocator: l.HtlcKeyLocator, + }, + } + signDesc.SignMethod = input.WitnessV0SignMethod + + rawSigs, err := signer.SignOutputRaw( + ctx, sweepTx, []*lndclient.SignDescriptor{&signDesc}, nil, + ) + if err != nil { + return nil, fmt.Errorf("sign output error: %w", err) + } + sig := rawSigs[0] + + // Add witness stack to the tx input. + sweepTx.TxIn[0].Witness, err = htlc.GenTimeoutWitness(sig) + if err != nil { + return nil, err + } + + return sweepTx, nil +} + +// 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 +} + +// TotalDepositAmount returns the total amount of the deposits. +func (l *StaticAddressLoopIn) TotalDepositAmount() btcutil.Amount { + var total btcutil.Amount + if len(l.Deposits) == 0 { + return total + } + + for _, d := range l.Deposits { + total += d.Value + } + return total +} + +// RemainingPaymentTimeSeconds returns the remaining time in seconds until the +// payment timeout is reached. The remaining time is calculated from the +// initiation time of the swap. If more than the swaps configured payment +// timeout has passed, the remaining time will be negative. +func (l *StaticAddressLoopIn) RemainingPaymentTimeSeconds() int64 { + elapsedSinceInitiation := time.Since(l.InitiationTime).Seconds() + + return int64(l.PaymentTimeoutSeconds) - int64(elapsedSinceInitiation) +} + +// Outpoints returns the wire outpoints of the deposits. +func (l *StaticAddressLoopIn) Outpoints() []wire.OutPoint { + outpoints := make([]wire.OutPoint, len(l.Deposits)) + for i, d := range l.Deposits { + outpoints[i] = wire.OutPoint{ + Hash: d.Hash, + Index: d.Index, + } + } + + return outpoints +} + +func (l *StaticAddressLoopIn) toPrevOuts(deposits []*deposit.Deposit, + pkScript []byte) (map[wire.OutPoint]*wire.TxOut, error) { + + prevOuts := make(map[wire.OutPoint]*wire.TxOut, len(deposits)) + for _, d := range deposits { + outpoint := wire.OutPoint{ + Hash: d.Hash, + Index: d.Index, + } + txOut := &wire.TxOut{ + Value: int64(d.Value), + PkScript: pkScript, + } + if _, ok := prevOuts[outpoint]; ok { + return nil, fmt.Errorf("duplicate outpoint %v", + outpoint) + } + prevOuts[outpoint] = txOut + } + + return prevOuts, nil +} + +// GetState returns the current state of the loop-in swap. +func (l *StaticAddressLoopIn) GetState() fsm.StateType { + l.mu.Lock() + defer l.mu.Unlock() + + return l.state +} + +// SetState sets the current state of the loop-in swap. +func (l *StaticAddressLoopIn) SetState(state fsm.StateType) { + l.mu.Lock() + defer l.mu.Unlock() + + l.state = state +} + +// IsInState returns true if the deposit is in the given state. +func (l *StaticAddressLoopIn) IsInState(state fsm.StateType) bool { + l.mu.Lock() + defer l.mu.Unlock() + + return l.state == state +}